From 487974b3ba11a05c23510348e61e90c5e8cca10b Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:37:49 +0000 Subject: [PATCH 1/4] fix: manage derived state across lifecycle --- .changeset/641-state-lifecycle.md | 5 + docs/diagnostics.md | 12 +- packages/agent-bundle/src/cli.ts | 12 ++ packages/agent-bundle/src/install/doctor.ts | 115 ++++++++++++++---- .../agent-bundle/src/install/state-root.ts | 88 ++++++++++++++ packages/agent-bundle/src/install/surface.ts | 52 ++++++-- .../agent-bundle/src/install/uninstall.ts | 101 ++++++++++----- packages/agent-bundle/tests/doctor.test.ts | 62 +++++++++- .../tests/packed-readonly-state-root.test.ts | 62 +++++++--- packages/agent-bundle/tests/uninstall.test.ts | 49 +++++++- .../en/guide/distribution/installation.mdx | 8 +- website/docs/en/reference/cli.mdx | 14 ++- .../zh/guide/distribution/installation.mdx | 6 +- website/docs/zh/reference/cli.mdx | 12 +- 14 files changed, 491 insertions(+), 107 deletions(-) create mode 100644 .changeset/641-state-lifecycle.md create mode 100644 packages/agent-bundle/src/install/state-root.ts diff --git a/.changeset/641-state-lifecycle.md b/.changeset/641-state-lifecycle.md new file mode 100644 index 000000000..6aebf6819 --- /dev/null +++ b/.changeset/641-state-lifecycle.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Make `uninstall --purge-data` remove the effective framework state root and make `doctor` report its source, existence, writability, and retained pre-#640 state (#641). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 5a7ec4d39..40d490b1c 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -38,7 +38,7 @@ even when no error diagnostic was reported. | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no packed file references; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7103`: the development package build; see below). | -| `AB7300`–`AB7331` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), and the operator `.env` layer of an installed pack (`AB7331`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | +| `AB7300`–`AB7332` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), and retained pre-#640 state (`AB7332`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation or MCP session revision (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route (`/api/discovery`): `AB8215` invalid path, `AB8216` query string or non-`GET` method (400/405), `AB8217` report over the 16 MiB response limit (413), `AB8218` discovery not available (503). | @@ -1349,6 +1349,16 @@ diagnostic. | --- | --- | --- | | `AB7331` | info / warning | Info: an installed copy (or the `--from` bundle) carries `.env` or `.env.local` at its plugin root; the message names the file and its variable count. Warning: the file exists but cannot be read, so the pack's shells skip it at launch — repair its permissions and rerun Doctor. | +## Read-only Doctor legacy state (`AB7332`) + +Doctor resolves each installed copy's effective framework state root from its +canonical code root and declared environment. It reports that root's source, +existence, and writability separately from the pre-#640 in-tree location. + +| Code | Severity | Trigger | +| --- | --- | --- | +| `AB7332` | info | `/state` still exists while the installed artifact resolves framework state elsewhere. Move any state that must be retained, or use `uninstall --purge-data --confirm-purge` to remove both roots. | + ## Read-only runtime identity introspection (`AB7317`–`AB7318`) | Code | Severity | Trigger | diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index f30574baa..7acc5efee 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -471,6 +471,18 @@ const humanDoctor = (result: DoctorReport): string => { host.bundle?.durableState, ].filter((report): report is DoctorDurableStateReport => report !== undefined); const uniqueReports = [...new Map(reports.map((report) => [report.directory, report])).values()]; + for (const report of uniqueReports) { + out.push( + ` state root: ${report.directory} (${report.exists ? 'exists' : 'missing'}, ` + + `${report.writable ? 'writable' : 'not writable'}, ${report.stateSource})\n`, + ); + } + const legacyReports = host.inventory.findings + .map((finding) => finding.legacyDurableState) + .filter((report): report is DoctorDurableStateReport => report !== undefined); + for (const report of [...new Map(legacyReports.map((entry) => [entry.directory, entry])).values()]) { + out.push(` legacy state: ${report.directory} (exists, ${report.writable ? 'writable' : 'not writable'})\n`); + } if (uniqueReports.length > 0) { const stores = uniqueReports.reduce((total, report) => total + report.summary.stores, 0); const bytes = uniqueReports.reduce((total, report) => total + report.summary.bytes, 0); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 9db99d4ea..02fdfd613 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -1,4 +1,5 @@ -import { lstat, readFile, readdir } from 'node:fs/promises'; +import { constants } from 'node:fs'; +import { access, lstat, readFile, readdir } from 'node:fs/promises'; import { createConnection } from 'node:net'; import { homedir } from 'node:os'; import { join, resolve } from 'node:path'; @@ -71,6 +72,7 @@ import { inspectCursorPluginHooks, } from './cursor-hooks-registration.ts'; import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; +import { resolveInstalledStateRoot } from './state-root.ts'; export type DoctorHost = InstallHost; export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable'; @@ -130,6 +132,8 @@ export interface DoctorFinding { /** Git commit of a staged Cursor marketplace repository. */ readonly commit?: string; readonly durableState?: DoctorDurableStateReport; + /** Pre-#640 `/state`, reported separately from the effective state root. */ + readonly legacyDurableState?: DoctorDurableStateReport; /** The operator `.env` layer the installed pack's shells read at launch (#469); names and counts only, never values. */ readonly operatorEnv?: DoctorOperatorEnvReport; /** @@ -243,12 +247,15 @@ export interface DoctorOperatorEnvReport { export interface DoctorDurableStateReport { readonly diagnostics: readonly Diagnostic[]; readonly directory: string; + readonly exists: boolean; readonly findings: readonly DoctorDurableStateStore[]; + readonly stateSource: 'derived' | 'legacy' | 'native'; readonly status: 'known' | 'warnings'; readonly summary: { readonly bytes: number; readonly stores: number; }; + readonly writable: boolean; } export interface DoctorInventory { @@ -531,6 +538,9 @@ const freezeInventory = ( const durableStateReport = ( directory: string, + exists: boolean, + writable: boolean, + stateSource: DoctorDurableStateReport['stateSource'], findings: readonly DoctorDurableStateStore[], diagnostics: readonly Diagnostic[], ): DoctorDurableStateReport => { @@ -538,12 +548,15 @@ const durableStateReport = ( return Object.freeze({ diagnostics: frozenDiagnostics, directory, + exists, findings: Object.freeze(findings.map((finding) => Object.freeze({ ...finding }))), + stateSource, status: frozenDiagnostics.length === 0 ? 'known' : 'warnings', summary: Object.freeze({ bytes: findings.reduce((total, finding) => total + finding.bytes, 0), stores: findings.length, }), + writable, }); }; @@ -623,15 +636,15 @@ const remnantDiagnostic = async (subject: string, path: string, receipt: Install }; const inspectDurableState = async ( - pluginRoot: string, + directory: string, + stateSource: DoctorDurableStateReport['stateSource'], target?: DoctorHost, -): Promise => { - const directory = join(pluginRoot, 'state'); +): Promise => { let entries: readonly string[]; try { entries = (await readdir(directory)).sort((left, right) => left.localeCompare(right)); } catch (error) { - if (isErrno(error, 'ENOENT')) return undefined; + if (isErrno(error, 'ENOENT')) return durableStateReport(directory, false, false, stateSource, [], []); const diagnostics = [diagnostic( 'AB7316', `Durable state directory ${JSON.stringify(directory)} could not be read.`, @@ -639,10 +652,23 @@ const inspectDurableState = async ( 'warning', target, )]; - return durableStateReport(directory, [], diagnostics); + return durableStateReport(directory, true, false, stateSource, [], diagnostics); } const diagnostics: Diagnostic[] = []; + const writable = await access(directory, constants.W_OK).then( + () => true, + () => false, + ); + if (!writable) { + diagnostics.push(diagnostic( + 'AB7316', + `Durable state directory ${JSON.stringify(directory)} is not writable.`, + 'Repair directory permissions before running the plugin; Doctor never opens or repairs state databases.', + 'warning', + target, + )); + } const findings: DoctorDurableStateStore[] = []; for (const file of entries.filter((entry) => entry.endsWith('.sqlite'))) { const path = join(directory, file); @@ -676,7 +702,39 @@ const inspectDurableState = async ( )); } } - return durableStateReport(directory, findings, diagnostics); + return durableStateReport(directory, true, writable, stateSource, findings, diagnostics); +}; + +const inspectInstalledDurableState = async ( + pluginRoot: string, + host: DoctorHost, + environment: Readonly, + home: string, +): Promise<{ + readonly diagnostics: readonly Diagnostic[]; + readonly effective: DoctorDurableStateReport; + readonly legacy?: DoctorDurableStateReport; +}> => { + const resolved = await resolveInstalledStateRoot(pluginRoot, host, environment, home); + const effective = await inspectDurableState(resolved.root, resolved.source, host); + const legacyRoot = join(pluginRoot, 'state'); + if (legacyRoot === resolved.root) { + return { diagnostics: effective.diagnostics, effective }; + } + const legacy = await inspectDurableState(legacyRoot, 'legacy', host); + if (!legacy.exists) return { diagnostics: effective.diagnostics, effective }; + const legacyDiagnostic = diagnostic( + 'AB7332', + `Legacy durable state remains at ${JSON.stringify(legacyRoot)} while this install resolves framework state to ${JSON.stringify(resolved.root)}.`, + 'Run `agent-bundle uninstall --purge-data --confirm-purge` for this install to remove both roots, or move required pre-#640 data before deleting the legacy directory.', + 'info', + host, + ); + return { + diagnostics: freezeDiagnostics([...effective.diagnostics, ...legacy.diagnostics, legacyDiagnostic]), + effective, + legacy, + }; }; /** @@ -939,6 +997,7 @@ const stagingGit = (run: DoctorCommandRunner): CursorStagingGit => async (args, const cursorInventory = async ( home: string, + environment: Readonly, available: boolean, git: CursorStagingGit, platform: NodeJS.Platform, @@ -1034,11 +1093,12 @@ const cursorInventory = async ( const stateOnly = await isRuntimeStateRemnant(path); const remnant = stateOnly || (remnantReceipt !== undefined && isRemnantReceipt(remnantReceipt)); if (remnant) { - const durableState = await inspectDurableState(path, 'cursor'); - if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); + const durableState = await inspectInstalledDurableState(path, 'cursor', environment, home); + diagnostics.push(...durableState.diagnostics); diagnostics.push(await remnantDiagnostic(`Cursor plugin entry ${JSON.stringify(path)}`, path, remnantReceipt)); findings.push({ - ...(durableState === undefined ? {} : { durableState }), + durableState: durableState.effective, + ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), entry, ...(remnantReceipt === undefined ? {} : { name: remnantReceipt.plugin, receipt: receiptSummary(remnantReceipt), version: remnantReceipt.version }), path, @@ -1089,8 +1149,8 @@ const cursorInventory = async ( } diagnostics.push(...staticDiagnostics); if (launch !== undefined) diagnostics.push(...launch.diagnostics); - const durableState = await inspectDurableState(path, 'cursor'); - if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); + const durableState = await inspectInstalledDurableState(path, 'cursor', environment, home); + diagnostics.push(...durableState.diagnostics); const operatorEnv = await inspectOperatorEnv(path, 'cursor'); diagnostics.push(...operatorEnv.diagnostics); const hooks = manifest.manifest === cursorManifestCandidates[0] @@ -1108,7 +1168,8 @@ const cursorInventory = async ( diagnostics.push(migratedReceiptDiagnostic('cursor', join(path, installReceiptFile), receipt)); } findings.push({ - ...(durableState === undefined ? {} : { durableState }), + durableState: durableState.effective, + ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), entry, ...(hooks === undefined ? {} : { hooks: hooks.registration }), operatorEnv, @@ -1213,12 +1274,12 @@ const readWebSurface = async ( * carry `pluginId`/`version` and the pinned cache layout supplies the path). * An unusable listing is reported honestly as unknown (`AB7303`). */ -const publicHostInventory = ( +const publicHostInventory = async ( host: Exclude, listing: PublicHostListing, environment: Readonly, home: string, -): { readonly diagnostics: readonly Diagnostic[]; readonly inventory: DoctorInventory } => { +): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly inventory: DoctorInventory }> => { const unknown = (detail: string) => ({ diagnostics: freezeDiagnostics([diagnostic( 'AB7303', @@ -1239,6 +1300,7 @@ const publicHostInventory = ( return unknown('not JSON'); } const findings: DoctorFinding[] = []; + const diagnostics: Diagnostic[] = []; if (host === 'claude') { if (!Array.isArray(document)) return unknown('not an array'); for (const row of document) { @@ -1257,12 +1319,16 @@ const publicHostInventory = ( // `enabled: false` is a copy the user switched off (`claude plugin disable`): installed, but no // hooks, MCP servers, or skills reach a session until it is enabled again (#476). const enabled = typeof row['enabled'] === 'boolean' ? row['enabled'] : undefined; + const durableState = await inspectInstalledDurableState(row['installPath'], host, environment, home); + diagnostics.push(...durableState.diagnostics); findings.push({ + durableState: durableState.effective, ...(enabled === undefined ? {} : { enabled }), entry: `${row['id']} (${row['scope']})`, ...(errors.length === 0 ? {} : { errors }), name: row['id'].slice(0, row['id'].indexOf('@') === -1 ? undefined : row['id'].indexOf('@')), path: row['installPath'], + ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), state: errors.length > 0 ? 'failed' : enabled === false ? 'disabled' : 'installed', version: row['version'], }); @@ -1277,16 +1343,21 @@ const publicHostInventory = ( const separator = row['pluginId'].indexOf('@'); const name = separator === -1 ? row['pluginId'] : row['pluginId'].slice(0, separator); const marketplace = separator === -1 ? '' : row['pluginId'].slice(separator + 1); + const path = join(publicHostCacheRoot(host, environment, home), marketplace, name, row['version']); + const durableState = await inspectInstalledDurableState(path, host, environment, home); + diagnostics.push(...durableState.diagnostics); findings.push({ + durableState: durableState.effective, entry: row['pluginId'], name, - path: join(publicHostCacheRoot(host, environment, home), marketplace, name, row['version']), + path, + ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), state: 'installed', version: row['version'], }); } } - return { diagnostics: Object.freeze([]), inventory: freezeInventory('known', findings) }; + return { diagnostics: freezeDiagnostics(diagnostics), inventory: freezeInventory('known', findings) }; }; const malformedBundle = ( @@ -2656,10 +2727,10 @@ const doctorHost = async ( return pending; }; const inventoried = host === 'cursor' - ? await cursorInventory(home, probed.probe.status === 'available', git, options.platform ?? process.platform) + ? await cursorInventory(home, environment, probed.probe.status === 'available', git, options.platform ?? process.platform) : probed.probe.status !== 'available' ? { diagnostics: Object.freeze([]), inventory: freezeInventory('skipped') } - : publicHostInventory(host, listing, environment, home); + : await publicHostInventory(host, listing, environment, home); const diagnostics = [...probed.diagnostics, ...inventoried.diagnostics]; // Store receipts are lifecycle evidence Agent Bundle itself wrote, so the store is inventoried from // the filesystem whether or not the host can be probed: malformed and migrated receipts are always @@ -2699,8 +2770,8 @@ const doctorHost = async ( if (checked.finding.lifecycle !== undefined) { diagnostics.push(lifecycleDiagnostic(host, identity.name, identity.version, checked.finding.lifecycle)); } - const durableState = await inspectDurableState(identity.bundleRoot, host); - if (durableState !== undefined) diagnostics.push(...durableState.diagnostics); + const durableState = await inspectDurableState(join(identity.bundleRoot, 'state'), 'legacy', host); + diagnostics.push(...durableState.diagnostics); const operatorEnv = await inspectOperatorEnv(identity.bundleRoot, host); diagnostics.push(...operatorEnv.diagnostics); bundle = Object.freeze({ @@ -2708,7 +2779,7 @@ const doctorHost = async ( ...(staticDiagnostics.some((entry) => entry.severity === 'error') ? { state: 'corrupt' as const } : {}), - ...(durableState === undefined ? {} : { durableState }), + ...(durableState.exists ? { durableState } : {}), operatorEnv, }); } catch (error) { diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts new file mode 100644 index 000000000..8f91729ac --- /dev/null +++ b/packages/agent-bundle/src/install/state-root.ts @@ -0,0 +1,88 @@ +import { readFile, realpath } from 'node:fs/promises'; +import { isAbsolute, join, resolve } from 'node:path'; + +import { + PLUGIN_STATE_ROOT_ENV_ANCHOR, + pluginStateSegment, + userDataStateRoot, +} from '@agent-bundle/runtime'; + +import { isErrno } from '../core/errors.ts'; +import type { InstallHost } from './install.ts'; + +export interface InstalledStateRoot { + readonly root: string; + readonly source: 'derived' | 'native'; +} + +const manifestCandidates = (host: InstallHost): readonly string[] => { + switch (host) { + case 'claude': + return ['.mcp.json']; + case 'codex': + return ['.codex-plugin/mcp.json']; + case 'cursor': + return ['.cursor-plugin/mcp.json', 'mcp.json']; + default: { + const exhaustive: never = host; + throw new TypeError(`Unknown install host ${String(exhaustive)}.`); + } + } +}; + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === 'object' && !Array.isArray(value); + +const declaredStateRoot = async (pluginRoot: string, host: InstallHost): Promise => { + for (const relativePath of manifestCandidates(host)) { + let document: unknown; + try { + document = JSON.parse(await readFile(join(pluginRoot, relativePath), 'utf8')) as unknown; + } catch (error) { + if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) continue; + throw error; + } + if (!isRecord(document) || !isRecord(document['mcpServers'])) continue; + for (const server of Object.values(document['mcpServers'])) { + if (!isRecord(server) || !isRecord(server['env'])) continue; + const declared = server['env'][PLUGIN_STATE_ROOT_ENV_ANCHOR]; + if (typeof declared !== 'string' || declared.trim() === '') continue; + const expanded = declared + .replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) + .replaceAll('${CURSOR_PLUGIN_ROOT}', pluginRoot) + .replaceAll('${PLUGIN_ROOT}', pluginRoot); + if (/\$\{[^}]*\}/u.test(expanded)) continue; + return isAbsolute(expanded) ? resolve(expanded) : resolve(pluginRoot, expanded); + } + } + return undefined; +}; + +export const resolveInstalledStateRoot = async ( + pluginRoot: string, + host: InstallHost, + environment: Readonly, + home: string, +): Promise => { + const canonicalRoot = await realpath(pluginRoot).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return resolve(pluginRoot); + throw error; + }); + const fromManifest = await declaredStateRoot(canonicalRoot, host); + const inherited = environment[PLUGIN_STATE_ROOT_ENV_ANCHOR] ?? ''; + const expandedInherited = inherited.trim() === '' || /\$\{[^}]*\}/u.test(inherited) + ? undefined + : isAbsolute(inherited) ? resolve(inherited) : resolve(canonicalRoot, inherited); + const declared = fromManifest ?? expandedInherited; + return Object.freeze(declared === undefined + ? { root: userDataStateRoot(canonicalRoot, environment, home), source: 'derived' as const } + : { root: declared, source: 'native' as const }); +}; + +export const installedWebDataRoot = async (pluginRoot: string, home: string): Promise => { + const canonicalRoot = await realpath(pluginRoot).catch((error: unknown) => { + if (isErrno(error, 'ENOENT')) return resolve(pluginRoot); + throw error; + }); + return join(home, '.agent-bundle', 'web-data', pluginStateSegment(canonicalRoot)); +}; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 2a4f093a9..c8c1942da 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -361,6 +361,36 @@ const cursorUninstallerSource = (): readonly string[] => [ ' console.log(`${label} ${paths.length} ${paths.length === 1 ? "entry" : "entries"}:`);', ' for (const path of paths) console.log(` ${path}`);', '};', + 'const runtimeStateRoots = async () => {', + ' const canonical = await realpath(destination);', + " const inherited = process.env.AGENT_BUNDLE_STATE_ROOT ?? '';", + " const inheritedStateRoot = inherited.trim() === '' || /\\$\\{[^}]*\\}/u.test(inherited)", + ' ? undefined', + ' : isAbsolute(inherited) ? resolve(inherited) : resolve(canonical, inherited);', + ' let declared;', + " for (const manifest of ['.cursor-plugin/mcp.json', 'mcp.json']) {", + ' let document;', + " try { document = JSON.parse(await readFile(join(canonical, manifest), 'utf8')); }", + " catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) continue; throw error; }", + " const servers = document !== null && typeof document === 'object' && !Array.isArray(document) ? document.mcpServers : undefined;", + " if (servers === null || typeof servers !== 'object' || Array.isArray(servers)) continue;", + ' for (const server of Object.values(servers)) {', + " const env = server !== null && typeof server === 'object' && !Array.isArray(server) ? server.env : undefined;", + " const value = env !== null && typeof env === 'object' && !Array.isArray(env) ? env.AGENT_BUNDLE_STATE_ROOT : undefined;", + " if (typeof value !== 'string' || value.trim() === '') continue;", + " const expanded = value.replaceAll('${CURSOR_PLUGIN_ROOT}', canonical).replaceAll('${PLUGIN_ROOT}', canonical);", + " if (!/\\$\\{[^}]*\\}/u.test(expanded)) declared = isAbsolute(expanded) ? resolve(expanded) : resolve(canonical, expanded);", + ' if (declared !== undefined) break;', + ' }', + ' if (declared !== undefined) break;', + ' }', + " const xdg = process.env.XDG_STATE_HOME ?? '';", + " const stateHome = isAbsolute(xdg) ? join(xdg, 'agent-bundle') : join(homedir(), '.agent-bundle', 'state');", + " const digest = createHash('sha256').update(canonical).digest('hex').slice(0, 16);", + " const name = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(basename(canonical)) ? basename(canonical) : 'plugin';", + ' const segment = `${name}-${digest}`;', + " return [declared ?? inheritedStateRoot ?? join(stateHome, segment), join(canonical, 'state'), join(homedir(), '.agent-bundle', 'web-data', segment)];", + '};', '', "if (uninstall && mode === 'local') {", ' const notInstalled = () => { console.log(`Not installed ${pluginName}@${pluginVersion} for cursor (local mode) at ${destination}`); process.exit(0); };', @@ -421,14 +451,22 @@ const cursorUninstallerSource = (): readonly string[] => [ ' files.push(path);', ' }', ' if (await exists(join(destination, receiptFile))) files.push(join(destination, receiptFile));', - " const stateDirectory = join(destination, 'state');", + " const [effectiveStateDirectory, stateDirectory, webDataDirectory] = await runtimeStateRoots();", + ' const externalDataPaths = [];', + ' for (const path of [effectiveStateDirectory, webDataDirectory]) {', + ' if (path === stateDirectory) continue;', + ' let metadata;', + " try { metadata = await lstat(path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }", + ' if (metadata.isSymbolicLink() || !metadata.isDirectory()) throw unsupported(path);', + ' externalDataPaths.push(path);', + ' }', ' let stateMetadata;', " try { stateMetadata = await lstat(stateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", " if (stateMetadata !== undefined && (stateMetadata.isSymbolicLink() || !stateMetadata.isDirectory())) throw unsupported('state');", ' // A state/ holding nothing is not durable state: pruned like an installer-created directory instead of kept as a remnant.', ' const emptyState = stateMetadata !== undefined && (await readdir(stateDirectory)).length === 0 ? stateDirectory : undefined;', - ' const dataPaths = stateMetadata === undefined || emptyState !== undefined ? [] : [stateDirectory];', - " const dataKinds = stateMetadata === undefined || emptyState !== undefined ? [] : ['state/ (state kernel, notices journal)'];", + ' const dataPaths = [...externalDataPaths, ...(stateMetadata === undefined || emptyState !== undefined ? [] : [stateDirectory])];', + " const dataKinds = [...externalDataPaths.map((path) => path === effectiveStateDirectory ? `framework state root ${path}` : `web-data directory ${path}`), ...(stateMetadata === undefined || emptyState !== undefined ? [] : ['legacy state/ (state kernel, notices journal)'])];", ' // The receipt\'s cursorExpansion records the PLUGIN_DATA directory this installer created for the copy (spec 9.1). Only', ' // the directory at this home\'s own plugin-data location is receipt-owned; a written one is durable state (kept or', ' // purged like state/), an empty one is an installer-created directory that is pruned, a recorded path elsewhere is left alone.', @@ -459,9 +497,9 @@ const cursorUninstallerSource = (): readonly string[] => [ ' : purgeData', " ? `Durable runtime state — ${dataKinds.join(' and ')} — is removed (--purge-data --confirm-purge).${foreignNote}`", " : `Durable runtime state — ${dataKinds.join(' and ')} — is kept; pass --purge-data --confirm-purge to remove it.${foreignNote}`;", - ' // A written PLUGIN_DATA kept by --keep-data lives outside the plugin root, so the root stays (with a remnant receipt', - ' // carrying the expansion) to keep that data receipt-owned for a later purge.', - ' const keepRoot = !purgeData && dataPaths.includes(pluginData);', + ' // External state kept by --keep-data needs the remnant receipt and canonical install path so a later purge', + ' // derives and removes the same root even though no plugin content remains.', + ' const keepRoot = !purgeData && dataPaths.some((path) => path !== stateDirectory);', ' const directories = [', ' ...ownedDirectories.map((directory) => join(destination, directory)),', ' ...(keepRoot ? [] : [destination]),', @@ -663,7 +701,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { "import { createHash, randomUUID } from 'node:crypto';", "import { cp, lstat, mkdir, mkdtemp, open, readFile, readdir, realpath, rename, rm, rmdir, writeFile } from 'node:fs/promises';", "import { homedir } from 'node:os';", - "import { basename, dirname, join, relative, resolve, sep } from 'node:path';", + "import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';", "import { fileURLToPath } from 'node:url';", '', `const pluginName = ${name};`, diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index c88154922..d00bcd6bd 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -61,6 +61,7 @@ import { type InstallRegistration, type StoredInstallReceipt, } from './receipt.ts'; +import { installedWebDataRoot, resolveInstalledStateRoot } from './state-root.ts'; /** * `agent-bundle uninstall ` (#101): the receipt-owned reverse of @@ -438,10 +439,8 @@ interface CursorLocalData { readonly emptyPluginData?: string; /** A `state/` directory holding nothing: not durable state, so it is pruned rather than kept alive as a remnant. */ readonly emptyState?: string; - /** Whether any durable state (state/ or a written PLUGIN_DATA) exists. */ + /** Whether any durable state root exists. */ readonly present: boolean; - /** A written `PLUGIN_DATA` directory kept by `--keep-data`: it lives outside the plugin root, so the root must survive to carry it. */ - readonly retainedPluginData?: string; readonly report: UninstallDataReport; } @@ -451,11 +450,22 @@ const cursorLocalData = async ( receipt: InstallReceipt | undefined, cursorRoot: string, plugin: string, + environment: Readonly, + home: string, ): Promise => { const stateDirectory = join(destination, 'state'); + const effectiveState = await resolveInstalledStateRoot(destination, 'cursor', environment, home); + const webData = await installedWebDataRoot(destination, home); const paths: string[] = []; const kinds: string[] = []; let emptyState: string | undefined; + if ( + effectiveState.root !== stateDirectory && + await realDirectory(effectiveState.root, 'cursor') !== undefined + ) { + paths.push(effectiveState.root); + kinds.push(`${effectiveState.source} framework state root ${effectiveState.root}`); + } if (await realDirectory(stateDirectory, 'cursor') !== undefined) { if ((await readdir(stateDirectory)).length === 0) { emptyState = stateDirectory; @@ -464,6 +474,10 @@ const cursorLocalData = async ( kinds.push('state/ (state kernel, notices journal)'); } } + if (await realDirectory(webData, 'cursor') !== undefined) { + paths.push(webData); + kinds.push(`web-data directory ${webData}`); + } // The receipt's cursorExpansion records the PLUGIN_DATA directory the installer created for this copy; only the // directory at this home's own plugin-data location is receipt-owned — a recorded path elsewhere is left alone. const recorded = receipt?.cursorExpansion?.pluginData; @@ -502,7 +516,6 @@ const cursorLocalData = async ( }), }; } - const retainedPluginData = policy === 'purge' ? undefined : paths.find((path) => path === expected); return { ...(emptyPluginData === undefined ? {} : { emptyPluginData }), ...(emptyState === undefined ? {} : { emptyState }), @@ -515,7 +528,6 @@ const cursorLocalData = async ( paths: Object.freeze(paths), policy, }), - ...(retainedPluginData === undefined ? {} : { retainedPluginData }), }; }; @@ -535,6 +547,8 @@ const uninstallCursorLocal = async ( version: identity.version, } as const; const cursorRoot = join(options.home ?? homedir(), '.cursor'); + const home = options.home ?? homedir(); + const environment = options.environment ?? process.env; const destination = join(cursorRoot, 'plugins', 'local', identity.plugin); const receiptPath = join(destination, installReceiptFile); const notInstalled = (): UninstallResult => Object.freeze({ @@ -558,7 +572,15 @@ const uninstallCursorLocal = async ( const owned = new Set(ownership.files); // A symlinked ancestor would let a leaf-only delete reach outside the plugin root: refused before any change. await assertRealAncestors(destination, ownership.files); - const data = await cursorLocalData(destination, policy, ownership.receipt, cursorRoot, identity.plugin); + const data = await cursorLocalData( + destination, + policy, + ownership.receipt, + cursorRoot, + identity.plugin, + environment, + home, + ); const files: string[] = []; for (const file of ownership.files) { const path = join(destination, file); @@ -573,9 +595,10 @@ const uninstallCursorLocal = async ( files.push(path); } if (ownership.receipt !== undefined || await exists(receiptPath)) files.push(receiptPath); - // A written PLUGIN_DATA directory kept by --keep-data lives outside the plugin root, so the root stays (with a - // remnant receipt carrying the expansion) to keep that data receipt-owned for a later purge. - const keepRoot = data.retainedPluginData !== undefined; + // External state kept by --keep-data needs the remnant receipt and canonical install path so a later purge can + // derive and remove the same root even though no plugin content remains. + const keepRoot = policy === 'keep' && + data.report.paths.some((path) => path !== join(destination, 'state')); const pluginDataRecorded = ownership.receipt?.cursorExpansion?.pluginData === cursorPluginDataDirectory(cursorRoot, identity.plugin); const directoryCandidates = [ ...ownership.directories.map((directory) => join(destination, directory)), @@ -653,7 +676,8 @@ const uninstallCursorLocal = async ( // directory instead of calling it corrupt. A reinstall fills it back in as an `installed`. await writeInstallReceipt(destination, createInstallReceipt({ // A kept PLUGIN_DATA directory stays receipt-owned through the remnant's expansion record. - ...(ownership.receipt?.cursorExpansion === undefined || data.retainedPluginData === undefined + ...(ownership.receipt?.cursorExpansion === undefined || + !data.report.paths.includes(ownership.receipt.cursorExpansion.pluginData) ? {} : { cursorExpansion: ownership.receipt.cursorExpansion }), host: 'cursor', @@ -1069,26 +1093,34 @@ const publicHostData = async ( hostRoot: string, id: string, sharedWith: readonly string[] | 'unknown', + environment: Readonly, + home: string, ): Promise => { - if (host === 'codex') { - return Object.freeze({ - detail: policy === 'purge' - ? '`codex plugin remove` deletes the cached plugin tree, state/ included; nothing further to purge.' - : '`codex plugin remove` deletes the cached plugin tree, state/ included; Codex 0.147.0 exposes no keep-data option, so durable state cannot be preserved through a Codex uninstall.', - outcome: policy === 'purge' ? 'removed-by-host' : 'unavailable', - paths: Object.freeze(entry === undefined ? [] : [join(entry.installPath, 'state')]), - policy, - }); - } const paths: string[] = []; - if (entry !== undefined && await realDirectory(join(entry.installPath, 'state'), host) !== undefined) { - paths.push(join(entry.installPath, 'state')); + if (entry !== undefined) { + const legacyStateRoot = join(entry.installPath, 'state'); + const effectiveState = await resolveInstalledStateRoot(entry.installPath, host, environment, home); + for (const path of [effectiveState.root, legacyStateRoot, await installedWebDataRoot(entry.installPath, home)]) { + if (!paths.includes(path) && await realDirectory(path, host) !== undefined) paths.push(path); + } + } + if (host === 'claude') { + const dataDirectory = join(hostRoot, 'plugins', 'data', id); + if (await realDirectory(dataDirectory, host) !== undefined) paths.push(dataDirectory); } - const dataDirectory = join(hostRoot, 'plugins', 'data', id); - if (await realDirectory(dataDirectory, host) !== undefined) paths.push(dataDirectory); if (paths.length === 0) { + if (host === 'codex' && entry !== undefined) { + return Object.freeze({ + detail: policy === 'purge' + ? '`codex plugin remove` deletes the cached plugin tree; no external framework state or web-data exists.' + : '`codex plugin remove` deletes the cached plugin tree and Codex exposes no keep-data option; no external framework state or web-data exists to preserve.', + outcome: policy === 'purge' ? 'removed-by-host' : 'unavailable', + paths: Object.freeze([]), + policy, + }); + } return Object.freeze({ - detail: 'No durable runtime state exists (no state/ under the cached copy and no plugins/data entry).', + detail: 'No durable runtime state exists for the installed copy.', outcome: 'absent', paths: Object.freeze([]), policy, @@ -1108,9 +1140,11 @@ const publicHostData = async ( } return Object.freeze({ detail: policy === 'purge' - ? 'Durable runtime state is removed after `claude plugin uninstall --keep-data` returns (--purge-data --confirm-purge).' - : '`claude plugin uninstall --keep-data` orphans the cached copy (state/ included) for Claude\'s ~14-day grace period and preserves plugins/data; pass --purge-data --confirm-purge to remove both now.', - outcome: policy === 'purge' ? 'purged' : 'retained-by-host', + ? `Durable runtime state is removed after the ${host} uninstall returns (--purge-data --confirm-purge).` + : host === 'claude' + ? '`claude plugin uninstall --keep-data` orphans the cached copy for Claude\'s ~14-day grace period; Agent Bundle preserves the effective framework state root, legacy state/, web-data, and plugins/data.' + : '`codex plugin remove` deletes the cached plugin tree, but Agent Bundle preserves the external framework state root and web-data.', + outcome: policy === 'purge' ? 'purged' : host === 'claude' ? 'retained-by-host' : 'kept', paths: Object.freeze(paths), policy, }); @@ -1254,7 +1288,16 @@ const uninstallPublicCli = async ( 'carry that claim, so after this uninstall the marketplace counts as user-owned: remove it by hand once nothing installs from it.' : ` The marketplace registration claim ${planned ? 'would move' : 'moves'} to receipt ${ownershipHeir.path} so the last ` + 'uninstall can still remove it.'; - const data = await publicHostData(host, policy, entry, hostRoot, id, dependents === 'unknown' ? 'unknown' : dependents.sameOtherScopes); + const data = await publicHostData( + host, + policy, + entry, + hostRoot, + id, + dependents === 'unknown' ? 'unknown' : dependents.sameOtherScopes, + environment, + home, + ); const registrations: UninstallRegistrationReport[] = []; if (pluginRegistration !== undefined) { registrations.push(Object.freeze({ diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 9acfcb39f..06671e822 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { expect, it } from '@rstest/core'; +import { userDataStateRoot } from '@agent-bundle/runtime'; import { createDefaultRegistry } from '../src/adapters/registry.ts'; import type { TargetArtifactWrite } from '../src/adapters/types.ts'; @@ -505,7 +506,9 @@ it('accepts a versionless Cursor inventory manifest as installed', async () => { it('inventories durable SQLite stores and sidecars without opening them', async () => { const fixture = await temporaryDoctor(); const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'stateful'); - const stateRoot = join(pluginRoot, 'state'); + const environment = { XDG_STATE_HOME: join(fixture.root, 'state-home') }; + const stateRoot = userDataStateRoot(pluginRoot, environment, fixture.home); + const legacyStateRoot = join(pluginRoot, 'state'); const store = 'project-tasks-0123456789abcdef.sqlite'; try { await Promise.all([ @@ -514,6 +517,7 @@ it('inventories durable SQLite stores and sidecars without opening them', async { name: 'stateful', version: '1.0.0' }, ), mkdir(stateRoot, { recursive: true }), + mkdir(legacyStateRoot, { recursive: true }), ]); await Promise.all([ writeFile(join(stateRoot, store), 'database'), @@ -524,6 +528,7 @@ it('inventories durable SQLite stores and sidecars without opening them', async const report = await runDoctor({ endpointDirectory: fixture.endpointDirectory, + environment, home: fixture.home, hosts: ['cursor'], }); @@ -532,6 +537,7 @@ it('inventories durable SQLite stores and sidecars without opening them', async ); expect(finding?.durableState).toMatchObject({ directory: stateRoot, + exists: true, findings: [{ bytes: 15, file: store, @@ -540,18 +546,62 @@ it('inventories durable SQLite stores and sidecars without opening them', async }], status: 'known', summary: { bytes: 15, stores: 1 }, + writable: true, }); + expect(report.diagnostics).toEqual(expect.arrayContaining([ + expect.objectContaining({ + code: 'AB7332', + message: expect.stringContaining(legacyStateRoot), + }), + ])); const human = captureCliTerminal(); const humanCode = await runCli(['doctor'], human.output, { runDoctor: async () => report }); expect(humanCode).toBe(0); expect(human.stdout()).toContain('durable state: 1 store, 15 B'); + expect(human.stdout()).toContain(`state root: ${stateRoot} (exists, writable, derived)`); const json = captureCliTerminal(); await runCli(['doctor', '--json'], json.output, { runDoctor: async () => report }); expect(JSON.parse(json.stdout()).hosts[0].inventory.findings[0].durableState).toMatchObject({ findings: [{ bytes: 15, file: store }], + exists: true, summary: { bytes: 15, stores: 1 }, + writable: true, + }); + } finally { + await fixture.cleanup(); + } +}); + +it('reports a missing derived state root and a declared state-root override', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'configured-state'); + const declaredStateRoot = join(fixture.root, 'declared-state'); + try { + await Promise.all([ + writeJson(join(pluginRoot, '.cursor-plugin/plugin.json'), { name: 'configured-state', version: '1.0.0' }), + writeJson(join(pluginRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + configured: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: declaredStateRoot }, + }, + }, + }), + ]); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + const finding = hostReport(report, 'cursor').inventory.findings.find((entry) => entry.entry === 'configured-state'); + expect(finding?.durableState).toMatchObject({ + directory: declaredStateRoot, + exists: false, + findings: [], + summary: { bytes: 0, stores: 0 }, + writable: false, }); } finally { await fixture.cleanup(); @@ -1154,7 +1204,7 @@ it('inventories Claude and Codex installs from their pinned plugin list --json v hosts: ['claude', 'codex'], }); expect(report.diagnostics.some((entry) => entry.code === 'AB7303')).toBe(false); - expect(hostReport(report, 'claude').inventory).toEqual({ + expect(hostReport(report, 'claude').inventory).toMatchObject({ findings: [ { enabled: true, @@ -1175,7 +1225,7 @@ it('inventories Claude and Codex installs from their pinned plugin list --json v ], status: 'known', }); - expect(hostReport(report, 'codex').inventory).toEqual({ + expect(hostReport(report, 'codex').inventory).toMatchObject({ findings: [{ entry: 'beta@beta-marketplace', name: 'beta', @@ -1667,7 +1717,7 @@ it('reports a Claude copy the host refused to load as load-failed (AB7325) inste state: 'registered', }); expect(host.bundle?.comparison).not.toHaveProperty('installedContentHash'); - expect(host.inventory).toEqual({ + expect(host.inventory).toMatchObject({ findings: [{ enabled: true, entry: 'doctor-fixture@doctor-fixture-marketplace (user)', @@ -1730,7 +1780,7 @@ it('reports an installed-but-disabled Claude copy as disabled (AB7327) with the }, state: 'registered', }); - expect(host.inventory).toEqual({ + expect(host.inventory).toMatchObject({ findings: [{ enabled: false, entry: 'doctor-fixture@doctor-fixture-marketplace (project)', @@ -2248,7 +2298,7 @@ it('explains a Cursor directory holding only preserved runtime state instead of await uninstallBundle({ from: bundle, home: fixture.home, host: 'cursor' }); const remnant = hostReport(await doctor(), 'cursor'); expect(remnant.inventory.findings).toEqual([expect.objectContaining({ - durableState: expect.objectContaining({ summary: { bytes: 8, stores: 1 } }), + legacyDurableState: expect.objectContaining({ summary: { bytes: 8, stores: 1 } }), name: 'doctor-fixture', path: destination, receipt: expect.objectContaining({ mode: 'local' }), diff --git a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts index 5a6d5fc26..5401a1444 100644 --- a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts +++ b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts @@ -1,5 +1,5 @@ import { execFile as executeFile } from 'node:child_process'; -import { chmod, cp, mkdtemp, readdir, readFile, rm, stat } from 'node:fs/promises'; +import { chmod, cp, mkdir, 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'; @@ -79,6 +79,9 @@ it('serves a state-writing tool from a read-only installed artifact without writ const consumer = await mkdtemp(join(tmpdir(), 'agent-bundle-packed-readonly-state-')); const project = join(consumer, 'project'); const artifact = join(project, 'artifact'); + const home = join(project, '.home'); + const cursorRoot = join(home, '.cursor'); + const installedRoot = join(cursorRoot, 'plugins', 'local', pluginName); let readOnly = false; try { @@ -98,12 +101,20 @@ it('serves a state-writing tool from a read-only installed artifact without writ }); const deletedSource = await removeProjectSource({ extraPaths: ['views'], projectRoot: project }); expect(deletedSource.removed).toEqual(['agent-bundle.config.ts', 'src', 'views']); + const env = stringEnvironment(installedEnvironment()); + delete env['AGENT_BUNDLE_PLUGIN_ROOT']; + delete env['AGENT_BUNDLE_STATE_ROOT']; + env['HOME'] = home; + await mkdir(cursorRoot, { recursive: true }); + const installer = join(artifact, 'install.mjs'); + expect((await execFile(process.execPath, [installer], { cwd: artifact, env })).stdout) + .toContain(`Installed ${pluginName}`); // 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 }); + await chmodTree(installedRoot, { directory: 0o555, file: 0o444 }); readOnly = true; - const listingBefore = await treeListing(artifact); + const listingBefore = await treeListing(installedRoot); expect(listingBefore).toEqual(expect.arrayContaining([ expect.stringMatching(new RegExp(`^bin/${pluginName}\\.mjs \\d+$`, 'u')), 'mcp/', @@ -113,9 +124,6 @@ it('serves a state-writing tool from a read-only installed artifact without writ // 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?`); @@ -124,18 +132,18 @@ it('serves a state-writing tool from a read-only installed artifact without writ // 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 webManifest = await readWebManifest(join(installedRoot, '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 }); + const launch = await resolveWebLaunch({ app: declaredApp, env, pluginRoot: installedRoot }); expect(launch.command).toBe(process.execPath); - expect(launch.cwd).toBe(artifact); - expect(launch.env['AGENT_BUNDLE_PLUGIN_ROOT']).toBe(artifact); + expect(launch.cwd).toBe(installedRoot); + expect(launch.env['AGENT_BUNDLE_PLUGIN_ROOT']).toBe(installedRoot); 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); + expect(entry.startsWith(join(installedRoot, 'mcp') + '/')).toBe(true); const openSession = () => openPackedMcpServer({ args, cwd: launch.cwd, @@ -165,9 +173,9 @@ it('serves a state-writing tool from a read-only installed artifact without writ // 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(await treeListing(installedRoot)).toEqual(listingBefore); + expect(await exists(join(installedRoot, 'state'))).toBe(false); + const stateRoot = userDataStateRoot(installedRoot, launch.env); expect(stateRoot.startsWith(join(stateHome, 'agent-bundle') + '/')).toBe(true); expect(await readdir(stateRoot)).toEqual(expect.arrayContaining([ expect.stringMatching(/\.sqlite$/u), @@ -176,7 +184,7 @@ it('serves a state-writing tool from a read-only installed artifact without writ // 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 bin = join(installedRoot, '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' }], @@ -195,11 +203,27 @@ it('serves a state-writing tool from a read-only installed artifact without writ } 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'); + expect(await treeListing(installedRoot)).toEqual(listingBefore); + expect(await exists(join(installedRoot, 'state'))).toBe(false); + expect(await readFile(join(installedRoot, 'agent-bundle.manifest.json'), 'utf8')).not.toContain('AGENT_BUNDLE_STATE_ROOT'); + + await chmodTree(installedRoot, { directory: 0o755, file: 0o644 }); + readOnly = false; + const kept = await execFile(process.execPath, [installer, '--uninstall', '--keep-data'], { cwd: artifact, env }); + expect(kept.stdout).toContain(`Data (keep): kept`); + expect(kept.stdout).toContain(stateRoot); + expect(await exists(stateRoot)).toBe(true); + await execFile(process.execPath, [installer], { cwd: artifact, env }); + const purged = await execFile( + process.execPath, + [installer, '--uninstall', '--purge-data', '--confirm-purge'], + { cwd: artifact, env }, + ); + expect(purged.stdout).toContain(`Data (purge): purged`); + expect(purged.stdout).toContain(stateRoot); + expect(await exists(stateRoot)).toBe(false); } finally { - if (readOnly) await chmodTree(artifact, { directory: 0o755, file: 0o644 }); + if (readOnly) await chmodTree(installedRoot, { directory: 0o755, file: 0o644 }); await rm(consumer, { force: true, recursive: true }); } }, 300_000); diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index 4b931f246..a77a48714 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { expect, it } from '@rstest/core'; +import { userDataStateRoot } from '@agent-bundle/runtime'; import { runCli } from '../src/cli.ts'; import { DiagnosticError } from '../src/core/diagnostics.ts'; @@ -154,7 +155,9 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o const cursorRoot = join(fixture.home, '.cursor'); await mkdir(join(cursorRoot, 'plugins', 'local'), { recursive: true }); const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); - const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + const environment = { XDG_STATE_HOME: join(fixture.cleanupRoot, 'state-home') }; + const options = { environment, from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + const derivedStateRoot = userDataStateRoot(destination, environment, fixture.home); try { const before = await snapshotTree(fixture.home); await installBundle(options); @@ -162,6 +165,8 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect((await readInstallReceipt(destination))?.hostDirectories).toEqual([]); await mkdir(join(destination, 'state')); await writeFile(join(destination, 'state', 'plugin.sqlite'), 'durable\n'); + await mkdir(derivedStateRoot, { recursive: true }); + await writeFile(join(derivedStateRoot, 'plugin.sqlite'), 'derived\n'); await writeFile(join(destination, 'operator-notes.md'), 'mine\n'); // Unowned directories that hold nothing retained survive too (the prune only touches owned directories): // one at the root and one nested inside an owned directory that would otherwise be pruned. @@ -186,13 +191,14 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect(keepPlan.retained).toEqual(['operator-notes.md', 'scratch/', 'skills/drafts/']); // Purging state/ still leaves the note, so the root survives that plan too; the purged directory is listed as one. const purgePlan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); - expect(purgePlan.removed.directories[0]).toBe(join(destination, 'state')); + expect(purgePlan.data.paths).toEqual([derivedStateRoot, join(destination, 'state')]); + expect(purgePlan.removed.directories.slice(0, 2)).toEqual([derivedStateRoot, join(destination, 'state')]); expect(purgePlan.removed.directories).not.toContain(destination); expect(purgePlan.removed.files).not.toContain(join(destination, 'state')); const kept = await uninstallBundle({ ...options, keepData: true }); expect(kept).toMatchObject({ - data: { outcome: 'kept', paths: [join(destination, 'state')], policy: 'keep' }, + data: { outcome: 'kept', paths: [derivedStateRoot, join(destination, 'state')], policy: 'keep' }, remnantReceipt: join(destination, installReceiptFile), retained: ['operator-notes.md', 'scratch/', 'skills/drafts/'], state: 'uninstalled', @@ -204,6 +210,7 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect(await readdir(join(destination, 'skills'))).toEqual(['drafts']); expect(await readInstallReceipt(destination)).toMatchObject({ files: [], hostDirectories: [], mode: 'local', registrations: [] }); expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); + expect(await readFile(join(derivedStateRoot, 'plugin.sqlite'), 'utf8')).toBe('derived\n'); expect(formatUninstallResult(kept)).toContain('Retained 3 unowned entries'); expect(formatUninstallResult(kept)).toContain('Remnant receipt:'); @@ -217,14 +224,15 @@ it('keeps Cursor runtime state and unowned entries by default and purges state o expect(await readFile(join(destination, 'state', 'plugin.sqlite'), 'utf8')).toBe('durable\n'); const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); expect(purged).toMatchObject({ - data: { outcome: 'purged', paths: [join(destination, 'state')], policy: 'purge' }, + data: { outcome: 'purged', paths: [derivedStateRoot, join(destination, 'state')], policy: 'purge' }, retained: [], state: 'uninstalled', }); expect(purged.remnantReceipt).toBeUndefined(); // A purged state/ tree is a directory and is reported as one, ahead of the pruned owned directories. - expect(purged.removed.directories[0]).toBe(join(destination, 'state')); + expect(purged.removed.directories.slice(0, 2)).toEqual([derivedStateRoot, join(destination, 'state')]); expect(purged.removed.files).not.toContain(join(destination, 'state')); + await expect(readdir(derivedStateRoot)).rejects.toMatchObject({ code: 'ENOENT' }); expect(diffTreeSnapshots(before, await snapshotTree(fixture.home))).toEqual({ added: [], changed: [], removed: [] }); } finally { await rm(fixture.cleanupRoot, { force: true, recursive: true }); @@ -392,6 +400,37 @@ it('keeps created host directories receipt-owned across a --keep-data cycle in a } }); +it('purges AGENT_BUNDLE_STATE_ROOT from the installed host manifest', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const declaredStateRoot = join(fixture.cleanupRoot, 'declared-state'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + writeJson(join(fixture.bundleRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: declaredStateRoot }, + }, + }, + }), + ]); + await installBundle(options); + await mkdir(declaredStateRoot, { recursive: true }); + await writeFile(join(declaredStateRoot, 'plugin.sqlite'), 'declared\n'); + const plan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); + expect(plan.data.paths).toEqual([declaredStateRoot]); + expect(plan.removed.directories).toContain(declaredStateRoot); + const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(purged.data).toMatchObject({ outcome: 'purged', paths: [declaredStateRoot] }); + await expect(readdir(declaredStateRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('refuses Cursor local uninstalls without proof of ownership unless forced, and foreign directories always', async () => { const fixture = await createFixture('cursor'); const cursorRoot = join(fixture.home, '.cursor'); diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 150dd56c2..273c21ceb 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -105,7 +105,7 @@ this plugin's installer did not place — is refused either way. Cursor copies c 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 +location); `uninstall --purge-data --confirm-purge` removes it for the installed code root. 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. @@ -187,9 +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. 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 +as retained. The effective framework state root, derived web-data, legacy `state/`, and for a +Cursor copy of an Agent Plugins pack the `PLUGIN_DATA` directory the receipt records are 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 d02c90042..6094483a1 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -206,8 +206,8 @@ 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 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 the legacy or in-place durable runtime state (`state/`, recorded `PLUGIN_DATA`); the derived `~/.agent-bundle/state/-` root stays. Refused (`AB7008`) without `--confirm-purge`. | +| `--keep-data` | on | Keep the effective framework state root (`AGENT_BUNDLE_STATE_ROOT`, else `~/.agent-bundle/state/-` or `$XDG_STATE_HOME/agent-bundle/-`), derived web-data, legacy `state/`, and a recorded Cursor `PLUGIN_DATA` directory. This is the default; the flag makes it explicit. | +| `--purge-data` | off | Remove those durable-data roots for the exact installed code root. 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. | @@ -221,10 +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 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 +orphaned for Claude's ~14-day grace period; a purge removes the effective framework state root, +derived web-data, legacy `state/`, and `plugins/data//`); Codex keeps external state by default +and removes it on a confirmed purge while `codex plugin remove` deletes the cached tree. 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`. @@ -258,6 +257,9 @@ surface exposes it (`AB7330`). It inventories the Agent Bundle receipt store und and warns about receipts the host no longer honours (`AB7328`), and reports receipts written before format 2 as migrated (`AB7329`). A Cursor directory holding only preserved runtime state from `uninstall --keep-data` is reported `missing` with an `AB7307` info, not corrupt or foreign. +For every installed copy Doctor reports the resolved framework state root, its `native` or +`derived` source, whether it exists, and whether it is writable. A pre-#640 +`/state` is reported separately and flagged with `AB7332`. ## validate diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index fea62a5e7..7a060e428 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -88,7 +88,7 @@ node ./install.mjs 插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目, `--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在 `~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置),`uninstall` -不会删除它。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, +配合 `--purge-data --confirm-purge` 会按已安装代码根删除它。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, 因为 `plugin update` 受版本门控;Codex 先 `codex plugin remove` 再 `add`。输出的 `INSTALL.md` 按宿主记录了 同样的步骤。 @@ -155,8 +155,8 @@ 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 -包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录)除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 +归属的内容,只撤销它记录的注册——绝不多删;非归属条目会被列为保留。有效框架状态根、推导出的 web-data、旧版 `state/`,以及 Agent Plugins +包的 Cursor 副本在回执中记录的 `PLUGIN_DATA` 目录,除非传入 `--purge-data --confirm-purge` 否则保留,且结果如实说明宿主自行决定而 Agent Bundle 无法左右的部分(Claude 把缓存 副本标为 orphaned 并保留约 14 天;Codex 删除缓存树且没有 keep-data 选项)。缺少回执(`AB7009`)或内容不匹配 (`AB7007`)会被拒绝,除非 `--force`;属于另一个插件的目录无论如何都被拒绝;再次运行是 `not-installed` 空操作。 格式 2 之前写入的回执会在补全生命周期字段后读取并给出诊断(`AB7329`),绝不被拒绝。 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 9bbd1f448..e88b49396 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -197,8 +197,8 @@ 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` 目录)。这是默认行为;该标志只是显式声明。(本发行版构建的产物把框架状态放在 `~/.agent-bundle/state/-`,`uninstall` 不会删除它。) | -| `--purge-data` | 关闭 | 同时删除旧版或就地的持久运行时状态(`state/`、回执记录的 `PLUGIN_DATA`);推导出的 `~/.agent-bundle/state/-` 保留。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | +| `--keep-data` | 开启 | 保留有效框架状态根(`AGENT_BUNDLE_STATE_ROOT`,否则为 `~/.agent-bundle/state/-` 或 `$XDG_STATE_HOME/agent-bundle/-`)、推导出的 web-data、旧版 `state/`,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明。 | +| `--purge-data` | 关闭 | 删除与该已安装代码根精确对应的上述持久数据根。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | | `--force` | 关闭 | 在没有回执(旧版 Cursor 副本、仅宿主侧的安装)或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。回执或清单指向另一个插件时无论如何都会被拒绝。 | | `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | @@ -209,9 +209,9 @@ 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//`,但不会删除当前的 `~/.agent-bundle/state/-` 框架状态);Codex 为 `removed-by-host` / `unavailable`(`codex plugin remove` 删除缓存树且没有 -keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 +Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内被标为 orphaned;purge 会删除有效框架状态根、 +推导出的 web-data、旧版 `state/` 与 `plugins/data//`);Codex 默认保留外部状态,在确认 purge 时删除它, +而 `codex plugin remove` 会删除缓存树。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 `--uninstall`,并支持 `--mode`、`--keep-data`、`--purge-data --confirm-purge`、`--force` 与 `--plan`。 ## doctor @@ -238,6 +238,8 @@ keep-data 选项)。相对包的安装器 bin 接受带同样标志的 `uninst 的原因(`AB7330`)。它还清点每个宿主根目录下的 Agent Bundle 回执仓库,对宿主已不再认可的回执发出警告(`AB7328`),并把 格式 2 之前写入的回执报告为已迁移(`AB7329`)。仅包含 `uninstall --keep-data` 所保留运行时状态的 Cursor 目录会以 `AB7307` info 报告为 `missing`,而不是 corrupt 或 foreign。 +对于每份已安装副本,Doctor 会报告解析后的框架状态根、其 `native` 或 `derived` 来源、是否存在以及是否可写。 +升级 #640 之前留下的 `/state` 会单独报告,并以 `AB7332` 标记。 ## validate From 15c2217d448586a4268527f596f9c7eb39264120 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:52:11 +0000 Subject: [PATCH 2/4] fix: preserve state roots through keep data --- .changeset/641-state-lifecycle.md | 2 +- docs/diagnostics.md | 2 +- packages/agent-bundle/README.md | 18 +++++++------ .../adapters/capabilities/codex-0.147.0.json | 2 +- packages/agent-bundle/src/install/doctor.ts | 6 +++-- packages/agent-bundle/src/install/receipt.ts | 26 +++++++++++++++++++ .../agent-bundle/src/install/state-root.ts | 9 ++----- packages/agent-bundle/src/install/surface.ts | 18 ++++++++++--- .../agent-bundle/src/install/uninstall.ts | 21 ++++++++++++--- .../tests/packed-readonly-state-root.test.ts | 1 - packages/agent-bundle/tests/uninstall.test.ts | 24 ++++++++++++++--- .../en/guide/distribution/installation.mdx | 2 +- .../zh/guide/distribution/installation.mdx | 2 +- 13 files changed, 100 insertions(+), 33 deletions(-) diff --git a/.changeset/641-state-lifecycle.md b/.changeset/641-state-lifecycle.md index 6aebf6819..98e3cf57d 100644 --- a/.changeset/641-state-lifecycle.md +++ b/.changeset/641-state-lifecycle.md @@ -2,4 +2,4 @@ "agent-bundle": patch --- -Make `uninstall --purge-data` remove the effective framework state root and make `doctor` report its source, existence, writability, and retained pre-#640 state (#641). +Make `uninstall --purge-data` remove the effective framework state root and make `doctor` report its source, existence, writability, `AB7316` permission failures, and `AB7332` retained pre-#640 state (#641). diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 40d490b1c..8e6e344bb 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1334,7 +1334,7 @@ SQLite lock or shared-memory files. | Code | Severity | Trigger | | --- | --- | --- | -| `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Repair permissions and rerun Doctor; Doctor never repairs state. | +| `AB7316` | warning | An installed bundle's effective or legacy state directory is not writable, or the directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Repair permissions and rerun Doctor; Doctor never repairs state. | ## Read-only Doctor operator env inventory (`AB7331`) diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 83ea964a5..e0165d906 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -136,8 +136,8 @@ manifests at files inside those payloads without compiling them. Payload files c | `agent-bundle build` | Build a validated artifact from source, plus the declared `dist/` package build. | | `agent-bundle prepack` | Run the release build, dry-run npm packing without scripts, and verify packaged outputs, artifact hashes, bins, and versions (`--output` and `--json` supported). | | `agent-bundle install ` | Install a built bundle into Claude, Codex, or Cursor (`--from`, `--scope`, `--replace`/`--force`, `--mode local\|marketplace` for Cursor, and `--json` supported). Same-version content drift of an agent-bundle-managed install is replaced automatically; identical reruns are a no-op. | -| `agent-bundle uninstall ` | Remove a receipt-owned install and nothing else: the receipt's files and directories, the host registrations it recorded (`claude plugin uninstall --keep-data` + `marketplace remove`, `codex plugin remove` + `marketplace remove`, the Cursor local directory or staged marketplace). `--plan` prints the exact paths without changing anything; durable `state/` is kept unless `--purge-data --confirm-purge`; a missing receipt or content mismatch is refused unless `--force`; a rerun is `not-installed`. | -| `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | +| `agent-bundle uninstall ` | Remove a receipt-owned install and nothing else: the receipt's files and directories, the host registrations it recorded (`claude plugin uninstall --keep-data` + `marketplace remove`, `codex plugin remove` + `marketplace remove`, the Cursor local directory or staged marketplace). `--plan` prints the exact paths without changing anything; the effective framework state root, web-data, and legacy `state/` are kept unless `--purge-data --confirm-purge`; a missing receipt or content mismatch is refused unless `--force`; a rerun is `not-installed`. | +| `agent-bundle doctor` | Read-only host inspection: host probes, installed inventory, effective and legacy state roots with existence and writability, store receipts cross-checked against the host, and, with `--from`, the installed copy compared against the built artifact by version and content hash (`current`, `stale`, `version-mismatch`, `foreign`, `not-installed`) plus the lifecycle stage (placed → registered → enabled → active, unobservable stages typed `unavailable`). | | `agent-bundle validate` | Validate project source, or an artifact with `--artifact`. | | `agent-bundle inspect` | Inspect the normalized model and each selected host projection's plan from source, with per-host component accounting: which skills, commands, rules, hooks, MCP surfaces, and scripts each host emits and, for every omission, whether the author excluded it or the host's pinned capability judgment (`degraded`/`unavailable`/`prohibited`, with reason) ruled it out. | | `agent-bundle inspect --bundler` | Dump the synthesized Rslib/Rsbuild configs (post-`tools`-hatch merge) for every generated output. | @@ -332,12 +332,14 @@ receipt and remove exactly what it owns: Durable runtime state (`state/`: state kernel, notices journal; for a Cursor copy of an Agent Plugins pack, also the `PLUGIN_DATA` directory the receipt -records) is kept by default; `--purge-data --confirm-purge` removes it -(`AB7008` without the confirmation). The typed `data.outcome` is honest per host: Cursor `kept` / -`purged` / `absent`; Claude `retained-by-host` (Claude 2.1.257 orphans the -cached copy for its ~14-day grace period; a purge also removes `state/` and -`plugins/data//`); Codex `removed-by-host` / `unavailable` (codex-cli -0.147.0 deletes the cached tree on `plugin remove` and has no keep-data option). +records), effective framework state, and web-data are kept by default; +`--purge-data --confirm-purge` removes them (`AB7008` without the confirmation). +The typed `data.outcome` is honest per host: Cursor `kept` / `purged` / `absent`; +Claude `retained-by-host` (Claude 2.1.257 orphans the cached copy for its ~14-day +grace period; a purge also removes external framework state, web-data, `state/`, +and `plugins/data//`); Codex reports external state as `kept` / `purged`, +while in-tree `state/` is removed by the host and cannot be kept (codex-cli +0.147.0 has no keep-data option). `--plan` reports the same exact paths and host verbs without opening a writer. A missing receipt (`AB7009`) or an owned-content, version, or `HEAD` mismatch (`AB7007`) is refused unless `--force`; a receipt or manifest naming another diff --git a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json index f64de6692..33368fe03 100644 --- a/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json +++ b/packages/agent-bundle/src/adapters/capabilities/codex-0.147.0.json @@ -25,7 +25,7 @@ }, "keepData": { "evidence": [ - "2026-09-03: `codex plugin remove` deletes the cached plugin tree, `state/` included, and codex-cli 0.147.0 documents no keep-data option; the uninstaller reports data outcome `unavailable` for --keep-data and `removed-by-host` for --purge-data instead of pretending to preserve state." + "2026-09-05: `codex plugin remove` deletes the cached plugin tree, `state/` included, and codex-cli 0.147.0 documents no keep-data option. Agent Bundle can preserve external framework state and web-data, but cannot preserve in-tree state through a Codex uninstall." ], "reason": "Codex owns the cached tree and removes it wholesale; durable state cannot be preserved through a Codex uninstall.", "state": "unavailable" diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 02fdfd613..70f90950a 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -710,12 +710,14 @@ const inspectInstalledDurableState = async ( host: DoctorHost, environment: Readonly, home: string, + receipt?: InstallReceipt, ): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly effective: DoctorDurableStateReport; readonly legacy?: DoctorDurableStateReport; }> => { - const resolved = await resolveInstalledStateRoot(pluginRoot, host, environment, home); + const resolved = receipt?.stateRoot ?? + await resolveInstalledStateRoot(pluginRoot, host, environment, home); const effective = await inspectDurableState(resolved.root, resolved.source, host); const legacyRoot = join(pluginRoot, 'state'); if (legacyRoot === resolved.root) { @@ -1093,7 +1095,7 @@ const cursorInventory = async ( const stateOnly = await isRuntimeStateRemnant(path); const remnant = stateOnly || (remnantReceipt !== undefined && isRemnantReceipt(remnantReceipt)); if (remnant) { - const durableState = await inspectInstalledDurableState(path, 'cursor', environment, home); + const durableState = await inspectInstalledDurableState(path, 'cursor', environment, home, remnantReceipt); diagnostics.push(...durableState.diagnostics); diagnostics.push(await remnantDiagnostic(`Cursor plugin entry ${JSON.stringify(path)}`, path, remnantReceipt)); findings.push({ diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 2eb44c450..6c57360ff 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -158,9 +158,16 @@ export interface InstallReceipt { /** Host registrations the installer performed, in the order it performed them. */ readonly registrations: readonly InstallRegistration[]; readonly scope: InstallReceiptScope; + /** Effective framework state root retained by a Cursor `--keep-data` uninstall. */ + readonly stateRoot?: { + readonly root: string; + readonly source: 'derived' | 'native'; + }; /** When this receipt was last written (install or replacement); `installedAt` is the first install. */ readonly updatedAt: string; readonly version: string; + /** Derived web-data root retained by a Cursor `--keep-data` uninstall. */ + readonly webDataRoot?: string; } /** The lifecycle identity every receipt writer supplies; inventory and timestamps come from the write. */ @@ -491,6 +498,19 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { return undefined; } const cursorExpansion = readCursorExpansion(record['cursorExpansion']); + const stateRootRecord = record['stateRoot']; + const stateRoot = stateRootRecord !== undefined && + stateRootRecord !== null && + typeof stateRootRecord === 'object' && + !Array.isArray(stateRootRecord) && + typeof (stateRootRecord as Record)['root'] === 'string' && + ((stateRootRecord as Record)['source'] === 'derived' || + (stateRootRecord as Record)['source'] === 'native') + ? Object.freeze({ + root: (stateRootRecord as Record)['root'] as string, + source: (stateRootRecord as Record)['source'] as 'derived' | 'native', + }) + : undefined; const base = { contentHash: record['contentHash'], ...(cursorExpansion === undefined ? {} : { cursorExpansion }), @@ -500,7 +520,9 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { host: record['host'], installedAt: record['installedAt'], plugin: record['plugin'], + ...(stateRoot === undefined ? {} : { stateRoot }), version: record['version'], + ...(typeof record['webDataRoot'] === 'string' ? { webDataRoot: record['webDataRoot'] } : {}), } as const; if (format === legacyInstallReceiptFormat) { return Object.freeze({ @@ -571,6 +593,8 @@ export const createInstallReceipt = (options: InstallReceiptIdentity & { readonly cursorExpansion?: InstallReceiptCursorExpansion; readonly directories?: readonly string[]; readonly inventory: TreeInventory; + readonly stateRoot?: InstallReceipt['stateRoot']; + readonly webDataRoot?: string; }): InstallReceipt => { const installedAt = options.installedAt ?? new Date().toISOString(); return Object.freeze({ @@ -587,8 +611,10 @@ export const createInstallReceipt = (options: InstallReceiptIdentity & { ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), registrations: Object.freeze(options.registrations.map((registration) => Object.freeze({ ...registration }))), scope: options.scope, + ...(options.stateRoot === undefined ? {} : { stateRoot: Object.freeze({ ...options.stateRoot }) }), updatedAt: options.updatedAt ?? installedAt, version: options.version, + ...(options.webDataRoot === undefined ? {} : { webDataRoot: options.webDataRoot }), }); }; diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index 8f91729ac..b1ad081f7 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -79,10 +79,5 @@ export const resolveInstalledStateRoot = async ( : { root: declared, source: 'native' as const }); }; -export const installedWebDataRoot = async (pluginRoot: string, home: string): Promise => { - const canonicalRoot = await realpath(pluginRoot).catch((error: unknown) => { - if (isErrno(error, 'ENOENT')) return resolve(pluginRoot); - throw error; - }); - return join(home, '.agent-bundle', 'web-data', pluginStateSegment(canonicalRoot)); -}; +export const installedWebDataRoot = (pluginRoot: string, home: string): string => + join(home, '.agent-bundle', 'web-data', pluginStateSegment(resolve(pluginRoot))); diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index c8c1942da..d7d449a12 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -389,7 +389,11 @@ const cursorUninstallerSource = (): readonly string[] => [ " const digest = createHash('sha256').update(canonical).digest('hex').slice(0, 16);", " const name = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(basename(canonical)) ? basename(canonical) : 'plugin';", ' const segment = `${name}-${digest}`;', - " return [declared ?? inheritedStateRoot ?? join(stateHome, segment), join(canonical, 'state'), join(homedir(), '.agent-bundle', 'web-data', segment)];", + ' const explicitStateRoot = declared ?? inheritedStateRoot;', + " const webCanonical = resolve(destination);", + " const webDigest = createHash('sha256').update(webCanonical).digest('hex').slice(0, 16);", + " const webName = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u.test(basename(webCanonical)) ? basename(webCanonical) : 'plugin';", + " return [explicitStateRoot ?? join(stateHome, segment), join(canonical, 'state'), join(homedir(), '.agent-bundle', 'web-data', `${webName}-${webDigest}`), explicitStateRoot === undefined ? 'derived' : 'native'];", '};', '', "if (uninstall && mode === 'local') {", @@ -451,7 +455,10 @@ const cursorUninstallerSource = (): readonly string[] => [ ' files.push(path);', ' }', ' if (await exists(join(destination, receiptFile))) files.push(join(destination, receiptFile));', - " const [effectiveStateDirectory, stateDirectory, webDataDirectory] = await runtimeStateRoots();", + " const [resolvedStateDirectory, stateDirectory, resolvedWebDataDirectory, resolvedStateSource] = await runtimeStateRoots();", + ' const effectiveStateDirectory = receipt?.stateRoot?.root ?? resolvedStateDirectory;', + ' const stateSource = receipt?.stateRoot?.source ?? resolvedStateSource;', + ' const webDataDirectory = receipt?.webDataRoot ?? resolvedWebDataDirectory;', ' const externalDataPaths = [];', ' for (const path of [effectiveStateDirectory, webDataDirectory]) {', ' if (path === stateDirectory) continue;', @@ -568,6 +575,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' // A kept PLUGIN_DATA directory stays receipt-owned through the remnant\'s expansion record.', ' ...(keepRoot && receipt?.cursorExpansion !== undefined ? { cursorExpansion: receipt.cursorExpansion } : {}),', ' directories: [], hostDirectories, installedAt: receipt?.installedAt, registrations: [],', + ' ...(keepRoot ? { stateRoot: { root: effectiveStateDirectory, source: stateSource }, webDataRoot: webDataDirectory } : {}),', ' }));', ' console.log(`Remnant receipt: ${join(destination, receiptFile)} — owns no files; keeps the created host directories receipt-owned for a later purge.`);', ' }', @@ -917,7 +925,9 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " typeof value.plugin !== 'string' || typeof value.version !== 'string' ||", " typeof value.host !== 'string' || typeof value.contentHash !== 'string' || typeof value.installedAt !== 'string' ||", ' !Array.isArray(value.files) || !value.files.every(safeRelative) ||', - ' !Array.isArray(value.directories) || !value.directories.every(safeRelative)) return undefined;', + ' !Array.isArray(value.directories) || !value.directories.every(safeRelative) ||', + " (value.stateRoot !== undefined && (value.stateRoot === null || typeof value.stateRoot !== 'object' || Array.isArray(value.stateRoot) || typeof value.stateRoot.root !== 'string' || !['derived', 'native'].includes(value.stateRoot.source))) ||", + " (value.webDataRoot !== undefined && typeof value.webDataRoot !== 'string')) return undefined;", ' if (value.format === legacyReceiptFormat) {', " return { ...value, format: receiptFormat, hostDirectories: [], migratedFrom: legacyReceiptFormat, mode: 'local',", " registrations: [{ kind: 'cursor-local-plugin' }], scope: 'user', updatedAt: value.installedAt };", @@ -1043,8 +1053,10 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' plugin: pluginName,', " registrations: options.registrations ?? [{ kind: 'cursor-local-plugin' }],", " scope: 'user',", + ' ...(options.stateRoot === undefined ? {} : { stateRoot: options.stateRoot }),', ' updatedAt: now,', ' version: pluginVersion,', + ' ...(options.webDataRoot === undefined ? {} : { webDataRoot: options.webDataRoot }),', " }, null, 2) + '\\n';", '};', '', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index d00bcd6bd..407cce7a0 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -61,7 +61,7 @@ import { type InstallRegistration, type StoredInstallReceipt, } from './receipt.ts'; -import { installedWebDataRoot, resolveInstalledStateRoot } from './state-root.ts'; +import { installedWebDataRoot, type InstalledStateRoot, resolveInstalledStateRoot } from './state-root.ts'; /** * `agent-bundle uninstall ` (#101): the receipt-owned reverse of @@ -442,6 +442,8 @@ interface CursorLocalData { /** Whether any durable state root exists. */ readonly present: boolean; readonly report: UninstallDataReport; + readonly stateRoot: InstalledStateRoot; + readonly webDataRoot: string; } const cursorLocalData = async ( @@ -454,8 +456,9 @@ const cursorLocalData = async ( home: string, ): Promise => { const stateDirectory = join(destination, 'state'); - const effectiveState = await resolveInstalledStateRoot(destination, 'cursor', environment, home); - const webData = await installedWebDataRoot(destination, home); + const effectiveState = receipt?.stateRoot ?? + await resolveInstalledStateRoot(destination, 'cursor', environment, home); + const webData = receipt?.webDataRoot ?? installedWebDataRoot(destination, home); const paths: string[] = []; const kinds: string[] = []; let emptyState: string | undefined; @@ -514,6 +517,8 @@ const cursorLocalData = async ( paths: Object.freeze([]), policy, }), + stateRoot: effectiveState, + webDataRoot: webData, }; } return { @@ -528,6 +533,8 @@ const cursorLocalData = async ( paths: Object.freeze(paths), policy, }), + stateRoot: effectiveState, + webDataRoot: webData, }; }; @@ -688,6 +695,7 @@ const uninstallCursorLocal = async ( plugin: identity.plugin, registrations: [], scope: 'user', + ...(keepRoot ? { stateRoot: data.stateRoot, webDataRoot: data.webDataRoot } : {}), updatedAt: new Date().toISOString(), version: ownership.receipt?.version ?? identity.version, })); @@ -1100,7 +1108,12 @@ const publicHostData = async ( if (entry !== undefined) { const legacyStateRoot = join(entry.installPath, 'state'); const effectiveState = await resolveInstalledStateRoot(entry.installPath, host, environment, home); - for (const path of [effectiveState.root, legacyStateRoot, await installedWebDataRoot(entry.installPath, home)]) { + const candidates = [ + effectiveState.root, + ...(host === 'codex' && policy === 'keep' ? [] : [legacyStateRoot]), + installedWebDataRoot(entry.installPath, home), + ]; + for (const path of candidates) { if (!paths.includes(path) && await realDirectory(path, host) !== undefined) paths.push(path); } } diff --git a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts index 5401a1444..23f913280 100644 --- a/packages/agent-bundle/tests/packed-readonly-state-root.test.ts +++ b/packages/agent-bundle/tests/packed-readonly-state-root.test.ts @@ -213,7 +213,6 @@ it('serves a state-writing tool from a read-only installed artifact without writ expect(kept.stdout).toContain(`Data (keep): kept`); expect(kept.stdout).toContain(stateRoot); expect(await exists(stateRoot)).toBe(true); - await execFile(process.execPath, [installer], { cwd: artifact, env }); const purged = await execFile( process.execPath, [installer, '--uninstall', '--purge-data', '--confirm-purge'], diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index a77a48714..c030d5459 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -423,6 +423,14 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the installed host manifest', async () = const plan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); expect(plan.data.paths).toEqual([declaredStateRoot]); expect(plan.removed.directories).toContain(declaredStateRoot); + const kept = await uninstallBundle({ ...options, keepData: true }); + expect(kept).toMatchObject({ + data: { outcome: 'kept', paths: [declaredStateRoot] }, + remnantReceipt: join(cursorRoot, 'plugins', 'local', 'uninstall-fixture', installReceiptFile), + }); + expect(await readInstallReceipt(join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'))) + .toMatchObject({ stateRoot: { root: declaredStateRoot, source: 'native' } }); + expect(await readFile(join(declaredStateRoot, 'plugin.sqlite'), 'utf8')).toBe('declared\n'); const purged = await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); expect(purged.data).toMatchObject({ outcome: 'purged', paths: [declaredStateRoot] }); await expect(readdir(declaredStateRoot)).rejects.toMatchObject({ code: 'ENOENT' }); @@ -1227,7 +1235,7 @@ it('purges Claude durable state only when confirmed and reports the host-retaine } }); -it('types the Codex data outcome as unavailable for keep and removed-by-host for purge', async () => { +it('keeps external Codex state while reporting in-tree state only for purge', async () => { const fixture = await createFixture('codex'); const hostRoot = join(fixture.cleanupRoot, 'codex-root'); let installed = false; @@ -1254,9 +1262,19 @@ it('types the Codex data outcome as unavailable for keep and removed-by-host for await installBundle(options); const installPath = join(hostRoot, 'plugins', 'cache', 'uninstall-fixture-marketplace', 'uninstall-fixture', '1.2.3'); await cp(fixture.bundleRoot, installPath, { recursive: true }); - expect((await uninstallBundle({ ...options, plan: true })).data).toMatchObject({ outcome: 'unavailable', policy: 'keep' }); + const stateRoot = userDataStateRoot(installPath, options.environment, fixture.home); + await Promise.all([ + mkdir(join(installPath, 'state'), { recursive: true }), + mkdir(stateRoot, { recursive: true }), + ]); + expect((await uninstallBundle({ ...options, plan: true })).data).toMatchObject({ + outcome: 'kept', + paths: [stateRoot], + policy: 'keep', + }); expect((await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true })).data).toMatchObject({ - outcome: 'removed-by-host', + outcome: 'purged', + paths: [stateRoot, join(installPath, 'state')], policy: 'purge', }); const scoped = await failureOf(uninstallBundle({ ...options, scope: 'project' })); diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 273c21ceb..6c84ef1cb 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -236,7 +236,7 @@ registration proof and the rows' `errors` already hold that verdict. | `AB7330` | info | The bundle's lifecycle stage on this host and its four observations; unobservable stages (a live session's loaded plugins on every host, Cursor's server-assigned enabled state) are typed `unavailable`, never guessed. | | `AB7328` | warning | A store receipt records a registration the host no longer holds (orphaned), or the receipt store cannot be read; `agent-bundle uninstall` consumes an orphaned receipt. | | `AB7329` | info | A receipt predates lifecycle receipts and was read with synthesized fields; rerun `install` once to rewrite it as format 2. | -| `AB7316` | warning | An installed bundle's `state/` directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Doctor inventories state by directory entry and metadata only; it never opens a database. | +| `AB7316` | warning | An installed bundle's effective or legacy state directory is not writable, or the directory or one of its `*.sqlite`, `-wal`, or `-shm` files cannot be read with filesystem metadata operations. Doctor inventories state by directory entry and metadata only; it never opens a database. | | `AB7317` | info | A live event runtime implements the older strict protocol and does not expose runtime identity. | | `AB7318` | error | A live event runtime became unavailable, timed out, or returned an invalid status response during the bounded read-only identity probe. | | `AB7319` | error | A host tree resolved from `doctor --from` violates its pinned document schemas or process-free loader rules; the message retains the originating build-validator code. | diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 7a060e428..9166f53ee 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -194,7 +194,7 @@ Doctor 还会清点每个宿主根目录下的回执仓库并与宿主交叉核 | `AB7330` | info | 该捆绑包在此宿主上的生命周期阶段及四项观察;无法观察的阶段(任一宿主的活跃会话加载了哪些插件、Cursor 由服务端分配的启用状态)被类型化为 `unavailable`,绝不猜测。 | | `AB7328` | warning | 某份仓库回执记录的注册宿主已不再持有(孤立),或回执仓库无法读取;`agent-bundle uninstall` 会消费孤立的回执。 | | `AB7329` | info | 某份回执早于生命周期回执,读取时补全了字段;再运行一次 `install` 即可将其重写为格式 2。 | -| `AB7316` | warning | 某个已安装捆绑包的 `state/` 目录,或它的某个 `*.sqlite`、`-wal`、`-shm` 文件无法通过文件系统元数据操作读取。Doctor 只按目录条目与元数据清点状态;它绝不打开数据库。 | +| `AB7316` | warning | 某个已安装捆绑包的有效或旧版状态目录不可写,或该目录及其某个 `*.sqlite`、`-wal`、`-shm` 文件无法通过文件系统元数据操作读取。Doctor 只按目录条目与元数据清点状态;它绝不打开数据库。 | | `AB7317` | info | 某个活跃的事件运行时实现的是较旧的严格协议,不暴露运行时身份。 | | `AB7318` | error | 在有界的只读身份探测过程中,某个活跃的事件运行时变为不可用、超时,或返回了无效的状态响应。 | | `AB7319` | error | 由 `doctor --from` 解析出的宿主目录树违反了它被固定的文档 schema 或无进程加载器规则;消息中保留原始的构建校验器代码。 | From e024155516c38b1221f2ceab57e603c9a24c6b95 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 18:56:04 +0000 Subject: [PATCH 3/4] fix: align web data root derivation --- packages/agent-bundle/src/install/state-root.ts | 4 ++-- packages/agent-bundle/src/web-host/launch.ts | 8 +++++++- packages/agent-bundle/tests/web-launch.test.ts | 15 +++++++++++++-- 3 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index b1ad081f7..201092cde 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -3,11 +3,11 @@ import { isAbsolute, join, resolve } from 'node:path'; import { PLUGIN_STATE_ROOT_ENV_ANCHOR, - pluginStateSegment, userDataStateRoot, } from '@agent-bundle/runtime'; import { isErrno } from '../core/errors.ts'; +import { webPluginDataRoot } from '../web-host/launch.ts'; import type { InstallHost } from './install.ts'; export interface InstalledStateRoot { @@ -80,4 +80,4 @@ export const resolveInstalledStateRoot = async ( }; export const installedWebDataRoot = (pluginRoot: string, home: string): string => - join(home, '.agent-bundle', 'web-data', pluginStateSegment(resolve(pluginRoot))); + webPluginDataRoot(pluginRoot, home); diff --git a/packages/agent-bundle/src/web-host/launch.ts b/packages/agent-bundle/src/web-host/launch.ts index 48910abcf..dfb76057f 100644 --- a/packages/agent-bundle/src/web-host/launch.ts +++ b/packages/agent-bundle/src/web-host/launch.ts @@ -43,13 +43,19 @@ const webPluginStateSegment = (pluginRoot: string): string => { return safePluginSegment.test(name) ? `${name}-${digest}` : `plugin-${digest}`; }; +/** + * The shared web-data root for one installed plugin. + */ +export const webPluginDataRoot = (pluginRoot: string, home = homedir()): string => + join(home, '.agent-bundle', 'web-data', webPluginStateSegment(resolve(pluginRoot))); + /** * 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)); + join(webPluginDataRoot(pluginRoot, home), mcpServerStateDirectory(server)); const inheritedEnvironment = (env: NodeJS.ProcessEnv): Record => Object.fromEntries(Object.entries(env).filter((entry): entry is [string, string] => typeof entry[1] === 'string')); diff --git a/packages/agent-bundle/tests/web-launch.test.ts b/packages/agent-bundle/tests/web-launch.test.ts index 60031732a..42396c643 100644 --- a/packages/agent-bundle/tests/web-launch.test.ts +++ b/packages/agent-bundle/tests/web-launch.test.ts @@ -1,12 +1,13 @@ -import { chmod, mkdir, mkdtemp, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises'; +import { chmod, mkdir, mkdtemp, readdir, realpath, rm, stat, symlink, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, 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'; import { pathTokens, pluginRootEnvAnchor } from '../src/core/types.ts'; +import { installedWebDataRoot } from '../src/install/state-root.ts'; import { resolveWebLaunch, WebLaunchError, webPluginDataDirectory } from '../src/web-host/launch.ts'; import type { WebManifestApp } from '../src/web-host/manifest.ts'; @@ -141,6 +142,16 @@ describe('resolveWebLaunch', () => { expect(webPluginDataDirectory(first, 'status', home)).toBe(webPluginDataDirectory(`${first}/mcp/..`, 'status', home)); }); + it('shares web-data derivation with uninstall through a symlinked plugin root', async () => { + const home = await homeRoot(); + const root = await artifactRoot(); + const link = `${root}-link`; + roots.push(link); + await symlink(root, link, 'dir'); + expect(installedWebDataRoot(link, home)) + .toBe(dirname(webPluginDataDirectory(link, '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 From 55d19903d6e0c71ed2ec596b949bf705d7fb2129 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 19:21:03 +0000 Subject: [PATCH 4/4] fix: keep lifecycle helpers runtime-free --- .../agent-bundle/src/install/state-root.ts | 33 ++++++++++++++----- .../tests/host-install-proof.test.ts | 2 +- 2 files changed, 25 insertions(+), 10 deletions(-) diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index 201092cde..155fff6c8 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -1,12 +1,9 @@ +import { createHash } from 'node:crypto'; import { readFile, realpath } from 'node:fs/promises'; -import { isAbsolute, join, resolve } from 'node:path'; - -import { - PLUGIN_STATE_ROOT_ENV_ANCHOR, - userDataStateRoot, -} from '@agent-bundle/runtime'; +import { basename, isAbsolute, join, resolve } from 'node:path'; import { isErrno } from '../core/errors.ts'; +import { pluginStateRootEnvAnchor } from '../core/types.ts'; import { webPluginDataRoot } from '../web-host/launch.ts'; import type { InstallHost } from './install.ts'; @@ -33,6 +30,24 @@ const manifestCandidates = (host: InstallHost): readonly string[] => { const isRecord = (value: unknown): value is Record => value !== null && typeof value === 'object' && !Array.isArray(value); +const safePluginSegment = /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]*[a-zA-Z0-9])?$/u; + +// The CLI cannot load the optional React runtime. Uninstall tests pin this spelling against +// `userDataStateRoot` from @agent-bundle/runtime for real installed roots. +const installedUserDataStateRoot = ( + canonicalRoot: string, + environment: Readonly, + home: string, +): string => { + const xdgStateHome = environment.XDG_STATE_HOME ?? ''; + const stateHome = isAbsolute(xdgStateHome) + ? join(xdgStateHome, 'agent-bundle') + : join(home, '.agent-bundle', 'state'); + const digest = createHash('sha256').update(canonicalRoot).digest('hex').slice(0, 16); + const name = basename(canonicalRoot); + return join(stateHome, safePluginSegment.test(name) ? `${name}-${digest}` : `plugin-${digest}`); +}; + const declaredStateRoot = async (pluginRoot: string, host: InstallHost): Promise => { for (const relativePath of manifestCandidates(host)) { let document: unknown; @@ -45,7 +60,7 @@ const declaredStateRoot = async (pluginRoot: string, host: InstallHost): Promise if (!isRecord(document) || !isRecord(document['mcpServers'])) continue; for (const server of Object.values(document['mcpServers'])) { if (!isRecord(server) || !isRecord(server['env'])) continue; - const declared = server['env'][PLUGIN_STATE_ROOT_ENV_ANCHOR]; + const declared = server['env'][pluginStateRootEnvAnchor]; if (typeof declared !== 'string' || declared.trim() === '') continue; const expanded = declared .replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) @@ -69,13 +84,13 @@ export const resolveInstalledStateRoot = async ( throw error; }); const fromManifest = await declaredStateRoot(canonicalRoot, host); - const inherited = environment[PLUGIN_STATE_ROOT_ENV_ANCHOR] ?? ''; + const inherited = environment[pluginStateRootEnvAnchor] ?? ''; const expandedInherited = inherited.trim() === '' || /\$\{[^}]*\}/u.test(inherited) ? undefined : isAbsolute(inherited) ? resolve(inherited) : resolve(canonicalRoot, inherited); const declared = fromManifest ?? expandedInherited; return Object.freeze(declared === undefined - ? { root: userDataStateRoot(canonicalRoot, environment, home), source: 'derived' as const } + ? { root: installedUserDataStateRoot(canonicalRoot, environment, home), source: 'derived' as const } : { root: declared, source: 'native' as const }); }; diff --git a/packages/agent-bundle/tests/host-install-proof.test.ts b/packages/agent-bundle/tests/host-install-proof.test.ts index 2a4b3326a..d3fa9e97e 100644 --- a/packages/agent-bundle/tests/host-install-proof.test.ts +++ b/packages/agent-bundle/tests/host-install-proof.test.ts @@ -561,7 +561,7 @@ codexPluginIt( keepData: 'unavailable', plan: 'no-op', proofLevel: proofLabel, - purgeData: 'removed-by-host', + purgeData: 'purged', refusals: { foreignOrMismatch: 'AB7007', missingReceipt: 'AB7009', unconfirmedPurge: 'AB7008' }, registrations: { 'codex-marketplace': 'removed', 'codex-plugin': 'removed' }, rerun: 'not-installed',