From 6d5db4716da688c1ea343996475628088a6f74af Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:02:44 +0000 Subject: [PATCH 1/8] docs: design state root ownership --- .../plans/2026-09-05-state-root-ownership.md | 407 ++++++++++++++++++ .../2026-09-05-state-root-ownership-design.md | 168 ++++++++ 2 files changed, 575 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-05-state-root-ownership.md create mode 100644 docs/superpowers/specs/2026-09-05-state-root-ownership-design.md diff --git a/docs/superpowers/plans/2026-09-05-state-root-ownership.md b/docs/superpowers/plans/2026-09-05-state-root-ownership.md new file mode 100644 index 000000000..e1fd88f9a --- /dev/null +++ b/docs/superpowers/plans/2026-09-05-state-root-ownership.md @@ -0,0 +1,407 @@ +# State Root Ownership Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make uninstall purge only receipt-recorded, installation-owned state subtrees while Doctor reports runtime location, receipt record, and ownership independently. + +**Architecture:** Replace the single discovered state root with a per-server resolver and a receipt-owned state ledger. Installation acquires ownership only for derived namespaces or newly created, identity-marked explicit roots; uninstall validates receipt evidence before recursive deletion, and the generated installer mirrors the same rules. + +**Tech Stack:** TypeScript, Node.js filesystem/path/crypto APIs, Effect-based installer flow, Rstest, generated self-contained ESM. + +## Global Constraints + +- Generated plugin output remains self-contained and imports only Node built-ins. +- The optional `@agent-bundle/runtime` peer must not load eagerly from install or CLI code. +- Purge never derives deletion authority from the uninstall process environment. +- Pre-existing, shared, marker-less, foreign-marker, or symlink-retargeted roots are retained. +- Public behavior changes update English and Chinese docs and exactly one patch changeset. + +--- + +### Task 1: Receipt-owned state schema + +**Files:** +- Modify: `packages/agent-bundle/src/install/receipt.ts` +- Test: `packages/agent-bundle/tests/receipt.test.ts` + +**Interfaces:** +- Produces: `InstallReceiptStateOwner`, `InstallReceiptStateRoot`, `InstallReceiptState` +- Extends: `InstallReceipt.state?: InstallReceiptState` +- Extends: `createInstallReceipt({ state?: InstallReceiptState })` + +- [ ] **Step 1: Write failing round-trip and rejection tests** + +```ts +const state = { + owner: { host: 'cursor', id: 'owner-1', mode: 'local', plugin: 'fixture', scope: 'user' }, + roots: [{ + canonicalRoot: '/state/fixture-a', + ownership: { kind: 'derived' }, + root: '/state/fixture-a', + servers: ['alpha'], + source: 'derived', + }], +} as const; +expect(await roundTripReceipt(createInstallReceipt({ ...identity, inventory, state }))) + .toMatchObject({ state }); +``` + +Also reject malformed owner ids, duplicate/unsorted server lists, relative +roots, invalid ownership discriminants, and marker ownership without an +absolute marker path. + +- [ ] **Step 2: Run the receipt test** + +Run: `pnpm build && pnpm exec rstest --config rstest.unit.config.ts packages/agent-bundle/tests/receipt.test.ts` + +Expected: FAIL because the receipt does not preserve `state`. + +- [ ] **Step 3: Implement and freeze the schema** + +```ts +export interface InstallReceiptStateRoot { + readonly canonicalRoot: string; + readonly ownership: + | { readonly kind: 'derived' } + | { readonly kind: 'marker'; readonly marker: string } + | { readonly kind: 'unowned'; readonly reason: 'foreign-marker' | 'pre-existing' | 'unproven' }; + readonly root: string; + readonly servers: readonly string[]; + readonly source: 'declared' | 'derived'; +} +``` + +Validate every nested field in `receiptFromDocument`, freeze arrays and +objects, and preserve backward compatibility when `state` is absent. + +- [ ] **Step 4: Re-run the receipt test** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/agent-bundle/src/install/receipt.ts packages/agent-bundle/tests/receipt.test.ts +git commit -m "feat: record state ownership in receipts" +``` + +### Task 2: Per-server runtime state resolution + +**Files:** +- Modify: `packages/agent-bundle/src/install/state-root.ts` +- Test: `packages/agent-bundle/tests/state-root.test.ts` + +**Interfaces:** +- Produces: `resolveInstalledStateRoots(pluginRoot, host, environment, home): Promise` +- `InstalledStateLocation`: `{ cwd, root, server, source, status }` +- Removes the singular `resolveInstalledStateRoot` + +- [ ] **Step 1: Write failing resolver tests** + +```ts +expect(await resolveInstalledStateRoots(root, 'cursor', {}, home)).toEqual([ + expect.objectContaining({ root: first, server: 'alpha', source: 'declared' }), + expect.objectContaining({ root: second, server: 'beta', source: 'declared' }), +]); +``` + +Cover two different roots, deduplication metadata, relative overrides resolved +against declared server cwd, unresolved relative overrides without a provable +cwd, root-token expansion, and no manifest override falling back to the +derived root. + +- [ ] **Step 2: Run the resolver test** + +Run: `pnpm build && pnpm exec rstest --config rstest.unit.config.ts packages/agent-bundle/tests/state-root.test.ts` + +Expected: FAIL because only the first override is returned. + +- [ ] **Step 3: Implement the resolver** + +Parse all `mcpServers` entries in deterministic name order. Resolve each +server cwd before resolving its state env. Use the packaging-safe local +equivalent of runtime `resolvePluginRoot`, pinned in tests against +`userDataStateRoot` for derived roots and a spawned Node process for relative +`resolve()` semantics. + +- [ ] **Step 4: Re-run the resolver test** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/agent-bundle/src/install/state-root.ts packages/agent-bundle/tests/state-root.test.ts +git commit -m "feat: resolve every server state root" +``` + +### Task 3: Install-time ownership acquisition + +**Files:** +- Modify: `packages/agent-bundle/src/install/state-root.ts` +- Modify: `packages/agent-bundle/src/install/install.ts` +- Modify: `packages/agent-bundle/src/install/receipt.ts` +- Test: `packages/agent-bundle/tests/install.test.ts` +- Test: `packages/agent-bundle/tests/uninstall.test.ts` + +**Interfaces:** +- Produces: `recordInstalledState(options): Promise<{ state, rollback }>` +- Produces marker: `.agent-bundle-state-owner.json` +- Consumes the per-server resolver from Task 2 + +- [ ] **Step 1: Write failing install tests** + +Create two absent explicit roots and one pre-existing shared root containing +`sentinel.txt`. Assert the receipt owns the absent roots by marker, records the +shared root as `unowned: pre-existing`, and each marker contains the receipt +owner id and install identity. + +- [ ] **Step 2: Run install tests** + +Expected: FAIL because install writes no state ledger or markers. + +- [ ] **Step 3: Implement acquisition and rollback** + +```ts +export interface StateOwnershipAcquisition { + readonly state: InstallReceiptState; + readonly rollback: () => Promise; +} +``` + +Generate or retain one owner UUID. Record derived roots without creating them. +For explicit absent roots, create the directory and marker with exclusive +filesystem operations. For existing roots, inspect markers without replacing +anything. On downstream failure, remove only markers created by this attempt +and remove only directories that become empty. + +- [ ] **Step 4: Thread state through every receipt writer** + +Cover Cursor local install/adopt/replace, Claude/Codex store receipts, and +receipt refresh. Replacement carries the existing owner id and reacquires +only newly declared roots. + +- [ ] **Step 5: Re-run install and uninstall tests** + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add packages/agent-bundle/src/install packages/agent-bundle/tests/install.test.ts packages/agent-bundle/tests/uninstall.test.ts +git commit -m "feat: acquire installation state ownership" +``` + +### Task 4: Receipt-only purge planning and deletion + +**Files:** +- Modify: `packages/agent-bundle/src/install/state-root.ts` +- Modify: `packages/agent-bundle/src/install/uninstall.ts` +- Test: `packages/agent-bundle/tests/uninstall.test.ts` + +**Interfaces:** +- Produces: `inspectRecordedStateOwnership(record): Promise` +- `StatePurgeDecision`: `{ action: 'purge' | 'retain' | 'absent', reason, root }` + +- [ ] **Step 1: Write destructive-safety regression tests** + +Cover changed uninstall env, two installs sharing a base, foreign marker, +marker-less root, unrelated sentinels outside owned roots, symlinked leaf, +unchanged symlink ancestor, retargeted ancestor, and keep-data followed by +later purge. + +```ts +await uninstallBundle({ ...options, environment: changedEnv, purgeData: true, confirmPurge: true }); +expect(await exists(recordedOwnedRoot)).toBe(false); +expect(await readFile(sharedSentinel, 'utf8')).toBe('keep\n'); +``` + +- [ ] **Step 2: Run uninstall tests** + +Expected: FAIL because uninstall still discovers roots from current env and +recursively removes every discovered directory. + +- [ ] **Step 3: Implement evidence validation** + +Read candidates only from `receipt.state.roots`. Require absolute lexical and +canonical roots, a real directory leaf, unchanged canonical resolution, and +an exact marker identity for marker-owned roots. Return retained decisions +instead of throwing for failed ownership evidence. + +- [ ] **Step 4: Update typed and human reports** + +List purged paths separately from retained state roots and include the reason +for each retained root. `--plan` reports the same decisions without writes. + +- [ ] **Step 5: Re-run uninstall tests** + +Expected: PASS and every sentinel survives. + +- [ ] **Step 6: Commit** + +```bash +git add packages/agent-bundle/src/install/state-root.ts packages/agent-bundle/src/install/uninstall.ts packages/agent-bundle/tests/uninstall.test.ts +git commit -m "fix: purge only receipted state roots" +``` + +### Task 5: Doctor ownership inventory + +**Files:** +- Modify: `packages/agent-bundle/src/install/doctor.ts` +- Modify: `packages/agent-bundle/src/cli.ts` +- Test: `packages/agent-bundle/tests/doctor.test.ts` + +**Interfaces:** +- Extends: `DoctorDurableStateReport` +- Reports current locations, receipt matches, ownership, purgeability, reason, + existence, writability, and servers + +- [ ] **Step 1: Write failing Doctor tests** + +Assert rows for derived-owned, marker-owned, shared-unowned, foreign-marker, +missing, and current-location-different-from-receipt cases. + +- [ ] **Step 2: Run Doctor tests** + +Expected: FAIL because Doctor exposes only one effective root and legacy root. + +- [ ] **Step 3: Implement Doctor reports** + +Inventory all current and recorded roots without opening state databases. +Deduplicate by path, retain server names, validate marker/canonical evidence +read-only, and add a diagnostic for retained unowned or invalidated ownership. + +- [ ] **Step 4: Re-run Doctor tests** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/agent-bundle/src/install/doctor.ts packages/agent-bundle/src/cli.ts packages/agent-bundle/tests/doctor.test.ts +git commit -m "feat: report state ownership in doctor" +``` + +### Task 6: Generated installer parity + +**Files:** +- Modify: `packages/agent-bundle/src/install/surface.ts` +- Test: `packages/agent-bundle/tests/install-surface.test.ts` +- Test: `packages/agent-bundle/tests/packed-readonly-state-root.test.ts` + +**Interfaces:** +- Generated `install.mjs` writes/reads the Task 1 receipt state shape and uses + the Task 2–4 ownership rules with Node built-ins only + +- [ ] **Step 1: Add failing generated-installer tests** + +Exercise two roots, shared sentinel retention, relative cwd, symlink ancestor, +keep then later purge, and marker ownership. + +- [ ] **Step 2: Run generated and packed tests** + +Run: + +```bash +pnpm build +pnpm exec rstest --config rstest.unit.config.ts packages/agent-bundle/tests/install-surface.test.ts +pnpm exec rstest --config rstest.config.ts packages/agent-bundle/tests/packed-readonly-state-root.test.ts +``` + +Expected: FAIL until emitted source mirrors the core behavior. + +- [ ] **Step 3: Implement emitted parity** + +Keep marker, resolver, receipt parser/writer, and purge decision code in the +generated installer self-contained. Do not introduce package imports. + +- [ ] **Step 4: Re-run generated and packed tests** + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add packages/agent-bundle/src/install/surface.ts packages/agent-bundle/tests/install-surface.test.ts packages/agent-bundle/tests/packed-readonly-state-root.test.ts +git commit -m "fix: generate ownership-safe state purge" +``` + +### Task 7: Documentation, changeset, and release gates + +**Files:** +- Modify: `website/docs/en/reference/cli.mdx` +- Modify: `website/docs/zh/reference/cli.mdx` +- Modify: `docs/diagnostics.md` +- Create: `.changeset/.md` + +**Interfaces:** +- Documents the three-fact model and any new diagnostic code + +- [ ] **Step 1: Update English and Chinese CLI references** + +State that runtime location does not imply ownership, explain marker-owned +explicit roots, list retained reasons, and describe Doctor ownership rows. + +- [ ] **Step 2: Add diagnostics and one patch changeset** + +The changeset summary names `uninstall --purge-data`, `doctor`, and any new +diagnostic codes, is imperative, and ends with `(#)` once the PR exists. + +- [ ] **Step 3: Run deslop** + +Read the full diff against `origin/main`; remove redundant comments, defensive +checks on trusted paths, duplicate helpers, casts that only silence types, and +unnecessary nesting without changing behavior. + +- [ ] **Step 4: Run all gates** + +```bash +pnpm build && +pnpm typecheck && +pnpm lint && +pnpm test:unit && +pnpm docs:site:build +``` + +Also run the targeted packed regression and any affected host-install tests. +Expected: all PASS. + +- [ ] **Step 5: Commit** + +```bash +git add website/docs docs/diagnostics.md .changeset +git commit -m "docs: explain state root ownership" +``` + +### Task 8: Review and merge + +**Files:** +- No source files unless review finds a defect + +**Interfaces:** +- Produces a squash-merged PR closing #644 and linking #592 + +- [ ] **Step 1: Push and open the PR** + +Include `Closes #644`, `Related: #592`, validation, and deslop sections. + +- [ ] **Step 2: Run Claude review** + +Use `claude-fable-5-1-thinking-high`; request concrete merge risks only. +Resolve every finding and rerun the reviewer after fixes. + +- [ ] **Step 3: Record self-review and wait for CI** + +Update the PR body with reviewer model, findings, dispositions, and final +result. Address every review thread and require all checks green. + +- [ ] **Step 4: Arm squash auto-merge** + +```bash +gh pr merge --squash --auto +``` + +- [ ] **Step 5: Report result** + +Report PR URL, merge SHA, closed issue, and the ownership model in exactly +three concise lines. diff --git a/docs/superpowers/specs/2026-09-05-state-root-ownership-design.md b/docs/superpowers/specs/2026-09-05-state-root-ownership-design.md new file mode 100644 index 000000000..53565676e --- /dev/null +++ b/docs/superpowers/specs/2026-09-05-state-root-ownership-design.md @@ -0,0 +1,168 @@ +# State Root Ownership Design + +## Goal + +Make state discovery informative and state deletion receipt-owned. A runtime +location is not deletion authority: `uninstall --purge-data` removes only the +subtrees this installation exclusively owns and retains every shared, +externally managed, unproven, or foreign-marked root. + +Tracking issue: #644. Architectural context: #592. + +## Three Separate Facts + +1. **Runtime location** — the effective state root for each MCP server, using + that server's declared environment and execution cwd with the same + `resolvePluginRoot` semantics as `@agent-bundle/runtime`. +2. **Installation record** — the immutable set of per-server locations + observed and persisted when this installation was created or replaced. + An environment change during uninstall may change the current runtime + observation, but never the recorded deletion candidates. +3. **Exclusive ownership** — evidence that authorizes recursive deletion of + one recorded root. Discovery, a manifest declaration, or an environment + variable is never ownership evidence by itself. + +## Receipt Model + +The format-2 receipt gains an optional frozen `state` object: + +```ts +interface InstallReceiptState { + readonly owner: { + readonly id: string; + readonly host: InstallHost; + readonly mode: InstallReceiptMode; + readonly plugin: string; + readonly scope: InstallReceiptScope; + readonly projectRoot?: string; + }; + readonly roots: readonly InstallReceiptStateRoot[]; +} + +interface InstallReceiptStateRoot { + readonly canonicalRoot: string; + readonly ownership: + | { readonly kind: 'derived' } + | { readonly kind: 'marker'; readonly marker: string } + | { + readonly kind: 'unowned'; + readonly reason: 'foreign-marker' | 'pre-existing' | 'unproven'; + }; + readonly root: string; + readonly servers: readonly string[]; + readonly source: 'declared' | 'derived'; +} +``` + +Roots are deduplicated by resolved path while preserving every server name. +The owner id is a random UUID created once and retained across replacement, +`--keep-data` remnants, and receipt migration. Existing receipts without +`state` remain readable but authorize no external deletion. + +## Runtime Resolution + +State resolution reads every server in the installed host MCP document. For +each server: + +- expand only the host/plugin-root tokens that the host expands; +- resolve its `cwd` first; +- pass the server environment, root fallback, user-data state anchor, home, + and resolved execution cwd through a local packaging-safe equivalent of + `resolvePluginRoot`; +- resolve relative `AGENT_BUNDLE_STATE_ROOT` against the execution cwd, as + Node's `resolve()` does inside the runtime process; +- retain unresolved token values or a relative value without a provable cwd + as an unproven runtime observation, never an owned root. + +Declared server environment wins for that server. The current process +environment may be shown by Doctor as a current observation but is never +added to the receipt at uninstall time and never creates purge authority. + +## Ownership Acquisition + +The default user-data root +`/agent-bundle/-` is exclusive by construction. +Installation records its lexical path and the canonical path obtained by +resolving the real state-home ancestor. + +An explicit root is owned only when all of these are true: + +1. it did not exist before installation; +2. the installer created the directory; +3. the installer atomically created + `.agent-bundle-state-owner.json` inside it; +4. the marker names the same owner id and install identity as the receipt. + +A pre-existing directory is recorded as `unowned: pre-existing`. A +marker-less directory is `unowned: unproven`. A marker naming another owner +is `unowned: foreign-marker`. Install never rewrites an override to a child +directory. + +Marker creation is rolled back if installation or receipt persistence fails: +remove only the marker created by this attempt, then remove its directory only +if empty. + +## Purge + +`--plan` and confirmed purge operate only on receipt `state.roots`. + +- `derived`: require the current lexical root to resolve to the recorded + canonical root and require the leaf to be a real directory, not a symlink. +- `marker`: require the same canonical-root check and an exact marker identity + match. +- `unowned`: retain with its recorded reason. +- missing roots: report absent; do not broaden the candidate. +- changed symlink ancestors, leaf symlinks, malformed markers, and foreign + markers: retain with a safety reason. + +Recursive deletion targets the validated root itself. It never targets an +override's parent or any shared base. Unrelated sentinels outside the owned +root therefore survive. + +`--keep-data` carries the full state record into the remnant receipt. A later +purge applies the same evidence without rereading a removed manifest or the +caller's current environment. + +Legacy `/state` and receipted Cursor `PLUGIN_DATA` keep their existing +separate ownership rules. Web-data remains a distinct derived root and must +also be receipt-recorded before it is purgeable. + +## Doctor + +Doctor reports, per deduplicated root: + +- servers using the root; +- current runtime location and source; +- whether it matches a receipt record; +- ownership (`derived`, `marker`, or `unowned` plus reason); +- existence and writability; +- purgeability and any failed evidence check. + +Doctor continues to report legacy state separately. A new informational or +warning diagnostic is added only when needed to make retained/unproven state +machine-readable, and is documented in `docs/diagnostics.md`. + +## Generated Installer + +The emitted `install.mjs` uses the same receipt schema, marker format, +resolution rules, validation, plan output, keep-data remnant behavior, and +purge decisions. It remains self-contained and imports only Node built-ins. + +## Tests + +Temporary-directory tests cover: + +- install environment differs from uninstall environment; +- two installations reference one configured base and unrelated sentinels + survive; +- two servers declare different roots; +- relative overrides resolve against each server execution cwd; +- unchanged and changed symlink ancestors; +- foreign and missing markers; +- `--keep-data` followed by later purge; +- Doctor ownership and purgeability rows; +- generated installer parity; +- packed state-writing behavior. + +All destructive tests place an unrelated sentinel outside each owned subtree +and assert that it survives. From 334a2fd6418e7d2517e3e4f6840b6486617bcf66 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:17:27 +0000 Subject: [PATCH 2/8] fix: make state purge receipt-owned --- docs/diagnostics.md | 9 +- packages/agent-bundle/src/cli.ts | 10 +- packages/agent-bundle/src/install/doctor.ts | 94 ++++- packages/agent-bundle/src/install/install.ts | 103 +++++- packages/agent-bundle/src/install/receipt.ts | 119 ++++++- .../agent-bundle/src/install/state-root.ts | 321 ++++++++++++++++-- packages/agent-bundle/src/install/surface.ts | 161 ++++++++- .../agent-bundle/src/install/uninstall.ts | 111 ++++-- packages/agent-bundle/tests/doctor.test.ts | 12 + packages/agent-bundle/tests/uninstall.test.ts | 219 +++++++++++- website/docs/en/reference/cli.mdx | 20 +- website/docs/zh/reference/cli.mdx | 14 +- 12 files changed, 1094 insertions(+), 99 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 44bcaeaaa..a870a7da5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1351,9 +1351,12 @@ diagnostic. ## 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. +Doctor resolves every installed MCP server's framework state root from its +canonical code root, declared environment, and execution directory. It reports +the servers, source, receipt ownership, current purgeability, existence, and +writability separately from the pre-#640 in-tree location. A runtime location +without matching receipt ownership remains visible but is never deletion +authority. | Code | Severity | Trigger | | --- | --- | --- | diff --git a/packages/agent-bundle/src/cli.ts b/packages/agent-bundle/src/cli.ts index 7acc5efee..855392798 100644 --- a/packages/agent-bundle/src/cli.ts +++ b/packages/agent-bundle/src/cli.ts @@ -467,14 +467,20 @@ const humanDoctor = (result: DoctorReport): string => { } } const reports = [ - ...host.inventory.findings.map((finding) => finding.durableState), + ...host.inventory.findings.flatMap((finding) => finding.durableStates ?? ( + finding.durableState === undefined ? [] : [finding.durableState] + )), 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`, + `${report.writable ? 'writable' : 'not writable'}, ${report.stateSource}); ` + + `ownership: ${report.ownership}${report.ownershipReason === undefined ? '' : ` (${report.ownershipReason})`}, ` + + `${report.purgeable ? 'purgeable' : 'retained'}${ + report.servers.length === 0 ? '' : `, servers: ${report.servers.join(', ')}` + }\n`, ); } const legacyReports = host.inventory.findings diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 70f90950a..08048c50b 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -72,7 +72,10 @@ import { inspectCursorPluginHooks, } from './cursor-hooks-registration.ts'; import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; -import { resolveInstalledStateRoot } from './state-root.ts'; +import { + inspectInstalledStateOwnership, + resolveInstalledStateRoots, +} from './state-root.ts'; export type DoctorHost = InstallHost; export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable'; @@ -132,6 +135,8 @@ export interface DoctorFinding { /** Git commit of a staged Cursor marketplace repository. */ readonly commit?: string; readonly durableState?: DoctorDurableStateReport; + /** Every current per-server state root, deduplicated by directory. */ + readonly durableStates?: readonly 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. */ @@ -249,6 +254,10 @@ export interface DoctorDurableStateReport { readonly directory: string; readonly exists: boolean; readonly findings: readonly DoctorDurableStateStore[]; + readonly ownership: 'derived' | 'legacy' | 'marker' | 'unowned' | 'unrecorded'; + readonly ownershipReason?: string; + readonly purgeable: boolean; + readonly servers: readonly string[]; readonly stateSource: 'derived' | 'legacy' | 'native'; readonly status: 'known' | 'warnings'; readonly summary: { @@ -543,6 +552,10 @@ const durableStateReport = ( stateSource: DoctorDurableStateReport['stateSource'], findings: readonly DoctorDurableStateStore[], diagnostics: readonly Diagnostic[], + ownership: DoctorDurableStateReport['ownership'] = stateSource === 'legacy' ? 'legacy' : 'unrecorded', + purgeable = false, + servers: readonly string[] = [], + ownershipReason?: string, ): DoctorDurableStateReport => { const frozenDiagnostics = freezeDiagnostics(diagnostics); return Object.freeze({ @@ -550,6 +563,10 @@ const durableStateReport = ( directory, exists, findings: Object.freeze(findings.map((finding) => Object.freeze({ ...finding }))), + ownership, + ...(ownershipReason === undefined ? {} : { ownershipReason }), + purgeable, + servers: Object.freeze([...servers]), stateSource, status: frozenDiagnostics.length === 0 ? 'known' : 'warnings', summary: Object.freeze({ @@ -714,27 +731,66 @@ const inspectInstalledDurableState = async ( ): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly effective: DoctorDurableStateReport; + readonly effectiveAll: readonly DoctorDurableStateReport[]; readonly legacy?: DoctorDurableStateReport; }> => { - const resolved = receipt?.stateRoot ?? - await resolveInstalledStateRoot(pluginRoot, host, environment, home); - const effective = await inspectDurableState(resolved.root, resolved.source, host); + const locations = await resolveInstalledStateRoots(pluginRoot, host, environment, home); + const grouped = new Map(); + for (const location of locations) { + if (location.root === undefined) continue; + const current = grouped.get(location.root); + if (current === undefined) grouped.set(location.root, { servers: [location.server], source: location.source }); + else current.servers.push(location.server); + } + const effectiveAll: DoctorDurableStateReport[] = []; + for (const [root, current] of grouped) { + const recorded = receipt?.state?.roots.find((candidate) => candidate.root === root); + const decision = recorded === undefined || receipt?.state === undefined + ? undefined + : await inspectInstalledStateOwnership(receipt.state, recorded); + const ownership = recorded?.ownership.kind ?? 'unrecorded'; + const inspected = await inspectDurableState( + root, + current.source === 'derived' ? 'derived' : 'native', + host, + ); + effectiveAll.push(Object.freeze({ + ...inspected, + ownership, + ...(recorded?.ownership.kind === 'unowned' + ? { ownershipReason: recorded.ownership.reason } + : decision?.reason === undefined ? {} : { ownershipReason: decision.reason }), + purgeable: decision?.action === 'purge', + servers: Object.freeze(current.servers), + })); + } + const fallback = receipt?.stateRoot?.root ?? pluginRoot; + const effective = effectiveAll[0] ?? Object.freeze({ + ...(await inspectDurableState(fallback, receipt?.stateRoot?.source ?? 'derived', host)), + ownership: 'unrecorded' as const, + purgeable: false, + servers: Object.freeze([]), + }); const legacyRoot = join(pluginRoot, 'state'); - if (legacyRoot === resolved.root) { - return { diagnostics: effective.diagnostics, effective }; + if (legacyRoot === effective.directory) { + return { diagnostics: effective.diagnostics, effective, effectiveAll: Object.freeze(effectiveAll) }; } const legacy = await inspectDurableState(legacyRoot, 'legacy', host); - if (!legacy.exists) return { diagnostics: effective.diagnostics, effective }; + const effectiveDiagnostics = effectiveAll.flatMap((entry) => entry.diagnostics); + if (!legacy.exists) { + return { diagnostics: freezeDiagnostics(effectiveDiagnostics), effective, effectiveAll: Object.freeze(effectiveAll) }; + } const legacyDiagnostic = diagnostic( 'AB7332', - `Legacy durable state remains at ${JSON.stringify(legacyRoot)} while this install resolves framework state to ${JSON.stringify(resolved.root)}.`, + `Legacy durable state remains at ${JSON.stringify(legacyRoot)} while this install resolves framework state to ${JSON.stringify(effective.directory)}.`, '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]), + diagnostics: freezeDiagnostics([...effectiveDiagnostics, ...legacy.diagnostics, legacyDiagnostic]), effective, + effectiveAll: Object.freeze(effectiveAll), legacy, }; }; @@ -1100,6 +1156,7 @@ const cursorInventory = async ( diagnostics.push(await remnantDiagnostic(`Cursor plugin entry ${JSON.stringify(path)}`, path, remnantReceipt)); findings.push({ durableState: durableState.effective, + durableStates: durableState.effectiveAll, ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), entry, ...(remnantReceipt === undefined ? {} : { name: remnantReceipt.plugin, receipt: receiptSummary(remnantReceipt), version: remnantReceipt.version }), @@ -1151,14 +1208,6 @@ const cursorInventory = async ( } diagnostics.push(...staticDiagnostics); if (launch !== undefined) diagnostics.push(...launch.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] - ? await inspectCursorPluginHooks(path, home, { caseInsensitivePaths: platform === 'win32' }) - : undefined; - if (hooks !== undefined) diagnostics.push(...hooks.diagnostics); // The in-tree receipt is read-only evidence here: a pre-lifecycle receipt is diagnosed, never rewritten. let receipt: InstallReceipt | undefined; try { @@ -1166,11 +1215,20 @@ const cursorInventory = async ( } catch { receipt = undefined; } + const durableState = await inspectInstalledDurableState(path, 'cursor', environment, home, receipt); + diagnostics.push(...durableState.diagnostics); + const operatorEnv = await inspectOperatorEnv(path, 'cursor'); + diagnostics.push(...operatorEnv.diagnostics); + const hooks = manifest.manifest === cursorManifestCandidates[0] + ? await inspectCursorPluginHooks(path, home, { caseInsensitivePaths: platform === 'win32' }) + : undefined; + if (hooks !== undefined) diagnostics.push(...hooks.diagnostics); if (receipt?.migratedFrom !== undefined) { diagnostics.push(migratedReceiptDiagnostic('cursor', join(path, installReceiptFile), receipt)); } findings.push({ durableState: durableState.effective, + durableStates: durableState.effectiveAll, ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), entry, ...(hooks === undefined ? {} : { hooks: hooks.registration }), @@ -1325,6 +1383,7 @@ const publicHostInventory = async ( diagnostics.push(...durableState.diagnostics); findings.push({ durableState: durableState.effective, + durableStates: durableState.effectiveAll, ...(enabled === undefined ? {} : { enabled }), entry: `${row['id']} (${row['scope']})`, ...(errors.length === 0 ? {} : { errors }), @@ -1350,6 +1409,7 @@ const publicHostInventory = async ( diagnostics.push(...durableState.diagnostics); findings.push({ durableState: durableState.effective, + durableStates: durableState.effectiveAll, entry: row['pluginId'], name, path, diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 4683b3e28..0ebd0e0ee 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -21,6 +21,7 @@ import { installReceiptStorePath, isRemnantReceipt, isRuntimeStateRemnant, + readInstallReceipt, readInstallReceiptFile, replaceInstalledTree, stageArtifact, @@ -29,10 +30,12 @@ import { writeStoredInstallReceipt, type InstalledManifestIdentity, type InstalledTreeComparison, + type InstallReceipt, type InstallReceiptIdentity, type InstallRegistration, type TreeInventory, } from './receipt.ts'; +import { recordInstalledState } from './state-root.ts'; export type InstallHost = 'claude' | 'codex' | 'cursor'; export type InstallScope = 'local' | 'project' | 'user'; @@ -576,7 +579,12 @@ const installPublicCli = async ( let previousContentHash: string | undefined; // Both hosts cache at `///` (pinned by the real-host proofs), so a // reported copy locates where the reinstalled version lands. - let destination: string | undefined; + let destination = join( + publicHostCacheRoot(host, environment, home), + marketplace, + identity.plugin, + identity.version, + ); const entry = inventory.status === 'available' ? inventory.entries[0] : undefined; // The store receipt is the lifecycle record for this host-owned copy: written on every install and // replacement, and refreshed when an identical copy is found without one (pre-#101 installs). It owns @@ -623,8 +631,33 @@ const installPublicCli = async ( if (installed !== undefined && sameVersion && installed.hash === artifact.hash) { // Byte-identical, so reinstalling cannot help: the host's refusal is the artifact's own defect. if (entry.errors !== undefined && entry.errors.length > 0) throw refusedInstallFailure(host, id, entry, 'existing'); - if (previousReceipt === undefined || previousReceipt.contentHash !== artifact.hash) { - await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ ...(await receiptIdentity()), inventory: storeInventory })); + if ( + previousReceipt === undefined || + previousReceipt.contentHash !== artifact.hash || + previousReceipt.state === undefined + ) { + const receiptIdentityValue = await receiptIdentity(); + const state = await recordInstalledState({ + environment, + home, + host, + mode: 'host-cli', + plugin: identity.plugin, + pluginRoot: entry.installPath, + previous: previousReceipt?.state, + ...(projectRoot === undefined ? {} : { projectRoot }), + scope, + }); + try { + await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ + ...receiptIdentityValue, + inventory: storeInventory, + state: state.state, + })); + } catch (error) { + await state.rollback(); + throw error; + } } return { ...base, destination: entry.installPath, state: 'already-installed' }; } @@ -670,17 +703,32 @@ const installPublicCli = async ( const createdMarketplace = previousReceipt === undefined && recorded.registrations.some((registration) => registration.kind === `${host}-marketplace`); let pluginInstalled = false; + let stateRollback: (() => Promise) | undefined; try { await runHostCommand(runner, identity, host, host === 'claude' ? ['plugin', 'install', id, '--scope', scope] : ['plugin', 'add', id]); pluginInstalled = true; + const state = await recordInstalledState({ + environment, + home, + host, + mode: 'host-cli', + plugin: identity.plugin, + pluginRoot: destination, + previous: previousReceipt?.state, + ...(projectRoot === undefined ? {} : { projectRoot }), + scope, + }); + stateRollback = state.rollback; await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ ...recorded, inventory: storeInventory, + state: state.state, updatedAt: new Date().toISOString(), })); } catch (error) { + if (stateRollback !== undefined) await stateRollback(); const rollbacks: (readonly string[])[] = [ ...(pluginInstalled ? [publicHostUninstallArguments(host, id, scope)] : []), ...(createdMarketplace ? [publicHostMarketplaceRemoveArguments(marketplace)] : []), @@ -874,6 +922,47 @@ const withStagedArtifact = ( return yield* applied; }); +const attachCursorStateOwnership = async ( + destination: string, + environment: Readonly, + home: string, + previousState?: InstallReceipt['state'], +): Promise => { + const receipt = await readInstallReceipt(destination); + if (receipt === undefined) throw new Error(`Installed receipt is missing at ${destination}.`); + const recorded = await recordInstalledState({ + environment, + home, + host: 'cursor', + mode: receipt.mode, + plugin: receipt.plugin, + pluginRoot: destination, + previous: receipt.state ?? previousState, + scope: receipt.scope, + }); + try { + await writeInstallReceipt(destination, createInstallReceipt({ + ...(receipt.cursorExpansion === undefined ? {} : { cursorExpansion: receipt.cursorExpansion }), + directories: receipt.directories, + host: receipt.host, + hostDirectories: receipt.hostDirectories, + installedAt: receipt.installedAt, + inventory: { files: receipt.files, hash: receipt.contentHash }, + mode: receipt.mode, + plugin: receipt.plugin, + registrations: receipt.registrations, + scope: receipt.scope, + state: recorded.state, + updatedAt: new Date().toISOString(), + version: receipt.version, + ...(receipt.webDataRoot === undefined ? {} : { webDataRoot: receipt.webDataRoot }), + })); + } catch (error) { + await recorded.rollback(); + throw error; + } +}; + /** * The local Cursor install as an Effect program: only the leaf I/O is lifted * (root resolution, inventories, `exists`, `mkdir`, staging, receipts), the @@ -900,6 +989,8 @@ const installCursor = Effect.fnUntraced(function*( ); const installRoot = join(cursorRoot, 'plugins', 'local'); const destination = join(installRoot, identity.plugin); + const environment = options.environment ?? process.env; + const home = options.home ?? homedir(); const base = { bundleRoot: identity.bundleRoot, destination, @@ -932,6 +1023,7 @@ const installCursor = Effect.fnUntraced(function*( () => stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }), (staged) => rename(staged.root, destination), ); + yield* liftPromise(() => attachCursorStateOwnership(destination, environment, home)); return { ...base, contentHash: artifact.hash, state: 'installed' } as const; } if (resolve(identity.bundleRoot) === destination) { @@ -963,6 +1055,7 @@ const installCursor = Effect.fnUntraced(function*( hostDirectories: [], inventory: artifact, }))); + yield* liftPromise(() => attachCursorStateOwnership(destination, environment, home)); return { ...base, contentHash: artifact.hash, state: 'adopted' } as const; } // A receipt-managed identical copy whose receipt predates format/2 is upgraded in place: the @@ -978,6 +1071,9 @@ const installCursor = Effect.fnUntraced(function*( updatedAt: new Date().toISOString(), }))); } + if (comparison.receipt?.state === undefined) { + yield* liftPromise(() => attachCursorStateOwnership(destination, environment, home)); + } return { ...base, contentHash: artifact.hash, state: 'already-installed' } as const; } const replaceable = (comparison.status === 'stale' && comparison.ownership === 'receipt') || remnant @@ -992,6 +1088,7 @@ const installCursor = Effect.fnUntraced(function*( () => stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt: replacement, stageRoot: installRoot }), (staged) => replaceInstalledTree({ comparison, destination, receipt: replacement, staged }), ); + yield* liftPromise(() => attachCursorStateOwnership(destination, environment, home, comparison.receipt?.state)); // Filling a state-only shell is a fresh install of plugin content, not a replacement of any. if (remnant) return { ...base, contentHash: artifact.hash, state: 'installed' } as const; return { diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 6c57360ff..521fb96ea 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -14,7 +14,7 @@ import { rmdir, writeFile, } from 'node:fs/promises'; -import { basename, dirname, join, relative, resolve, sep } from 'node:path'; +import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; @@ -120,6 +120,33 @@ export interface InstallReceiptCursorExpansion { readonly pluginRoot: string; } +export type InstallReceiptStateUnownedReason = 'foreign-marker' | 'pre-existing' | 'unproven'; + +export interface InstallReceiptStateOwner { + readonly host: string; + readonly id: string; + readonly mode: InstallReceiptMode; + readonly plugin: string; + readonly projectRoot?: string; + readonly scope: InstallReceiptScope; +} + +export interface InstallReceiptStateRoot { + readonly canonicalRoot: string; + readonly ownership: + | { readonly kind: 'derived' } + | { readonly kind: 'marker'; readonly marker: string } + | { readonly kind: 'unowned'; readonly reason: InstallReceiptStateUnownedReason }; + readonly root: string; + readonly servers: readonly string[]; + readonly source: 'declared' | 'derived'; +} + +export interface InstallReceiptState { + readonly owner: InstallReceiptStateOwner; + readonly roots: readonly InstallReceiptStateRoot[]; +} + export interface InstallReceipt { readonly contentHash: string; readonly cursorExpansion?: InstallReceiptCursorExpansion; @@ -158,6 +185,8 @@ export interface InstallReceipt { /** Host registrations the installer performed, in the order it performed them. */ readonly registrations: readonly InstallRegistration[]; readonly scope: InstallReceiptScope; + /** Per-server runtime locations and the independent evidence authorizing deletion. */ + readonly state?: InstallReceiptState; /** Effective framework state root retained by a Cursor `--keep-data` uninstall. */ readonly stateRoot?: { readonly root: string; @@ -452,6 +481,78 @@ const isRegistrationKind = (value: unknown): value is InstallRegistrationKind => const optionalString = (value: unknown): value is string | undefined => value === undefined || typeof value === 'string'; +const readReceiptState = (value: unknown): InstallReceiptState | undefined => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const ownerValue = record['owner']; + if (ownerValue === null || typeof ownerValue !== 'object' || Array.isArray(ownerValue)) return undefined; + const owner = ownerValue as Record; + if ( + typeof owner['host'] !== 'string' || + typeof owner['id'] !== 'string' || + owner['id'].length === 0 || + !isReceiptMode(owner['mode']) || + typeof owner['plugin'] !== 'string' || + !optionalString(owner['projectRoot']) || + !isReceiptScope(owner['scope']) || + !Array.isArray(record['roots']) + ) { + return undefined; + } + const roots: InstallReceiptStateRoot[] = []; + for (const valueRoot of record['roots']) { + if (valueRoot === null || typeof valueRoot !== 'object' || Array.isArray(valueRoot)) return undefined; + const root = valueRoot as Record; + const ownershipValue = root['ownership']; + if ( + typeof root['canonicalRoot'] !== 'string' || + !isAbsolute(root['canonicalRoot']) || + typeof root['root'] !== 'string' || + !isAbsolute(root['root']) || + !Array.isArray(root['servers']) || + !root['servers'].every((server) => typeof server === 'string' && server.length > 0) || + (root['source'] !== 'declared' && root['source'] !== 'derived') || + ownershipValue === null || + typeof ownershipValue !== 'object' || + Array.isArray(ownershipValue) + ) { + return undefined; + } + const ownershipRecord = ownershipValue as Record; + const ownership = ownershipRecord['kind'] === 'derived' + ? Object.freeze({ kind: 'derived' as const }) + : ownershipRecord['kind'] === 'marker' && + typeof ownershipRecord['marker'] === 'string' && + ownershipRecord['marker'] === join(root['root'], '.agent-bundle-state-owner.json') + ? Object.freeze({ kind: 'marker' as const, marker: ownershipRecord['marker'] }) + : ownershipRecord['kind'] === 'unowned' && + (ownershipRecord['reason'] === 'foreign-marker' || + ownershipRecord['reason'] === 'pre-existing' || + ownershipRecord['reason'] === 'unproven') + ? Object.freeze({ kind: 'unowned' as const, reason: ownershipRecord['reason'] }) + : undefined; + if (ownership === undefined) return undefined; + roots.push(Object.freeze({ + canonicalRoot: root['canonicalRoot'], + ownership, + root: root['root'], + servers: Object.freeze([...root['servers']]), + source: root['source'], + })); + } + return Object.freeze({ + owner: Object.freeze({ + host: owner['host'], + id: owner['id'], + mode: owner['mode'], + plugin: owner['plugin'], + ...(owner['projectRoot'] === undefined ? {} : { projectRoot: owner['projectRoot'] }), + scope: owner['scope'], + }), + roots: Object.freeze(roots), + }); +}; + const readRegistration = (value: unknown): InstallRegistration | undefined => { if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; const record = value as Record; @@ -498,6 +599,8 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { return undefined; } const cursorExpansion = readCursorExpansion(record['cursorExpansion']); + const state = readReceiptState(record['state']); + if (record['state'] !== undefined && state === undefined) return undefined; const stateRootRecord = record['stateRoot']; const stateRoot = stateRootRecord !== undefined && stateRootRecord !== null && @@ -520,6 +623,7 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { host: record['host'], installedAt: record['installedAt'], plugin: record['plugin'], + ...(state === undefined ? {} : { state }), ...(stateRoot === undefined ? {} : { stateRoot }), version: record['version'], ...(typeof record['webDataRoot'] === 'string' ? { webDataRoot: record['webDataRoot'] } : {}), @@ -593,6 +697,7 @@ export const createInstallReceipt = (options: InstallReceiptIdentity & { readonly cursorExpansion?: InstallReceiptCursorExpansion; readonly directories?: readonly string[]; readonly inventory: TreeInventory; + readonly state?: InstallReceiptState; readonly stateRoot?: InstallReceipt['stateRoot']; readonly webDataRoot?: string; }): InstallReceipt => { @@ -611,6 +716,18 @@ 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.state === undefined + ? {} + : { + state: Object.freeze({ + owner: Object.freeze({ ...options.state.owner }), + roots: Object.freeze(options.state.roots.map((root) => Object.freeze({ + ...root, + ownership: Object.freeze({ ...root.ownership }), + servers: Object.freeze([...root.servers]), + }))), + }), + }), ...(options.stateRoot === undefined ? {} : { stateRoot: Object.freeze({ ...options.stateRoot }) }), updatedAt: options.updatedAt ?? installedAt, version: options.version, diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index 155fff6c8..2a4f4f6b1 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -1,17 +1,33 @@ -import { createHash } from 'node:crypto'; -import { readFile, realpath } from 'node:fs/promises'; -import { basename, isAbsolute, join, resolve } from 'node:path'; +import { createHash, randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, readFile, realpath, rm, rmdir } from 'node:fs/promises'; +import { basename, dirname, 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'; +import type { + InstallReceiptMode, + InstallReceiptScope, + InstallReceiptState, + InstallReceiptStateOwner, + InstallReceiptStateRoot, +} from './receipt.ts'; export interface InstalledStateRoot { readonly root: string; readonly source: 'derived' | 'native'; } +export interface InstalledStateLocation { + readonly root?: string; + readonly server: string; + readonly source: 'declared' | 'derived'; + readonly status: 'resolved' | 'unproven'; +} + +export const stateOwnershipMarkerFile = '.agent-bundle-state-owner.json'; + const manifestCandidates = (host: InstallHost): readonly string[] => { switch (host) { case 'claude': @@ -48,7 +64,10 @@ const installedUserDataStateRoot = ( return join(stateHome, safePluginSegment.test(name) ? `${name}-${digest}` : `plugin-${digest}`); }; -const declaredStateRoot = async (pluginRoot: string, host: InstallHost): Promise => { +const installedServers = async ( + pluginRoot: string, + host: InstallHost, +): Promise; readonly name: string }[]> => { for (const relativePath of manifestCandidates(host)) { let document: unknown; try { @@ -58,40 +77,288 @@ const declaredStateRoot = async (pluginRoot: string, host: InstallHost): Promise 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'][pluginStateRootEnvAnchor]; - 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 Object.entries(document['mcpServers']) + .filter((entry): entry is [string, Record] => isRecord(entry[1])) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([name, server]) => ({ + ...(typeof server['cwd'] === 'string' ? { cwd: server['cwd'] } : {}), + environment: isRecord(server['env']) + ? Object.fromEntries(Object.entries(server['env']).filter((entry): entry is [string, string] => + typeof entry[1] === 'string')) + : {}, + name, + })); } - return undefined; + return []; }; -export const resolveInstalledStateRoot = async ( +const expandPluginRoot = (value: string, pluginRoot: string): string => + value + .replaceAll('${CLAUDE_PLUGIN_ROOT}', pluginRoot) + .replaceAll('${CURSOR_PLUGIN_ROOT}', pluginRoot) + .replaceAll('${PLUGIN_ROOT}', pluginRoot); + +export const resolveInstalledStateRoots = async ( pluginRoot: string, host: InstallHost, environment: Readonly, home: string, -): Promise => { +): 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[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: installedUserDataStateRoot(canonicalRoot, environment, home), source: 'derived' as const } - : { root: declared, source: 'native' as const }); + const servers = await installedServers(canonicalRoot, host); + if (servers.length === 0) { + return Object.freeze([Object.freeze({ + root: installedUserDataStateRoot(canonicalRoot, environment, home), + server: 'default', + source: 'derived' as const, + status: 'resolved' as const, + })]); + } + return Object.freeze(servers.map((server) => { + const declared = server.environment[pluginStateRootEnvAnchor]; + if (declared === undefined || declared.trim() === '') { + return Object.freeze({ + root: installedUserDataStateRoot(canonicalRoot, environment, home), + server: server.name, + source: 'derived' as const, + status: 'resolved' as const, + }); + } + const expanded = expandPluginRoot(declared, canonicalRoot); + if (/\$\{[^}]*\}/u.test(expanded)) { + return Object.freeze({ server: server.name, source: 'declared' as const, status: 'unproven' as const }); + } + if (isAbsolute(expanded)) { + return Object.freeze({ + root: resolve(expanded), + server: server.name, + source: 'declared' as const, + status: 'resolved' as const, + }); + } + if (server.cwd === undefined) { + return Object.freeze({ server: server.name, source: 'declared' as const, status: 'unproven' as const }); + } + const expandedCwd = expandPluginRoot(server.cwd, canonicalRoot); + if (/\$\{[^}]*\}/u.test(expandedCwd)) { + return Object.freeze({ server: server.name, source: 'declared' as const, status: 'unproven' as const }); + } + const cwd = isAbsolute(expandedCwd) ? resolve(expandedCwd) : resolve(canonicalRoot, expandedCwd); + return Object.freeze({ + root: resolve(cwd, expanded), + server: server.name, + source: 'declared' as const, + status: 'resolved' as const, + }); + })); +}; + +export const resolveInstalledStateRoot = async ( + pluginRoot: string, + host: InstallHost, + environment: Readonly, + home: string, +): Promise => { + const locations = await resolveInstalledStateRoots(pluginRoot, host, environment, home); + const first = locations.find((location) => location.root !== undefined); + if (first !== undefined && first.root !== undefined) { + return Object.freeze({ root: first.root, source: first.source === 'derived' ? 'derived' : 'native' }); + } + const canonicalRoot = await realpath(pluginRoot).catch(() => resolve(pluginRoot)); + return Object.freeze({ + root: installedUserDataStateRoot(canonicalRoot, environment, home), + source: 'derived', + }); +}; + +const canonicalPath = async (path: string): Promise => { + try { + return await realpath(path); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + const parent = dirname(path); + if (parent === path) return resolve(path); + return join(await canonicalPath(parent), basename(path)); + } +}; + +const markerDocument = (owner: InstallReceiptStateOwner): string => + `${JSON.stringify({ format: 1, owner }, null, 2)}\n`; + +const markerMatches = async (marker: string, owner: InstallReceiptStateOwner): Promise => { + try { + const document = JSON.parse(await readFile(marker, 'utf8')) as unknown; + return isRecord(document) && + document['format'] === 1 && + isRecord(document['owner']) && + document['owner']['id'] === owner.id && + document['owner']['host'] === owner.host && + document['owner']['mode'] === owner.mode && + document['owner']['plugin'] === owner.plugin && + document['owner']['scope'] === owner.scope && + document['owner']['projectRoot'] === owner.projectRoot; + } catch (error) { + if (isErrno(error, 'ENOENT') || error instanceof SyntaxError) return false; + throw error; + } +}; + +export interface RecordInstalledStateOptions { + readonly environment: Readonly; + readonly home: string; + readonly host: InstallHost; + readonly mode: InstallReceiptMode; + readonly plugin: string; + readonly pluginRoot: string; + readonly previous?: InstallReceiptState; + readonly projectRoot?: string; + readonly scope: InstallReceiptScope; +} + +export interface RecordedInstalledState { + readonly rollback: () => Promise; + readonly state: InstallReceiptState; +} + +export interface InstalledStateOwnershipDecision { + readonly action: 'absent' | 'purge' | 'retain'; + readonly path: string; + readonly reason?: string; +} + +export const inspectInstalledStateOwnership = async ( + state: InstallReceiptState, + root: InstallReceiptStateRoot, +): Promise => { + let metadata; + try { + metadata = await lstat(root.root); + } catch (error) { + if (isErrno(error, 'ENOENT')) return Object.freeze({ action: 'absent', path: root.root }); + throw error; + } + if (root.ownership.kind === 'unowned') { + return Object.freeze({ action: 'retain', path: root.root, reason: root.ownership.reason }); + } + if (!metadata.isDirectory() || metadata.isSymbolicLink()) { + return Object.freeze({ action: 'retain', path: root.root, reason: 'unsupported-entry' }); + } + if (await realpath(root.root) !== root.canonicalRoot) { + return Object.freeze({ action: 'retain', path: root.root, reason: 'canonical-path-changed' }); + } + if ( + root.ownership.kind === 'marker' && + ( + root.ownership.marker !== join(root.root, stateOwnershipMarkerFile) || + !(await markerMatches(root.ownership.marker, state.owner)) + ) + ) { + return Object.freeze({ action: 'retain', path: root.root, reason: 'marker-mismatch' }); + } + return Object.freeze({ action: 'purge', path: root.root }); +}; + +export const recordInstalledState = async ( + options: RecordInstalledStateOptions, +): Promise => { + const owner = Object.freeze({ + host: options.host, + id: options.previous?.owner.id ?? randomUUID(), + mode: options.mode, + plugin: options.plugin, + ...(options.projectRoot === undefined ? {} : { projectRoot: options.projectRoot }), + scope: options.scope, + }); + const locations = await resolveInstalledStateRoots( + options.pluginRoot, + options.host, + options.environment, + options.home, + ); + const roots = new Map(); + for (const location of locations) { + if (location.root === undefined) continue; + const previous = roots.get(location.root); + if (previous === undefined) roots.set(location.root, { location, servers: [location.server] }); + else previous.servers.push(location.server); + } + const created: string[] = []; + const recorded: InstallReceiptStateRoot[] = []; + for (const { location, servers } of roots.values()) { + const root = location.root as string; + const canonicalRoot = await canonicalPath(root); + if (location.source === 'derived') { + recorded.push(Object.freeze({ + canonicalRoot, + ownership: Object.freeze({ kind: 'derived' as const }), + root, + servers: Object.freeze(servers), + source: 'derived', + })); + continue; + } + const marker = join(root, stateOwnershipMarkerFile); + let existed = true; + try { + await lstat(root); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + existed = false; + } + let ownership: InstallReceiptStateRoot['ownership']; + if (!existed) { + await mkdir(dirname(root), { recursive: true }); + try { + await mkdir(root); + const handle = await open(marker, 'wx'); + try { + await handle.writeFile(markerDocument(owner), 'utf8'); + } finally { + await handle.close(); + } + created.push(root); + ownership = Object.freeze({ kind: 'marker' as const, marker }); + } catch (error) { + if (!isErrno(error, 'EEXIST')) throw error; + ownership = Object.freeze({ kind: 'unowned' as const, reason: 'pre-existing' as const }); + } + } else if (await markerMatches(marker, owner)) { + ownership = Object.freeze({ kind: 'marker' as const, marker }); + } else { + let markerExists = true; + try { + await lstat(marker); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + markerExists = false; + } + ownership = Object.freeze({ + kind: 'unowned' as const, + reason: markerExists ? 'foreign-marker' as const : 'pre-existing' as const, + }); + } + recorded.push(Object.freeze({ + canonicalRoot: await canonicalPath(root), + ownership, + root, + servers: Object.freeze(servers), + source: 'declared', + })); + } + return Object.freeze({ + rollback: async () => { + for (const root of created.reverse()) { + await rm(join(root, stateOwnershipMarkerFile), { force: true }); + await rmdir(root).catch((error: unknown) => { + if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) throw error; + }); + } + }, + state: Object.freeze({ owner, roots: Object.freeze(recorded) }), + }); }; export const installedWebDataRoot = (pluginRoot: string, home: string): string => diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index d7d449a12..0cbea94f3 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -456,11 +456,34 @@ const cursorUninstallerSource = (): readonly string[] => [ ' }', ' if (await exists(join(destination, receiptFile))) files.push(join(destination, receiptFile));', " const [resolvedStateDirectory, stateDirectory, resolvedWebDataDirectory, resolvedStateSource] = await runtimeStateRoots();", - ' const effectiveStateDirectory = receipt?.stateRoot?.root ?? resolvedStateDirectory;', - ' const stateSource = receipt?.stateRoot?.source ?? resolvedStateSource;', + ' const retainedState = [];', + ' const ownedStatePaths = [];', + ' if (receipt?.state !== undefined) {', + ' for (const root of receipt.state.roots) {', + ' let metadata;', + " try { metadata = await lstat(root.root); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }", + " if (root.ownership.kind === 'unowned') { retainedState.push({ path: root.root, reason: root.ownership.reason }); continue; }", + " if (!metadata.isDirectory() || metadata.isSymbolicLink()) { retainedState.push({ path: root.root, reason: 'unsupported-entry' }); continue; }", + " if (await realpath(root.root) !== root.canonicalRoot) { retainedState.push({ path: root.root, reason: 'canonical-path-changed' }); continue; }", + " if (root.ownership.kind === 'marker') {", + ' let marker;', + " try { marker = JSON.parse(await readFile(root.ownership.marker, 'utf8')); } catch { marker = undefined; }", + ' const owner = marker?.owner;', + ' const expected = receipt.state.owner;', + ' if (root.ownership.marker !== join(root.root, stateMarkerFile) || marker?.format !== 1 || owner?.id !== expected.id ||', + ' owner?.host !== expected.host || owner?.mode !== expected.mode || owner?.plugin !== expected.plugin ||', + " owner?.scope !== expected.scope || owner?.projectRoot !== expected.projectRoot) { retainedState.push({ path: root.root, reason: 'marker-mismatch' }); continue; }", + ' }', + ' ownedStatePaths.push(root.root);', + ' }', + ' } else {', + ' let metadata;', + " try { metadata = await lstat(resolvedStateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", + " if (metadata?.isDirectory()) retainedState.push({ path: resolvedStateDirectory, reason: 'unproven' });", + ' }', ' const webDataDirectory = receipt?.webDataRoot ?? resolvedWebDataDirectory;', - ' const externalDataPaths = [];', - ' for (const path of [effectiveStateDirectory, webDataDirectory]) {', + ' const externalDataPaths = [...ownedStatePaths];', + ' for (const path of [webDataDirectory]) {', ' if (path === stateDirectory) continue;', ' let metadata;', " try { metadata = await lstat(path); } catch (error) { if (error?.code === 'ENOENT') continue; throw error; }", @@ -473,7 +496,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' // 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 = [...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)'])];", + " const dataKinds = [...externalDataPaths.map((path) => ownedStatePaths.includes(path) ? `owned 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.', @@ -498,15 +521,16 @@ const cursorUninstallerSource = (): readonly string[] => [ ' else { dataPaths.push(pluginData); dataKinds.push(`the PLUGIN_DATA directory ${pluginData}`); }', ' }', ' }', - " const dataOutcome = dataPaths.length === 0 ? 'absent' : purgeData ? 'purged' : 'kept';", - ' const dataDetail = dataPaths.length === 0', + " const retainedStateNote = retainedState.length === 0 ? '' : ` Retained ${retainedState.map((entry) => `${entry.path} (${entry.reason})`).join(', ')} because the receipt does not prove exclusive ownership.`;", + " const dataOutcome = dataPaths.length === 0 ? retainedState.length === 0 ? 'absent' : 'kept' : purgeData ? 'purged' : 'kept';", + ' const dataDetail = dataPaths.length === 0 && retainedState.length === 0', " ? `No durable runtime state exists (${emptyState === undefined ? 'no state/ under the installed plugin root' : 'state/ under the installed plugin root is empty and is pruned'}${emptyPluginData === undefined ? '' : `; the installer-created PLUGIN_DATA directory ${emptyPluginData} is empty and is pruned`}).${foreignNote}`", ' : 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}`;", + " ? `${dataPaths.length === 0 ? 'No owned durable runtime state is removed.' : `Durable runtime state — ${dataKinds.join(' and ')} — is removed (--purge-data --confirm-purge).`}${retainedStateNote}${foreignNote}`", + " : `Durable runtime state${dataKinds.length === 0 ? '' : ` — ${dataKinds.join(' and ')}`} — is kept; pass --purge-data --confirm-purge to remove owned roots.${retainedStateNote}${foreignNote}`;", ' // 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 keepRoot = !purgeData && [...dataPaths, ...retainedState.map((entry) => entry.path)].some((path) => path !== stateDirectory);', ' const directories = [', ' ...ownedDirectories.map((directory) => join(destination, directory)),', ' ...(keepRoot ? [] : [destination]),', @@ -522,7 +546,7 @@ const cursorUninstallerSource = (): readonly string[] => [ ' // A keep-data rerun over a remnant whose preserved data (or retained unowned entries) are still there is the documented', ' // no-op. Once state/ and the PLUGIN_DATA directory are gone or emptied by hand the remnant guards nothing, and the rerun', ' // consumes it (receipt, empty plugin root, the host and plugin-data directories it recorded) like an explicit purge would.', - ' const remnantGuards = dataPaths.length > 0 || (await listRetained(destination, ownedSet, ownedDirectorySet)).length > 0;', + ' const remnantGuards = dataPaths.length > 0 || retainedState.length > 0 || (await listRetained(destination, ownedSet, ownedDirectorySet)).length > 0;', ' if (remnantOnly && !purgeData && remnantGuards && files.length === 1 && files[0] === join(destination, receiptFile)) {', ' // A rerun over what an earlier --keep-data uninstall left behind, still keeping the data: nothing to remove, so the', ' // remnant receipt stays and the run is the documented no-op.', @@ -552,6 +576,7 @@ const cursorUninstallerSource = (): readonly string[] => [ " printPaths('Would remove directory', [...purgedDirectories, ...prunable]);", " console.log(`Data (${purgeData ? 'purge' : 'keep'}): ${dataOutcome} — ${dataDetail}`);", ' for (const path of dataPaths) console.log(` ${path}`);', + ' for (const entry of retainedState) console.log(` retained ${entry.path}: ${entry.reason}`);', ' const retained = await listRetained(destination, ownedSet, ownedDirectorySet);', " if (retained.length > 0) printPaths(`Retained unowned under ${destination}:`, retained);", ' if (!prunable.includes(destination)) console.log(`Remnant receipt (would be written): ${join(destination, receiptFile)} — owns no files; keeps the created host directories receipt-owned for a later purge.`);', @@ -575,7 +600,8 @@ 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 } : {}),', + ' ...(receipt?.state === undefined ? {} : { state: receipt.state }),', + ' ...(keepRoot ? { webDataRoot: webDataDirectory } : {}),', ' }));', ' console.log(`Remnant receipt: ${join(destination, receiptFile)} — owns no files; keeps the created host directories receipt-owned for a later purge.`);', ' }', @@ -911,6 +937,18 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' registrationKinds.includes(value.kind) &&', " (value.commit === undefined || typeof value.commit === 'string') && (value.id === undefined || typeof value.id === 'string') &&", " (value.name === undefined || typeof value.name === 'string') && (value.scope === undefined || isScope(value.scope));", + "const isStateOwnership = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) && (", + " value.kind === 'derived' ||", + " (value.kind === 'marker' && typeof value.marker === 'string' && isAbsolute(value.marker)) ||", + " (value.kind === 'unowned' && ['foreign-marker', 'pre-existing', 'unproven'].includes(value.reason)));", + "const isReceiptState = (value) => value !== null && typeof value === 'object' && !Array.isArray(value) &&", + " value.owner !== null && typeof value.owner === 'object' && !Array.isArray(value.owner) &&", + " typeof value.owner.id === 'string' && value.owner.id.length > 0 && typeof value.owner.host === 'string' &&", + " ['host-cli', 'local', 'marketplace'].includes(value.owner.mode) && typeof value.owner.plugin === 'string' && isScope(value.owner.scope) &&", + " Array.isArray(value.roots) && value.roots.every((root) => root !== null && typeof root === 'object' && !Array.isArray(root) &&", + " typeof root.root === 'string' && isAbsolute(root.root) && typeof root.canonicalRoot === 'string' && isAbsolute(root.canonicalRoot) &&", + " ['declared', 'derived'].includes(root.source) && Array.isArray(root.servers) && root.servers.every((server) => typeof server === 'string') &&", + ' isStateOwnership(root.ownership));', '// Same shape check as the core reader: a receipt missing any field reads as absent. A format/1 receipt (#420)', '// is read with its lifecycle fields synthesized (local mode, user scope, one cursor-local-plugin registration,', '// no host directories) and `migratedFrom` set; the next replacement rewrites it as the current format.', @@ -926,6 +964,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): 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) ||', + ' (value.state !== undefined && !isReceiptState(value.state)) ||', " (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) {', @@ -1053,6 +1092,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' plugin: pluginName,', " registrations: options.registrations ?? [{ kind: 'cursor-local-plugin' }],", " scope: 'user',", + ' ...(options.state === undefined ? {} : { state: options.state }),', ' ...(options.stateRoot === undefined ? {} : { stateRoot: options.stateRoot }),', ' updatedAt: now,', ' version: pluginVersion,', @@ -1060,6 +1100,99 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " }, null, 2) + '\\n';", '};', '', + "const stateMarkerFile = '.agent-bundle-state-owner.json';", + "const expandStatePath = (value, root) => value.replaceAll('${CURSOR_PLUGIN_ROOT}', root).replaceAll('${PLUGIN_ROOT}', root);", + 'const canonicalPath = async (path) => {', + ' try { return await realpath(path); }', + " catch (error) { if (error?.code !== 'ENOENT') throw error; const parent = dirname(path); return parent === path ? resolve(path) : join(await canonicalPath(parent), basename(path)); }", + '};', + 'const stateLocations = async () => {', + ' const canonical = await realpath(destination);', + ' let servers = [];', + " 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; }", + " if (document?.mcpServers !== null && typeof document?.mcpServers === 'object' && !Array.isArray(document.mcpServers)) {", + ' servers = Object.entries(document.mcpServers).sort(([left], [right]) => left.localeCompare(right));', + ' 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 derived = join(stateHome, `${name}-${digest}`);', + " if (servers.length === 0) return [{ root: derived, server: 'default', source: 'derived' }];", + ' const locations = [];', + ' for (const [server, definition] of servers) {', + " const value = definition?.env?.AGENT_BUNDLE_STATE_ROOT;", + " if (typeof value !== 'string' || value.trim() === '') { locations.push({ root: derived, server, source: 'derived' }); continue; }", + ' const expanded = expandStatePath(value, canonical);', + ' if (/\\$\\{[^}]*\\}/u.test(expanded)) continue;', + ' if (isAbsolute(expanded)) { locations.push({ root: resolve(expanded), server, source: \'declared\' }); continue; }', + " if (typeof definition?.cwd !== 'string') continue;", + ' const expandedCwd = expandStatePath(definition.cwd, canonical);', + ' if (/\\$\\{[^}]*\\}/u.test(expandedCwd)) continue;', + ' const cwd = isAbsolute(expandedCwd) ? resolve(expandedCwd) : resolve(canonical, expandedCwd);', + " locations.push({ root: resolve(cwd, expanded), server, source: 'declared' });", + ' }', + ' return locations;', + '};', + 'const attachStateOwnership = async (previousState) => {', + ' const receipt = await readReceipt(destination);', + ' if (receipt === undefined) throw new Error(`Installed receipt is missing at ${destination}.`);', + ' const owner = previousState?.owner ?? { host: \'cursor\', id: randomUUID(), mode: \'local\', plugin: pluginName, scope: \'user\' };', + ' const grouped = new Map();', + ' for (const location of await stateLocations()) {', + ' const current = grouped.get(location.root);', + ' if (current === undefined) grouped.set(location.root, { ...location, servers: [location.server] });', + ' else current.servers.push(location.server);', + ' }', + ' const roots = [];', + ' const created = [];', + ' const markerOwns = async (marker) => {', + ' let document;', + " try { document = JSON.parse(await readFile(marker, 'utf8')); } catch { return false; }", + ' const actual = document?.owner;', + ' return document?.format === 1 && actual?.id === owner.id && actual?.host === owner.host && actual?.mode === owner.mode &&', + ' actual?.plugin === owner.plugin && actual?.scope === owner.scope && actual?.projectRoot === owner.projectRoot;', + ' };', + ' for (const location of grouped.values()) {', + ' const canonicalRoot = await canonicalPath(location.root);', + " if (location.source === 'derived') { roots.push({ canonicalRoot, ownership: { kind: 'derived' }, root: location.root, servers: location.servers, source: 'derived' }); continue; }", + ' const marker = join(location.root, stateMarkerFile);', + ' let existed = true;', + " try { await lstat(location.root); } catch (error) { if (error?.code !== 'ENOENT') throw error; existed = false; }", + ' let ownership;', + ' if (!existed) {', + ' await mkdir(dirname(location.root), { recursive: true });', + ' try {', + ' await mkdir(location.root);', + " const handle = await open(marker, 'wx');", + " try { await handle.writeFile(`${JSON.stringify({ format: 1, owner }, null, 2)}\\n`, 'utf8'); } finally { await handle.close(); }", + " ownership = { kind: 'marker', marker };", + ' created.push(location.root);', + " } catch (error) { if (error?.code !== 'EEXIST') throw error; ownership = { kind: 'unowned', reason: 'pre-existing' }; }", + ' } else if (await markerOwns(marker)) ownership = { kind: \'marker\', marker };', + ' else {', + ' let markerExists = true;', + " try { await lstat(marker); } catch (error) { if (error?.code !== 'ENOENT') throw error; markerExists = false; }", + " ownership = { kind: 'unowned', reason: markerExists ? 'foreign-marker' : 'pre-existing' };", + ' }', + ' roots.push({ canonicalRoot: await canonicalPath(location.root), ownership, root: location.root, servers: location.servers, source: \'declared\' });', + ' }', + ' try {', + ' await writeReceiptFile(join(destination, receiptFile), `${JSON.stringify({ ...receipt, state: { owner, roots }, updatedAt: new Date().toISOString() }, null, 2)}\\n`);', + ' } catch (error) {', + ' for (const root of created.reverse()) {', + ' await rm(join(root, stateMarkerFile), { force: true });', + " try { await rmdir(root); } catch (rollbackError) { if (!['ENOENT', 'ENOTEMPTY'].includes(rollbackError?.code)) throw rollbackError; }", + ' }', + ' throw error;', + ' }', + '};', + '', '// Staged sibling copy on the destination filesystem so every later rename is atomic.', 'const stage = async (tree, receiptOptions = {}) => {', ' const parent = await mkdtemp(join(installRoot, `.${basename(destination)}.stage-`));', @@ -1280,6 +1413,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const staged = await stage(artifact, { hostDirectories: createdHostDirectories });', ' try {', ' await rename(staged.root, destination);', + ' await attachStateOwnership();', ' console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' reportExpansion();', ' } finally {', @@ -1334,6 +1468,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' // Byte-identical pre-receipt copy: adoption only writes the receipt (adoption created no directories),', ' // through an exclusively created random sibling so no existing file or link is followed or overwritten.', ' await writeReceiptFile(join(destination, receiptFile), receiptFor(artifact, { directories: [], hostDirectories: [] }));', + ' await attachStateOwnership();', ' console.log(`Adopted ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' reportExpansion();', ' process.exit(0);', @@ -1345,6 +1480,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' directories: receipt.directories, hostDirectories: receipt.hostDirectories, installedAt: receipt.installedAt,', ' }));', ' }', + ' if (ownership === \'receipt\' && receipt.state === undefined) await attachStateOwnership();', ' console.log(`Already installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' process.exit(0);', '}', @@ -1428,6 +1564,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' directories, hostDirectories: previous?.hostDirectories ?? [], installedAt: previous?.installedAt,', " }), 'utf8');", ' await rename(join(staged.root, receiptFile), join(destination, receiptFile));', + ' await attachStateOwnership(previous?.state);', ' if (stateOnlyRemnant) console.log(`Installed ${pluginName}@${pluginVersion} at ${destination} (content ${short(artifact.hash)})`);', ' else console.log(`Replaced ${pluginName}@${pluginVersion} at ${destination} (content ${short(installedHash)} -> ${short(artifact.hash)})`);', ' reportExpansion();', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 407cce7a0..c89950e58 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, type InstalledStateRoot, resolveInstalledStateRoot } from './state-root.ts'; +import { inspectInstalledStateOwnership, installedWebDataRoot, resolveInstalledStateRoot } from './state-root.ts'; /** * `agent-bundle uninstall ` (#101): the receipt-owned reverse of @@ -99,6 +99,8 @@ export interface UninstallDataReport { /** The durable-state paths the decision applied to (absolute). */ readonly paths: readonly string[]; readonly policy: UninstallDataPolicy; + /** Recorded state roots retained because this installation lacks valid deletion authority. */ + readonly retained?: readonly { readonly path: string; readonly reason: string }[]; } /** @@ -442,7 +444,6 @@ interface CursorLocalData { /** Whether any durable state root exists. */ readonly present: boolean; readonly report: UninstallDataReport; - readonly stateRoot: InstalledStateRoot; readonly webDataRoot: string; } @@ -456,18 +457,27 @@ const cursorLocalData = async ( home: string, ): Promise => { const stateDirectory = join(destination, 'state'); - const effectiveState = receipt?.stateRoot ?? - await resolveInstalledStateRoot(destination, 'cursor', environment, home); const webData = receipt?.webDataRoot ?? installedWebDataRoot(destination, home); const paths: string[] = []; + const retainedState: { path: string; reason: 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 (receipt?.state !== undefined) { + for (const root of receipt.state.roots) { + if (root.root === stateDirectory) continue; + const decision = await inspectInstalledStateOwnership(receipt.state, root); + if (decision.action === 'purge') { + paths.push(root.root); + kinds.push(`${root.source} framework state root ${root.root}`); + } else if (decision.action === 'retain') { + retainedState.push({ path: root.root, reason: decision.reason ?? 'unproven' }); + } + } + } else { + const observed = receipt?.stateRoot ?? await resolveInstalledStateRoot(destination, 'cursor', environment, home); + if (observed.root !== stateDirectory && await realDirectory(observed.root, 'cursor') !== undefined) { + retainedState.push({ path: observed.root, reason: 'unproven' }); + } } if (await realDirectory(stateDirectory, 'cursor') !== undefined) { if ((await readdir(stateDirectory)).length === 0) { @@ -502,7 +512,7 @@ const cursorLocalData = async ( const foreignNote = foreignPluginData === undefined ? '' : ` The receipt records PLUGIN_DATA at ${foreignPluginData}, outside this home's agent-bundle/plugin-data; it is not touched.`; - if (paths.length === 0) { + if (paths.length === 0 && retainedState.length === 0) { return { ...(emptyPluginData === undefined ? {} : { emptyPluginData }), ...(emptyState === undefined ? {} : { emptyState }), @@ -517,23 +527,27 @@ const cursorLocalData = async ( paths: Object.freeze([]), policy, }), - stateRoot: effectiveState, webDataRoot: webData, }; } + const retainedNote = retainedState.length === 0 + ? '' + : ` Retained ${retainedState.map((entry) => `${entry.path} (${entry.reason})`).join(', ')} because the receipt does not prove exclusive ownership.`; return { ...(emptyPluginData === undefined ? {} : { emptyPluginData }), ...(emptyState === undefined ? {} : { emptyState }), present: true, report: Object.freeze({ detail: policy === 'purge' - ? `Durable runtime state — ${kinds.join(' and ')} — is removed (--purge-data --confirm-purge).${foreignNote}` - : `Durable runtime state — ${kinds.join(' and ')} — is kept; pass --purge-data --confirm-purge to remove it.${foreignNote}`, - outcome: policy === 'purge' ? 'purged' : 'kept', + ? `${paths.length === 0 ? 'No owned durable runtime state is removed.' : `Durable runtime state — ${kinds.join(' and ')} — is removed (--purge-data --confirm-purge).`}${retainedNote}${foreignNote}` + : `Durable runtime state${kinds.length === 0 ? '' : ` — ${kinds.join(' and ')}`} — is kept; pass --purge-data --confirm-purge to remove owned roots.${retainedNote}${foreignNote}`, + outcome: policy === 'purge' && paths.length > 0 ? 'purged' : 'kept', paths: Object.freeze(paths), policy, + ...(retainedState.length === 0 + ? {} + : { retained: Object.freeze(retainedState.map((entry) => Object.freeze(entry))) }), }), - stateRoot: effectiveState, webDataRoot: webData, }; }; @@ -695,7 +709,8 @@ const uninstallCursorLocal = async ( plugin: identity.plugin, registrations: [], scope: 'user', - ...(keepRoot ? { stateRoot: data.stateRoot, webDataRoot: data.webDataRoot } : {}), + ...(ownership.receipt?.state === undefined ? {} : { state: ownership.receipt.state }), + ...(keepRoot ? { webDataRoot: data.webDataRoot } : {}), updatedAt: new Date().toISOString(), version: ownership.receipt?.version ?? identity.version, })); @@ -1103,25 +1118,43 @@ const publicHostData = async ( sharedWith: readonly string[] | 'unknown', environment: Readonly, home: string, + receipt: InstallReceipt | undefined, ): Promise => { const paths: string[] = []; + const retainedState: { path: string; reason: string }[] = []; + if (receipt?.state !== undefined) { + for (const root of receipt.state.roots) { + const decision = await inspectInstalledStateOwnership(receipt.state, root); + if (decision.action === 'purge') paths.push(decision.path); + if (decision.action === 'retain') { + retainedState.push({ path: decision.path, reason: decision.reason ?? 'unproven' }); + } + } + } if (entry !== undefined) { const legacyStateRoot = join(entry.installPath, 'state'); - const effectiveState = await resolveInstalledStateRoot(entry.installPath, host, environment, 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); } + if (receipt?.state === undefined) { + const observed = await resolveInstalledStateRoot(entry.installPath, host, environment, home); + if ( + !paths.includes(observed.root) && + await realDirectory(observed.root, host) !== undefined + ) { + retainedState.push({ path: observed.root, reason: 'unproven' }); + } + } } if (host === 'claude') { const dataDirectory = join(hostRoot, 'plugins', 'data', id); if (await realDirectory(dataDirectory, host) !== undefined) paths.push(dataDirectory); } - if (paths.length === 0) { + if (paths.length === 0 && retainedState.length === 0) { if (host === 'codex' && entry !== undefined) { return Object.freeze({ detail: policy === 'purge' @@ -1139,7 +1172,7 @@ const publicHostData = async ( policy, }); } - if (policy === 'purge' && (sharedWith === 'unknown' || sharedWith.length > 0)) { + if (policy === 'purge' && paths.length > 0 && (sharedWith === 'unknown' || sharedWith.length > 0)) { // The cache copy and plugins/data/ are scope-less: another scope's install still uses them. throw failure( 'AB7008', @@ -1153,13 +1186,24 @@ const publicHostData = async ( } return Object.freeze({ detail: policy === 'purge' - ? `Durable runtime state is removed after the ${host} uninstall returns (--purge-data --confirm-purge).` + ? `${paths.length === 0 + ? 'No owned durable runtime state is removed.' + : `Owned durable runtime state is removed after the ${host} uninstall returns (--purge-data --confirm-purge).`}${ + retainedState.length === 0 + ? '' + : ` Retained ${retainedState.map((entry) => `${entry.path} (${entry.reason})`).join(', ')} because the receipt does not prove exclusive ownership.` + }` : 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', + outcome: policy === 'purge' + ? paths.length > 0 ? 'purged' : 'kept' + : host === 'claude' ? 'retained-by-host' : 'kept', paths: Object.freeze(paths), policy, + ...(retainedState.length === 0 + ? {} + : { retained: Object.freeze(retainedState.map((entry) => Object.freeze(entry))) }), }); }; @@ -1310,6 +1354,7 @@ const uninstallPublicCli = async ( dependents === 'unknown' ? 'unknown' : dependents.sameOtherScopes, environment, home, + receipt, ); const registrations: UninstallRegistrationReport[] = []; if (pluginRegistration !== undefined) { @@ -1346,11 +1391,14 @@ const uninstallPublicCli = async ( })); } const purgedDirectories = policy === 'purge' && data.outcome === 'purged' ? data.paths : []; + const keepReceipt = policy === 'keep' && + receipt !== undefined && + (data.paths.length > 0 || (data.retained?.length ?? 0) > 0); const result = { ...base, data, ...(entry === undefined ? {} : { destination: entry.installPath }), - receipt: receiptReport(receiptPath, receipt, status), + receipt: receiptReport(receiptPath, receipt, keepReceipt ? 'remnant' : status), registrations: Object.freeze(registrations), retained: Object.freeze([]), } as const; @@ -1385,7 +1433,20 @@ const uninstallPublicCli = async ( }); } } - const removedReceipt = await removeStoredInstallReceipt(receiptPath, hostRoot); + let removedReceipt: readonly string[]; + if (keepReceipt && receipt !== undefined) { + await writeStoredInstallReceipt(receiptPath, { + ...receipt, + directories: Object.freeze([]), + files: Object.freeze([]), + hostDirectories: Object.freeze([]), + registrations: Object.freeze([]), + updatedAt: new Date().toISOString(), + }); + removedReceipt = Object.freeze([]); + } else { + removedReceipt = await removeStoredInstallReceipt(receiptPath, hostRoot); + } return Object.freeze({ ...result, removed: Object.freeze({ diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 06671e822..74d650ed3 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -546,6 +546,9 @@ it('inventories durable SQLite stores and sidecars without opening them', async }], status: 'known', summary: { bytes: 15, stores: 1 }, + ownership: 'unrecorded', + purgeable: false, + servers: ['default'], writable: true, }); expect(report.diagnostics).toEqual(expect.arrayContaining([ @@ -560,6 +563,7 @@ it('inventories durable SQLite stores and sidecars without opening them', async expect(humanCode).toBe(0); expect(human.stdout()).toContain('durable state: 1 store, 15 B'); expect(human.stdout()).toContain(`state root: ${stateRoot} (exists, writable, derived)`); + expect(human.stdout()).toContain('ownership: unrecorded, retained, servers: default'); const json = captureCliTerminal(); await runCli(['doctor', '--json'], json.output, { runDoctor: async () => report }); @@ -600,6 +604,9 @@ it('reports a missing derived state root and a declared state-root override', as directory: declaredStateRoot, exists: false, findings: [], + ownership: 'unrecorded', + purgeable: false, + servers: ['configured'], summary: { bytes: 0, stores: 0 }, writable: false, }); @@ -2081,6 +2088,11 @@ it('surfaces the placed → registered → enabled → active lifecycle per host }); expect(cursorPlaced.bundle?.receipt).toMatchObject({ mode: 'local', scope: 'user' }); expect(cursorPlaced.inventory.findings[0]?.receipt).toMatchObject({ mode: 'local' }); + expect(cursorPlaced.inventory.findings[0]?.durableState).toMatchObject({ + ownership: 'derived', + purgeable: false, + servers: ['default'], + }); } finally { await fixture.cleanup(); } diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index c030d5459..d2934846e 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -418,7 +418,17 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the installed host manifest', async () = }), ]); await installBundle(options); - await mkdir(declaredStateRoot, { recursive: true }); + expect(await readInstallReceipt(join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'))) + .toMatchObject({ + state: { + roots: [{ + ownership: { kind: 'marker', marker: join(declaredStateRoot, '.agent-bundle-state-owner.json') }, + root: declaredStateRoot, + servers: ['stateful'], + source: 'declared', + }], + }, + }); 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]); @@ -429,7 +439,7 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the installed host manifest', async () = remnantReceipt: join(cursorRoot, 'plugins', 'local', 'uninstall-fixture', installReceiptFile), }); expect(await readInstallReceipt(join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'))) - .toMatchObject({ stateRoot: { root: declaredStateRoot, source: 'native' } }); + .toMatchObject({ state: { roots: [{ root: declaredStateRoot, source: 'declared' }] } }); 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] }); @@ -439,6 +449,211 @@ it('purges AGENT_BUNDLE_STATE_ROOT from the installed host manifest', async () = } }); +it('never purges a pre-existing declared state root or its unrelated sentinel', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const sharedRoot = join(fixture.cleanupRoot, 'shared-state'); + const sentinel = join(sharedRoot, 'unrelated.txt'); + const options = { from: fixture.bundleRoot, home: fixture.home, host: 'cursor' as const }; + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + mkdir(sharedRoot, { recursive: true }), + writeJson(join(fixture.bundleRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: sharedRoot }, + }, + }, + }), + ]); + await writeFile(sentinel, 'keep\n'); + await installBundle(options); + const plan = await uninstallBundle({ ...options, confirmPurge: true, plan: true, purgeData: true }); + expect(plan.data).toMatchObject({ + outcome: 'kept', + paths: [], + retained: [{ path: sharedRoot, reason: 'pre-existing' }], + }); + expect(plan.removed.directories).not.toContain(sharedRoot); + await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('purges recorded derived state despite an uninstall environment change', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); + const installEnvironment = { XDG_STATE_HOME: join(fixture.cleanupRoot, 'install-state-home') }; + const uninstallEnvironment = { XDG_STATE_HOME: join(fixture.cleanupRoot, 'uninstall-state-home') }; + const recordedRoot = userDataStateRoot(destination, installEnvironment, fixture.home); + const unrelatedRoot = userDataStateRoot(destination, uninstallEnvironment, fixture.home); + const sentinel = join(unrelatedRoot, 'unrelated.txt'); + try { + await mkdir(cursorRoot, { recursive: true }); + await installBundle({ environment: installEnvironment, from: fixture.bundleRoot, home: fixture.home, host: 'cursor' }); + await Promise.all([ + mkdir(recordedRoot, { recursive: true }), + mkdir(unrelatedRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile(join(recordedRoot, 'plugin.sqlite'), 'owned\n'), + writeFile(sentinel, 'keep\n'), + ]); + const purged = await uninstallBundle({ + confirmPurge: true, + environment: uninstallEnvironment, + from: fixture.bundleRoot, + home: fixture.home, + host: 'cursor', + purgeData: true, + }); + expect(purged.data.paths).toContain(recordedRoot); + expect(purged.data.paths).not.toContain(unrelatedRoot); + await expect(readdir(recordedRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('records and purges each server state root using its execution cwd', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); + const firstRoot = join(fixture.cleanupRoot, 'first-state'); + const relativeRoot = join(destination, 'shared-state'); + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + writeJson(join(fixture.bundleRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + alpha: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: firstRoot }, + }, + beta: { + command: 'node', + cwd: '${CURSOR_PLUGIN_ROOT}/runtime', + env: { AGENT_BUNDLE_STATE_ROOT: '../shared-state' }, + }, + }, + }), + ]); + await installBundle({ from: fixture.bundleRoot, home: fixture.home, host: 'cursor' }); + expect((await readInstallReceipt(destination))?.state?.roots).toEqual([ + expect.objectContaining({ root: firstRoot, servers: ['alpha'], source: 'declared' }), + expect.objectContaining({ root: relativeRoot, servers: ['beta'], source: 'declared' }), + ]); + await Promise.all([ + writeFile(join(firstRoot, 'alpha.sqlite'), 'alpha\n'), + writeFile(join(relativeRoot, 'beta.sqlite'), 'beta\n'), + ]); + const purged = await uninstallBundle({ + confirmPurge: true, + from: fixture.bundleRoot, + home: fixture.home, + host: 'cursor', + purgeData: true, + }); + expect(purged.data.paths).toEqual([firstRoot, relativeRoot]); + await expect(readdir(firstRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(readdir(relativeRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('retains a marked root when its marker is replaced by another install identity', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const declaredRoot = join(fixture.cleanupRoot, 'marked-state'); + const marker = join(declaredRoot, '.agent-bundle-state-owner.json'); + 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: declaredRoot }, + }, + }, + }), + ]); + await installBundle({ from: fixture.bundleRoot, home: fixture.home, host: 'cursor' }); + await writeJson(marker, { + format: 1, + owner: { host: 'cursor', id: 'another-install', mode: 'local', plugin: 'other', scope: 'user' }, + }); + const sentinel = join(declaredRoot, 'sentinel.txt'); + await writeFile(sentinel, 'keep\n'); + const result = await uninstallBundle({ + confirmPurge: true, + from: fixture.bundleRoot, + home: fixture.home, + host: 'cursor', + purgeData: true, + }); + expect(result.data).toMatchObject({ + outcome: 'kept', + retained: [{ path: declaredRoot, reason: 'marker-mismatch' }], + }); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('retains a marked root when a symlinked ancestor is retargeted', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const firstTarget = join(fixture.cleanupRoot, 'first-target'); + const secondTarget = join(fixture.cleanupRoot, 'second-target'); + const linkedBase = join(fixture.cleanupRoot, 'state-link'); + const declaredRoot = join(linkedBase, 'owned-state'); + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + mkdir(firstTarget), + mkdir(secondTarget), + writeJson(join(fixture.bundleRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: declaredRoot }, + }, + }, + }), + ]); + await symlink(firstTarget, linkedBase); + await installBundle({ from: fixture.bundleRoot, home: fixture.home, host: 'cursor' }); + await rm(linkedBase); + await mkdir(join(secondTarget, 'owned-state')); + const sentinel = join(secondTarget, 'owned-state', 'sentinel.txt'); + await writeFile(sentinel, 'keep\n'); + await symlink(secondTarget, linkedBase); + const result = await uninstallBundle({ + confirmPurge: true, + from: fixture.bundleRoot, + home: fixture.home, + host: 'cursor', + purgeData: true, + }); + expect(result.data).toMatchObject({ + outcome: 'kept', + retained: [{ path: declaredRoot, reason: 'canonical-path-changed' }], + }); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + } 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/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 6094483a1..f0d3c0527 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 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`. | +| `--keep-data` | on | Keep every recorded framework state root, derived web-data, legacy `state/`, and a recorded Cursor `PLUGIN_DATA` directory. This is the default; the flag makes it explicit and preserves the ownership receipt for a later purge. | +| `--purge-data` | off | Remove only receipt-recorded, installation-owned durable-data roots. Refused (`AB7008`) without `--confirm-purge`; shared, externally managed, marker-less, foreign-marker, and otherwise unproven roots are retained and listed. | | `--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. | @@ -227,6 +227,17 @@ and removes it on a confirmed purge while `codex plugin remove` deletes the cach `uninstall ` with the same flags; the emitted `install.mjs` accepts `--uninstall` with `--mode`, `--keep-data`, `--purge-data --confirm-purge`, `--force`, and `--plan`. +State receipts keep three facts separate. The installed MCP documents determine each server's +runtime location (including relative overrides resolved from that server's execution directory); +the receipt records the locations observed for this installation; and only independent ownership +evidence authorizes deletion. The default +`~/.agent-bundle/state/-` (or +`$XDG_STATE_HOME/agent-bundle/-`) namespace is owned by construction. An explicit +`AGENT_BUNDLE_STATE_ROOT` is owned only when installation created the previously absent directory +and wrote its install-identity marker. A pre-existing directory is never recursively removed merely +because a server or the current uninstall environment names it. Multiple servers and roots are +recorded and judged independently. + ## doctor | Option | Default | Meaning | @@ -257,8 +268,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 +For every installed copy Doctor reports every per-server framework state root, its `native` or +`derived` source, receipt ownership (`derived`, `marker`, `unowned`, or `unrecorded`), whether its +evidence is currently purgeable, the servers using it, 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/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index e88b49396..8724b849f 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` | 开启 | 保留有效框架状态根(`AGENT_BUNDLE_STATE_ROOT`,否则为 `~/.agent-bundle/state/-` 或 `$XDG_STATE_HOME/agent-bundle/-`)、推导出的 web-data、旧版 `state/`,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明。 | -| `--purge-data` | 关闭 | 删除与该已安装代码根精确对应的上述持久数据根。没有 `--confirm-purge` 时被拒绝(`AB7008`)。 | +| `--keep-data` | 开启 | 保留回执记录的所有框架状态根、推导出的 web-data、旧版 `state/`,以及回执记录的 Cursor `PLUGIN_DATA` 目录。这是默认行为;该标志只是显式声明,并保留归属回执供以后 purge。 | +| `--purge-data` | 关闭 | 只删除回执记录且由该安装独占的持久数据根。没有 `--confirm-purge` 时被拒绝(`AB7008`);共享、外部管理、无标记、外来标记及其他无法证明归属的根都会被保留并列出。 | | `--force` | 关闭 | 在没有回执(旧版 Cursor 副本、仅宿主侧的安装)或归属内容、版本、暂存 `HEAD` 与回执不再匹配时继续。回执或清单指向另一个插件时无论如何都会被拒绝。 | | `--plan` | 关闭 | 打印将被删除的确切路径与宿主注册,不做任何改动。 | @@ -214,6 +214,13 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 而 `codex plugin remove` 会删除缓存树。相对包的安装器 bin 接受带同样标志的 `uninstall `;输出的 `install.mjs` 接受 `--uninstall`,并支持 `--mode`、`--keep-data`、`--purge-data --confirm-purge`、`--force` 与 `--plan`。 +状态回执把三件事分开:已安装的 MCP 文档决定每个服务器的运行时位置(相对覆盖值从该服务器的执行目录解析); +回执记录本次安装观察到的位置;只有独立的归属证据才允许删除。默认的 +`~/.agent-bundle/state/-`(或 +`$XDG_STATE_HOME/agent-bundle/-`)命名空间按构造归该安装独占。显式 +`AGENT_BUNDLE_STATE_ROOT` 只有在安装时原本不存在、由安装器创建并写入安装身份标记时才归该安装所有。 +预先存在的目录绝不会仅因服务器声明或卸载时的当前环境指向它而被递归删除。多个服务器与多个根会分别记录、分别判断。 + ## doctor | 选项 | 默认值 | 含义 | @@ -238,7 +245,8 @@ Claude 为 `retained-by-host`(缓存副本在 Claude 约 14 天的宽限期内 的原因(`AB7330`)。它还清点每个宿主根目录下的 Agent Bundle 回执仓库,对宿主已不再认可的回执发出警告(`AB7328`),并把 格式 2 之前写入的回执报告为已迁移(`AB7329`)。仅包含 `uninstall --keep-data` 所保留运行时状态的 Cursor 目录会以 `AB7307` info 报告为 `missing`,而不是 corrupt 或 foreign。 -对于每份已安装副本,Doctor 会报告解析后的框架状态根、其 `native` 或 `derived` 来源、是否存在以及是否可写。 +对于每份已安装副本,Doctor 会报告每个服务器对应的框架状态根、其 `native` 或 `derived` 来源、回执归属 +(`derived`、`marker`、`unowned` 或 `unrecorded`)、当前证据是否允许 purge、使用它的服务器、是否存在以及是否可写。 升级 #640 之前留下的 `/state` 会单独报告,并以 `AB7332` 标记。 ## validate From d6391d3aaab9e5bd33c845eb87feac7114520b83 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:22:31 +0000 Subject: [PATCH 3/8] fix: harden state ownership evidence --- .changeset/owned-state-purge.md | 5 ++ packages/agent-bundle/src/install/doctor.ts | 18 ++++- packages/agent-bundle/src/install/receipt.ts | 10 ++- .../agent-bundle/src/install/state-root.ts | 20 +++++- packages/agent-bundle/src/install/surface.ts | 12 +++- packages/agent-bundle/tests/uninstall.test.ts | 69 ++++++++++++++++--- 6 files changed, 117 insertions(+), 17 deletions(-) create mode 100644 .changeset/owned-state-purge.md diff --git a/.changeset/owned-state-purge.md b/.changeset/owned-state-purge.md new file mode 100644 index 000000000..4fded014f --- /dev/null +++ b/.changeset/owned-state-purge.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': patch +--- + +Make `uninstall --purge-data` remove only receipt-owned state roots and make `doctor` report per-server state ownership (#647) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 08048c50b..8a4e26d46 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -49,6 +49,7 @@ import { isPreservedRuntimeRoot, isRemnantReceipt, isRuntimeStateRemnant, + listStoredInstallReceipts, readInstallReceipt, readInstallReceiptFile, treeInventory, @@ -1361,6 +1362,7 @@ const publicHostInventory = async ( } const findings: DoctorFinding[] = []; const diagnostics: Diagnostic[] = []; + const storedReceipts = await listStoredInstallReceipts(publicHostRoot(host, environment, home)); if (host === 'claude') { if (!Array.isArray(document)) return unknown('not an array'); for (const row of document) { @@ -1379,7 +1381,13 @@ const publicHostInventory = async ( // `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); + const name = row['id'].slice(0, row['id'].indexOf('@') === -1 ? undefined : row['id'].indexOf('@')); + const receipt = storedReceipts.receipts.find((stored) => + stored.receipt.plugin === name && + stored.receipt.scope === row['scope'] && + stored.receipt.version === row['version'] + )?.receipt; + const durableState = await inspectInstalledDurableState(row['installPath'], host, environment, home, receipt); diagnostics.push(...durableState.diagnostics); findings.push({ durableState: durableState.effective, @@ -1387,7 +1395,7 @@ const publicHostInventory = async ( ...(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('@')), + name, path: row['installPath'], ...(durableState.legacy === undefined ? {} : { legacyDurableState: durableState.legacy }), state: errors.length > 0 ? 'failed' : enabled === false ? 'disabled' : 'installed', @@ -1405,7 +1413,11 @@ const publicHostInventory = async ( 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); + const receipt = storedReceipts.receipts.find((stored) => + stored.receipt.plugin === name && + stored.receipt.version === row['version'] + )?.receipt; + const durableState = await inspectInstalledDurableState(path, host, environment, home, receipt); diagnostics.push(...durableState.diagnostics); findings.push({ durableState: durableState.effective, diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 521fb96ea..c5a0932bf 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -623,7 +623,6 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { host: record['host'], installedAt: record['installedAt'], plugin: record['plugin'], - ...(state === undefined ? {} : { state }), ...(stateRoot === undefined ? {} : { stateRoot }), version: record['version'], ...(typeof record['webDataRoot'] === 'string' ? { webDataRoot: record['webDataRoot'] } : {}), @@ -654,6 +653,14 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { if (registration === undefined) return undefined; registrations.push(registration); } + const ownedState = state !== undefined && + state.owner.host === record['host'] && + state.owner.mode === record['mode'] && + state.owner.plugin === record['plugin'] && + state.owner.scope === record['scope'] && + state.owner.projectRoot === record['projectRoot'] + ? state + : undefined; return Object.freeze({ ...base, hostDirectories: Object.freeze([...record['hostDirectories']]), @@ -661,6 +668,7 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { ...(typeof record['projectRoot'] === 'string' ? { projectRoot: record['projectRoot'] } : {}), registrations: Object.freeze(registrations), scope: record['scope'], + ...(ownedState === undefined ? {} : { state: ownedState }), updatedAt: record['updatedAt'], }); }; diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index 2a4f4f6b1..d74c5d48b 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -110,6 +110,23 @@ export const resolveInstalledStateRoots = async ( }); const servers = await installedServers(canonicalRoot, host); if (servers.length === 0) { + const inherited = environment[pluginStateRootEnvAnchor]; + if (inherited !== undefined && inherited.trim() !== '') { + const expanded = expandPluginRoot(inherited, canonicalRoot); + if (!/\$\{[^}]*\}/u.test(expanded) && isAbsolute(expanded)) { + return Object.freeze([Object.freeze({ + root: resolve(expanded), + server: 'default', + source: 'declared' as const, + status: 'resolved' as const, + })]); + } + return Object.freeze([Object.freeze({ + server: 'default', + source: 'declared' as const, + status: 'unproven' as const, + })]); + } return Object.freeze([Object.freeze({ root: installedUserDataStateRoot(canonicalRoot, environment, home), server: 'default', @@ -118,7 +135,8 @@ export const resolveInstalledStateRoots = async ( })]); } return Object.freeze(servers.map((server) => { - const declared = server.environment[pluginStateRootEnvAnchor]; + const declared = server.environment[pluginStateRootEnvAnchor] ?? + environment[pluginStateRootEnvAnchor]; if (declared === undefined || declared.trim() === '') { return Object.freeze({ root: installedUserDataStateRoot(canonicalRoot, environment, home), diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 0cbea94f3..6822d7752 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -974,6 +974,9 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " if (!['host-cli', 'local', 'marketplace'].includes(value.mode) || !isScope(value.scope) || typeof value.updatedAt !== 'string' ||", ' !Array.isArray(value.hostDirectories) || !value.hostDirectories.every(safeRelative) ||', ' !Array.isArray(value.registrations) || !value.registrations.every(isRegistration)) return undefined;', + ' if (value.state !== undefined && (value.state.owner.host !== value.host || value.state.owner.mode !== value.mode ||', + ' value.state.owner.plugin !== value.plugin || value.state.owner.scope !== value.scope ||', + ' value.state.owner.projectRoot !== value.projectRoot)) { value = { ...value }; delete value.state; }', ' return value;', '};', 'const readReceipt = (root) => readReceiptFile(join(root, receiptFile));', @@ -1123,10 +1126,15 @@ const cursorInstallerSource = (model: NormalizedPlugin): 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 derived = join(stateHome, `${name}-${digest}`);', - " if (servers.length === 0) return [{ root: derived, server: 'default', source: 'derived' }];", + " if (servers.length === 0) {", + " const inherited = process.env.AGENT_BUNDLE_STATE_ROOT;", + " if (typeof inherited !== 'string' || inherited.trim() === '') return [{ root: derived, server: 'default', source: 'derived' }];", + ' const expanded = expandStatePath(inherited, canonical);', + " return !/\\$\\{[^}]*\\}/u.test(expanded) && isAbsolute(expanded) ? [{ root: resolve(expanded), server: 'default', source: 'declared' }] : [];", + ' }', ' const locations = [];', ' for (const [server, definition] of servers) {', - " const value = definition?.env?.AGENT_BUNDLE_STATE_ROOT;", + " const value = definition?.env?.AGENT_BUNDLE_STATE_ROOT ?? process.env.AGENT_BUNDLE_STATE_ROOT;", " if (typeof value !== 'string' || value.trim() === '') { locations.push({ root: derived, server, source: 'derived' }); continue; }", ' const expanded = expandStatePath(value, canonical);', ' if (/\\$\\{[^}]*\\}/u.test(expanded)) continue;', diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index d2934846e..a7d065969 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -484,22 +484,18 @@ it('never purges a pre-existing declared state root or its unrelated sentinel', } }); -it('purges recorded derived state despite an uninstall environment change', async () => { +it('purges only the install-time AGENT_BUNDLE_STATE_ROOT when the uninstall environment changes', async () => { const fixture = await createFixture('cursor'); const cursorRoot = join(fixture.home, '.cursor'); - const destination = join(cursorRoot, 'plugins', 'local', 'uninstall-fixture'); - const installEnvironment = { XDG_STATE_HOME: join(fixture.cleanupRoot, 'install-state-home') }; - const uninstallEnvironment = { XDG_STATE_HOME: join(fixture.cleanupRoot, 'uninstall-state-home') }; - const recordedRoot = userDataStateRoot(destination, installEnvironment, fixture.home); - const unrelatedRoot = userDataStateRoot(destination, uninstallEnvironment, fixture.home); + const recordedRoot = join(fixture.cleanupRoot, 'install-state-root'); + const unrelatedRoot = join(fixture.cleanupRoot, 'uninstall-state-root'); + const installEnvironment = { AGENT_BUNDLE_STATE_ROOT: recordedRoot }; + const uninstallEnvironment = { AGENT_BUNDLE_STATE_ROOT: unrelatedRoot }; const sentinel = join(unrelatedRoot, 'unrelated.txt'); try { await mkdir(cursorRoot, { recursive: true }); await installBundle({ environment: installEnvironment, from: fixture.bundleRoot, home: fixture.home, host: 'cursor' }); - await Promise.all([ - mkdir(recordedRoot, { recursive: true }), - mkdir(unrelatedRoot, { recursive: true }), - ]); + await mkdir(unrelatedRoot, { recursive: true }); await Promise.all([ writeFile(join(recordedRoot, 'plugin.sqlite'), 'owned\n'), writeFile(sentinel, 'keep\n'), @@ -609,6 +605,59 @@ it('retains a marked root when its marker is replaced by another install identit } }); +it('lets only the owning installation purge a root shared by two installs', async () => { + const owner = await createFixture('cursor'); + const observer = await createFixture('cursor'); + const sharedRoot = join(owner.cleanupRoot, 'shared-state'); + const manifest = { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: sharedRoot }, + }, + }, + }; + try { + await Promise.all([ + mkdir(join(owner.home, '.cursor'), { recursive: true }), + mkdir(join(observer.home, '.cursor'), { recursive: true }), + writeJson(join(owner.bundleRoot, '.cursor-plugin/mcp.json'), manifest), + writeJson(join(observer.bundleRoot, '.cursor-plugin/mcp.json'), manifest), + ]); + await installBundle({ from: owner.bundleRoot, home: owner.home, host: 'cursor' }); + await installBundle({ from: observer.bundleRoot, home: observer.home, host: 'cursor' }); + const observerRoot = join(observer.home, '.cursor', 'plugins', 'local', 'uninstall-fixture'); + expect((await readInstallReceipt(observerRoot))?.state?.roots[0]?.ownership).toEqual({ + kind: 'unowned', + reason: 'foreign-marker', + }); + const sentinel = join(sharedRoot, 'sentinel.txt'); + await writeFile(sentinel, 'keep\n'); + const retained = await uninstallBundle({ + confirmPurge: true, + from: observer.bundleRoot, + home: observer.home, + host: 'cursor', + purgeData: true, + }); + expect(retained.data.retained).toEqual([{ path: sharedRoot, reason: 'foreign-marker' }]); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + await uninstallBundle({ + confirmPurge: true, + from: owner.bundleRoot, + home: owner.home, + host: 'cursor', + purgeData: true, + }); + await expect(readdir(sharedRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await Promise.all([ + rm(owner.cleanupRoot, { force: true, recursive: true }), + rm(observer.cleanupRoot, { force: true, recursive: true }), + ]); + } +}); + it('retains a marked root when a symlinked ancestor is retargeted', async () => { const fixture = await createFixture('cursor'); const cursorRoot = join(fixture.home, '.cursor'); From 73d991de9c389859895fcd6c8f267520e682e3e6 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:27:04 +0000 Subject: [PATCH 4/8] test: cover state ownership boundaries --- packages/agent-bundle/src/install/install.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 0ebd0e0ee..24d18c4f5 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -1049,12 +1049,13 @@ const installCursor = Effect.fnUntraced(function*( if (comparison.status === 'current') { if (comparison.ownership === 'legacy' && options.replace === true) { // Adoption created nothing: the legacy copy's directories are not the installer's to prune. - yield* liftPromise(() => writeInstallReceipt(destination, createInstallReceipt({ + const adoptedReceipt = createInstallReceipt({ ...receipt, directories: [], hostDirectories: [], inventory: artifact, - }))); + }); + yield* liftPromise(() => writeInstallReceipt(destination, adoptedReceipt)); yield* liftPromise(() => attachCursorStateOwnership(destination, environment, home)); return { ...base, contentHash: artifact.hash, state: 'adopted' } as const; } @@ -1071,7 +1072,7 @@ const installCursor = Effect.fnUntraced(function*( updatedAt: new Date().toISOString(), }))); } - if (comparison.receipt?.state === undefined) { + if (comparison.ownership === 'receipt' && comparison.receipt?.state === undefined) { yield* liftPromise(() => attachCursorStateOwnership(destination, environment, home)); } return { ...base, contentHash: artifact.hash, state: 'already-installed' } as const; @@ -1088,7 +1089,12 @@ const installCursor = Effect.fnUntraced(function*( () => stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt: replacement, stageRoot: installRoot }), (staged) => replaceInstalledTree({ comparison, destination, receipt: replacement, staged }), ); - yield* liftPromise(() => attachCursorStateOwnership(destination, environment, home, comparison.receipt?.state)); + yield* liftPromise(() => attachCursorStateOwnership( + destination, + environment, + home, + comparison.receipt?.state, + )); // Filling a state-only shell is a fresh install of plugin content, not a replacement of any. if (remnant) return { ...base, contentHash: artifact.hash, state: 'installed' } as const; return { From 8340937a4d8de8632ff7bc99d2bb84ac73b65866 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 20:48:35 +0000 Subject: [PATCH 5/8] fix: close state ownership review gaps --- .../plans/2026-09-05-state-root-ownership.md | 407 ------------------ .../2026-09-05-state-root-ownership-design.md | 168 -------- .../src/dev/host-install-manager.ts | 1 + packages/agent-bundle/src/install/doctor.ts | 31 +- packages/agent-bundle/src/install/install.ts | 9 +- .../agent-bundle/src/install/state-root.ts | 21 +- packages/agent-bundle/src/install/surface.ts | 15 +- .../agent-bundle/src/install/uninstall.ts | 13 +- packages/agent-bundle/tests/doctor.test.ts | 35 ++ .../tests/install-surface.test.ts | 64 +++ packages/agent-bundle/tests/uninstall.test.ts | 64 +++ website/docs/en/reference/cli.mdx | 3 + website/docs/zh/reference/cli.mdx | 2 + 13 files changed, 229 insertions(+), 604 deletions(-) delete mode 100644 docs/superpowers/plans/2026-09-05-state-root-ownership.md delete mode 100644 docs/superpowers/specs/2026-09-05-state-root-ownership-design.md diff --git a/docs/superpowers/plans/2026-09-05-state-root-ownership.md b/docs/superpowers/plans/2026-09-05-state-root-ownership.md deleted file mode 100644 index e1fd88f9a..000000000 --- a/docs/superpowers/plans/2026-09-05-state-root-ownership.md +++ /dev/null @@ -1,407 +0,0 @@ -# State Root Ownership Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Make uninstall purge only receipt-recorded, installation-owned state subtrees while Doctor reports runtime location, receipt record, and ownership independently. - -**Architecture:** Replace the single discovered state root with a per-server resolver and a receipt-owned state ledger. Installation acquires ownership only for derived namespaces or newly created, identity-marked explicit roots; uninstall validates receipt evidence before recursive deletion, and the generated installer mirrors the same rules. - -**Tech Stack:** TypeScript, Node.js filesystem/path/crypto APIs, Effect-based installer flow, Rstest, generated self-contained ESM. - -## Global Constraints - -- Generated plugin output remains self-contained and imports only Node built-ins. -- The optional `@agent-bundle/runtime` peer must not load eagerly from install or CLI code. -- Purge never derives deletion authority from the uninstall process environment. -- Pre-existing, shared, marker-less, foreign-marker, or symlink-retargeted roots are retained. -- Public behavior changes update English and Chinese docs and exactly one patch changeset. - ---- - -### Task 1: Receipt-owned state schema - -**Files:** -- Modify: `packages/agent-bundle/src/install/receipt.ts` -- Test: `packages/agent-bundle/tests/receipt.test.ts` - -**Interfaces:** -- Produces: `InstallReceiptStateOwner`, `InstallReceiptStateRoot`, `InstallReceiptState` -- Extends: `InstallReceipt.state?: InstallReceiptState` -- Extends: `createInstallReceipt({ state?: InstallReceiptState })` - -- [ ] **Step 1: Write failing round-trip and rejection tests** - -```ts -const state = { - owner: { host: 'cursor', id: 'owner-1', mode: 'local', plugin: 'fixture', scope: 'user' }, - roots: [{ - canonicalRoot: '/state/fixture-a', - ownership: { kind: 'derived' }, - root: '/state/fixture-a', - servers: ['alpha'], - source: 'derived', - }], -} as const; -expect(await roundTripReceipt(createInstallReceipt({ ...identity, inventory, state }))) - .toMatchObject({ state }); -``` - -Also reject malformed owner ids, duplicate/unsorted server lists, relative -roots, invalid ownership discriminants, and marker ownership without an -absolute marker path. - -- [ ] **Step 2: Run the receipt test** - -Run: `pnpm build && pnpm exec rstest --config rstest.unit.config.ts packages/agent-bundle/tests/receipt.test.ts` - -Expected: FAIL because the receipt does not preserve `state`. - -- [ ] **Step 3: Implement and freeze the schema** - -```ts -export interface InstallReceiptStateRoot { - readonly canonicalRoot: string; - readonly ownership: - | { readonly kind: 'derived' } - | { readonly kind: 'marker'; readonly marker: string } - | { readonly kind: 'unowned'; readonly reason: 'foreign-marker' | 'pre-existing' | 'unproven' }; - readonly root: string; - readonly servers: readonly string[]; - readonly source: 'declared' | 'derived'; -} -``` - -Validate every nested field in `receiptFromDocument`, freeze arrays and -objects, and preserve backward compatibility when `state` is absent. - -- [ ] **Step 4: Re-run the receipt test** - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/agent-bundle/src/install/receipt.ts packages/agent-bundle/tests/receipt.test.ts -git commit -m "feat: record state ownership in receipts" -``` - -### Task 2: Per-server runtime state resolution - -**Files:** -- Modify: `packages/agent-bundle/src/install/state-root.ts` -- Test: `packages/agent-bundle/tests/state-root.test.ts` - -**Interfaces:** -- Produces: `resolveInstalledStateRoots(pluginRoot, host, environment, home): Promise` -- `InstalledStateLocation`: `{ cwd, root, server, source, status }` -- Removes the singular `resolveInstalledStateRoot` - -- [ ] **Step 1: Write failing resolver tests** - -```ts -expect(await resolveInstalledStateRoots(root, 'cursor', {}, home)).toEqual([ - expect.objectContaining({ root: first, server: 'alpha', source: 'declared' }), - expect.objectContaining({ root: second, server: 'beta', source: 'declared' }), -]); -``` - -Cover two different roots, deduplication metadata, relative overrides resolved -against declared server cwd, unresolved relative overrides without a provable -cwd, root-token expansion, and no manifest override falling back to the -derived root. - -- [ ] **Step 2: Run the resolver test** - -Run: `pnpm build && pnpm exec rstest --config rstest.unit.config.ts packages/agent-bundle/tests/state-root.test.ts` - -Expected: FAIL because only the first override is returned. - -- [ ] **Step 3: Implement the resolver** - -Parse all `mcpServers` entries in deterministic name order. Resolve each -server cwd before resolving its state env. Use the packaging-safe local -equivalent of runtime `resolvePluginRoot`, pinned in tests against -`userDataStateRoot` for derived roots and a spawned Node process for relative -`resolve()` semantics. - -- [ ] **Step 4: Re-run the resolver test** - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/agent-bundle/src/install/state-root.ts packages/agent-bundle/tests/state-root.test.ts -git commit -m "feat: resolve every server state root" -``` - -### Task 3: Install-time ownership acquisition - -**Files:** -- Modify: `packages/agent-bundle/src/install/state-root.ts` -- Modify: `packages/agent-bundle/src/install/install.ts` -- Modify: `packages/agent-bundle/src/install/receipt.ts` -- Test: `packages/agent-bundle/tests/install.test.ts` -- Test: `packages/agent-bundle/tests/uninstall.test.ts` - -**Interfaces:** -- Produces: `recordInstalledState(options): Promise<{ state, rollback }>` -- Produces marker: `.agent-bundle-state-owner.json` -- Consumes the per-server resolver from Task 2 - -- [ ] **Step 1: Write failing install tests** - -Create two absent explicit roots and one pre-existing shared root containing -`sentinel.txt`. Assert the receipt owns the absent roots by marker, records the -shared root as `unowned: pre-existing`, and each marker contains the receipt -owner id and install identity. - -- [ ] **Step 2: Run install tests** - -Expected: FAIL because install writes no state ledger or markers. - -- [ ] **Step 3: Implement acquisition and rollback** - -```ts -export interface StateOwnershipAcquisition { - readonly state: InstallReceiptState; - readonly rollback: () => Promise; -} -``` - -Generate or retain one owner UUID. Record derived roots without creating them. -For explicit absent roots, create the directory and marker with exclusive -filesystem operations. For existing roots, inspect markers without replacing -anything. On downstream failure, remove only markers created by this attempt -and remove only directories that become empty. - -- [ ] **Step 4: Thread state through every receipt writer** - -Cover Cursor local install/adopt/replace, Claude/Codex store receipts, and -receipt refresh. Replacement carries the existing owner id and reacquires -only newly declared roots. - -- [ ] **Step 5: Re-run install and uninstall tests** - -Expected: PASS. - -- [ ] **Step 6: Commit** - -```bash -git add packages/agent-bundle/src/install packages/agent-bundle/tests/install.test.ts packages/agent-bundle/tests/uninstall.test.ts -git commit -m "feat: acquire installation state ownership" -``` - -### Task 4: Receipt-only purge planning and deletion - -**Files:** -- Modify: `packages/agent-bundle/src/install/state-root.ts` -- Modify: `packages/agent-bundle/src/install/uninstall.ts` -- Test: `packages/agent-bundle/tests/uninstall.test.ts` - -**Interfaces:** -- Produces: `inspectRecordedStateOwnership(record): Promise` -- `StatePurgeDecision`: `{ action: 'purge' | 'retain' | 'absent', reason, root }` - -- [ ] **Step 1: Write destructive-safety regression tests** - -Cover changed uninstall env, two installs sharing a base, foreign marker, -marker-less root, unrelated sentinels outside owned roots, symlinked leaf, -unchanged symlink ancestor, retargeted ancestor, and keep-data followed by -later purge. - -```ts -await uninstallBundle({ ...options, environment: changedEnv, purgeData: true, confirmPurge: true }); -expect(await exists(recordedOwnedRoot)).toBe(false); -expect(await readFile(sharedSentinel, 'utf8')).toBe('keep\n'); -``` - -- [ ] **Step 2: Run uninstall tests** - -Expected: FAIL because uninstall still discovers roots from current env and -recursively removes every discovered directory. - -- [ ] **Step 3: Implement evidence validation** - -Read candidates only from `receipt.state.roots`. Require absolute lexical and -canonical roots, a real directory leaf, unchanged canonical resolution, and -an exact marker identity for marker-owned roots. Return retained decisions -instead of throwing for failed ownership evidence. - -- [ ] **Step 4: Update typed and human reports** - -List purged paths separately from retained state roots and include the reason -for each retained root. `--plan` reports the same decisions without writes. - -- [ ] **Step 5: Re-run uninstall tests** - -Expected: PASS and every sentinel survives. - -- [ ] **Step 6: Commit** - -```bash -git add packages/agent-bundle/src/install/state-root.ts packages/agent-bundle/src/install/uninstall.ts packages/agent-bundle/tests/uninstall.test.ts -git commit -m "fix: purge only receipted state roots" -``` - -### Task 5: Doctor ownership inventory - -**Files:** -- Modify: `packages/agent-bundle/src/install/doctor.ts` -- Modify: `packages/agent-bundle/src/cli.ts` -- Test: `packages/agent-bundle/tests/doctor.test.ts` - -**Interfaces:** -- Extends: `DoctorDurableStateReport` -- Reports current locations, receipt matches, ownership, purgeability, reason, - existence, writability, and servers - -- [ ] **Step 1: Write failing Doctor tests** - -Assert rows for derived-owned, marker-owned, shared-unowned, foreign-marker, -missing, and current-location-different-from-receipt cases. - -- [ ] **Step 2: Run Doctor tests** - -Expected: FAIL because Doctor exposes only one effective root and legacy root. - -- [ ] **Step 3: Implement Doctor reports** - -Inventory all current and recorded roots without opening state databases. -Deduplicate by path, retain server names, validate marker/canonical evidence -read-only, and add a diagnostic for retained unowned or invalidated ownership. - -- [ ] **Step 4: Re-run Doctor tests** - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/agent-bundle/src/install/doctor.ts packages/agent-bundle/src/cli.ts packages/agent-bundle/tests/doctor.test.ts -git commit -m "feat: report state ownership in doctor" -``` - -### Task 6: Generated installer parity - -**Files:** -- Modify: `packages/agent-bundle/src/install/surface.ts` -- Test: `packages/agent-bundle/tests/install-surface.test.ts` -- Test: `packages/agent-bundle/tests/packed-readonly-state-root.test.ts` - -**Interfaces:** -- Generated `install.mjs` writes/reads the Task 1 receipt state shape and uses - the Task 2–4 ownership rules with Node built-ins only - -- [ ] **Step 1: Add failing generated-installer tests** - -Exercise two roots, shared sentinel retention, relative cwd, symlink ancestor, -keep then later purge, and marker ownership. - -- [ ] **Step 2: Run generated and packed tests** - -Run: - -```bash -pnpm build -pnpm exec rstest --config rstest.unit.config.ts packages/agent-bundle/tests/install-surface.test.ts -pnpm exec rstest --config rstest.config.ts packages/agent-bundle/tests/packed-readonly-state-root.test.ts -``` - -Expected: FAIL until emitted source mirrors the core behavior. - -- [ ] **Step 3: Implement emitted parity** - -Keep marker, resolver, receipt parser/writer, and purge decision code in the -generated installer self-contained. Do not introduce package imports. - -- [ ] **Step 4: Re-run generated and packed tests** - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add packages/agent-bundle/src/install/surface.ts packages/agent-bundle/tests/install-surface.test.ts packages/agent-bundle/tests/packed-readonly-state-root.test.ts -git commit -m "fix: generate ownership-safe state purge" -``` - -### Task 7: Documentation, changeset, and release gates - -**Files:** -- Modify: `website/docs/en/reference/cli.mdx` -- Modify: `website/docs/zh/reference/cli.mdx` -- Modify: `docs/diagnostics.md` -- Create: `.changeset/.md` - -**Interfaces:** -- Documents the three-fact model and any new diagnostic code - -- [ ] **Step 1: Update English and Chinese CLI references** - -State that runtime location does not imply ownership, explain marker-owned -explicit roots, list retained reasons, and describe Doctor ownership rows. - -- [ ] **Step 2: Add diagnostics and one patch changeset** - -The changeset summary names `uninstall --purge-data`, `doctor`, and any new -diagnostic codes, is imperative, and ends with `(#)` once the PR exists. - -- [ ] **Step 3: Run deslop** - -Read the full diff against `origin/main`; remove redundant comments, defensive -checks on trusted paths, duplicate helpers, casts that only silence types, and -unnecessary nesting without changing behavior. - -- [ ] **Step 4: Run all gates** - -```bash -pnpm build && -pnpm typecheck && -pnpm lint && -pnpm test:unit && -pnpm docs:site:build -``` - -Also run the targeted packed regression and any affected host-install tests. -Expected: all PASS. - -- [ ] **Step 5: Commit** - -```bash -git add website/docs docs/diagnostics.md .changeset -git commit -m "docs: explain state root ownership" -``` - -### Task 8: Review and merge - -**Files:** -- No source files unless review finds a defect - -**Interfaces:** -- Produces a squash-merged PR closing #644 and linking #592 - -- [ ] **Step 1: Push and open the PR** - -Include `Closes #644`, `Related: #592`, validation, and deslop sections. - -- [ ] **Step 2: Run Claude review** - -Use `claude-fable-5-1-thinking-high`; request concrete merge risks only. -Resolve every finding and rerun the reviewer after fixes. - -- [ ] **Step 3: Record self-review and wait for CI** - -Update the PR body with reviewer model, findings, dispositions, and final -result. Address every review thread and require all checks green. - -- [ ] **Step 4: Arm squash auto-merge** - -```bash -gh pr merge --squash --auto -``` - -- [ ] **Step 5: Report result** - -Report PR URL, merge SHA, closed issue, and the ownership model in exactly -three concise lines. diff --git a/docs/superpowers/specs/2026-09-05-state-root-ownership-design.md b/docs/superpowers/specs/2026-09-05-state-root-ownership-design.md deleted file mode 100644 index 53565676e..000000000 --- a/docs/superpowers/specs/2026-09-05-state-root-ownership-design.md +++ /dev/null @@ -1,168 +0,0 @@ -# State Root Ownership Design - -## Goal - -Make state discovery informative and state deletion receipt-owned. A runtime -location is not deletion authority: `uninstall --purge-data` removes only the -subtrees this installation exclusively owns and retains every shared, -externally managed, unproven, or foreign-marked root. - -Tracking issue: #644. Architectural context: #592. - -## Three Separate Facts - -1. **Runtime location** — the effective state root for each MCP server, using - that server's declared environment and execution cwd with the same - `resolvePluginRoot` semantics as `@agent-bundle/runtime`. -2. **Installation record** — the immutable set of per-server locations - observed and persisted when this installation was created or replaced. - An environment change during uninstall may change the current runtime - observation, but never the recorded deletion candidates. -3. **Exclusive ownership** — evidence that authorizes recursive deletion of - one recorded root. Discovery, a manifest declaration, or an environment - variable is never ownership evidence by itself. - -## Receipt Model - -The format-2 receipt gains an optional frozen `state` object: - -```ts -interface InstallReceiptState { - readonly owner: { - readonly id: string; - readonly host: InstallHost; - readonly mode: InstallReceiptMode; - readonly plugin: string; - readonly scope: InstallReceiptScope; - readonly projectRoot?: string; - }; - readonly roots: readonly InstallReceiptStateRoot[]; -} - -interface InstallReceiptStateRoot { - readonly canonicalRoot: string; - readonly ownership: - | { readonly kind: 'derived' } - | { readonly kind: 'marker'; readonly marker: string } - | { - readonly kind: 'unowned'; - readonly reason: 'foreign-marker' | 'pre-existing' | 'unproven'; - }; - readonly root: string; - readonly servers: readonly string[]; - readonly source: 'declared' | 'derived'; -} -``` - -Roots are deduplicated by resolved path while preserving every server name. -The owner id is a random UUID created once and retained across replacement, -`--keep-data` remnants, and receipt migration. Existing receipts without -`state` remain readable but authorize no external deletion. - -## Runtime Resolution - -State resolution reads every server in the installed host MCP document. For -each server: - -- expand only the host/plugin-root tokens that the host expands; -- resolve its `cwd` first; -- pass the server environment, root fallback, user-data state anchor, home, - and resolved execution cwd through a local packaging-safe equivalent of - `resolvePluginRoot`; -- resolve relative `AGENT_BUNDLE_STATE_ROOT` against the execution cwd, as - Node's `resolve()` does inside the runtime process; -- retain unresolved token values or a relative value without a provable cwd - as an unproven runtime observation, never an owned root. - -Declared server environment wins for that server. The current process -environment may be shown by Doctor as a current observation but is never -added to the receipt at uninstall time and never creates purge authority. - -## Ownership Acquisition - -The default user-data root -`/agent-bundle/-` is exclusive by construction. -Installation records its lexical path and the canonical path obtained by -resolving the real state-home ancestor. - -An explicit root is owned only when all of these are true: - -1. it did not exist before installation; -2. the installer created the directory; -3. the installer atomically created - `.agent-bundle-state-owner.json` inside it; -4. the marker names the same owner id and install identity as the receipt. - -A pre-existing directory is recorded as `unowned: pre-existing`. A -marker-less directory is `unowned: unproven`. A marker naming another owner -is `unowned: foreign-marker`. Install never rewrites an override to a child -directory. - -Marker creation is rolled back if installation or receipt persistence fails: -remove only the marker created by this attempt, then remove its directory only -if empty. - -## Purge - -`--plan` and confirmed purge operate only on receipt `state.roots`. - -- `derived`: require the current lexical root to resolve to the recorded - canonical root and require the leaf to be a real directory, not a symlink. -- `marker`: require the same canonical-root check and an exact marker identity - match. -- `unowned`: retain with its recorded reason. -- missing roots: report absent; do not broaden the candidate. -- changed symlink ancestors, leaf symlinks, malformed markers, and foreign - markers: retain with a safety reason. - -Recursive deletion targets the validated root itself. It never targets an -override's parent or any shared base. Unrelated sentinels outside the owned -root therefore survive. - -`--keep-data` carries the full state record into the remnant receipt. A later -purge applies the same evidence without rereading a removed manifest or the -caller's current environment. - -Legacy `/state` and receipted Cursor `PLUGIN_DATA` keep their existing -separate ownership rules. Web-data remains a distinct derived root and must -also be receipt-recorded before it is purgeable. - -## Doctor - -Doctor reports, per deduplicated root: - -- servers using the root; -- current runtime location and source; -- whether it matches a receipt record; -- ownership (`derived`, `marker`, or `unowned` plus reason); -- existence and writability; -- purgeability and any failed evidence check. - -Doctor continues to report legacy state separately. A new informational or -warning diagnostic is added only when needed to make retained/unproven state -machine-readable, and is documented in `docs/diagnostics.md`. - -## Generated Installer - -The emitted `install.mjs` uses the same receipt schema, marker format, -resolution rules, validation, plan output, keep-data remnant behavior, and -purge decisions. It remains self-contained and imports only Node built-ins. - -## Tests - -Temporary-directory tests cover: - -- install environment differs from uninstall environment; -- two installations reference one configured base and unrelated sentinels - survive; -- two servers declare different roots; -- relative overrides resolve against each server execution cwd; -- unchanged and changed symlink ancestors; -- foreign and missing markers; -- `--keep-data` followed by later purge; -- Doctor ownership and purgeability rows; -- generated installer parity; -- packed state-writing behavior. - -All destructive tests place an unrelated sentinel outside each owned subtree -and assert that it survives. diff --git a/packages/agent-bundle/src/dev/host-install-manager.ts b/packages/agent-bundle/src/dev/host-install-manager.ts index adb213e08..744652761 100644 --- a/packages/agent-bundle/src/dev/host-install-manager.ts +++ b/packages/agent-bundle/src/dev/host-install-manager.ts @@ -410,6 +410,7 @@ export class DevHostInstallManager { let installed = this.#installed.get(host); if (installed === undefined) { const result = await this.#installBundle({ + environment: this.#environment, from: prepared.root, ...(this.#home === undefined ? {} : { home: this.#home }), host, diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 8a4e26d46..02987468f 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -765,21 +765,30 @@ const inspectInstalledDurableState = async ( servers: Object.freeze(current.servers), })); } - const fallback = receipt?.stateRoot?.root ?? pluginRoot; - const effective = effectiveAll[0] ?? Object.freeze({ - ...(await inspectDurableState(fallback, receipt?.stateRoot?.source ?? 'derived', host)), - ownership: 'unrecorded' as const, - purgeable: false, - servers: Object.freeze([]), - }); + const unresolvedServers = locations + .filter((location) => location.status === 'unproven') + .map((location) => location.server); + const effective = effectiveAll[0] ?? durableStateReport( + ``, + false, + false, + 'native', + [], + [], + 'unrecorded', + false, + unresolvedServers, + 'relative override has no provable execution directory', + ); + const reportedAll = effectiveAll.length === 0 ? Object.freeze([effective]) : Object.freeze(effectiveAll); const legacyRoot = join(pluginRoot, 'state'); if (legacyRoot === effective.directory) { - return { diagnostics: effective.diagnostics, effective, effectiveAll: Object.freeze(effectiveAll) }; + return { diagnostics: effective.diagnostics, effective, effectiveAll: reportedAll }; } const legacy = await inspectDurableState(legacyRoot, 'legacy', host); - const effectiveDiagnostics = effectiveAll.flatMap((entry) => entry.diagnostics); + const effectiveDiagnostics = reportedAll.flatMap((entry) => entry.diagnostics); if (!legacy.exists) { - return { diagnostics: freezeDiagnostics(effectiveDiagnostics), effective, effectiveAll: Object.freeze(effectiveAll) }; + return { diagnostics: freezeDiagnostics(effectiveDiagnostics), effective, effectiveAll: reportedAll }; } const legacyDiagnostic = diagnostic( 'AB7332', @@ -791,7 +800,7 @@ const inspectInstalledDurableState = async ( return { diagnostics: freezeDiagnostics([...effectiveDiagnostics, ...legacy.diagnostics, legacyDiagnostic]), effective, - effectiveAll: Object.freeze(effectiveAll), + effectiveAll: reportedAll, legacy, }; }; diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 24d18c4f5..590994d5f 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -579,12 +579,13 @@ const installPublicCli = async ( let previousContentHash: string | undefined; // Both hosts cache at `///` (pinned by the real-host proofs), so a // reported copy locates where the reinstalled version lands. - let destination = join( + const predictedDestination = join( publicHostCacheRoot(host, environment, home), marketplace, identity.plugin, identity.version, ); + let destination: string | undefined; const entry = inventory.status === 'available' ? inventory.entries[0] : undefined; // The store receipt is the lifecycle record for this host-owned copy: written on every install and // replacement, and refreshed when an identical copy is found without one (pre-#101 installs). It owns @@ -595,7 +596,7 @@ const installPublicCli = async ( // belongs to whoever configured it; when `plugin marketplace list --json` cannot say, the registration // is not claimed either (fail-closed: `uninstall` then retains it and says why). const receiptIdentity = async (): Promise => { - const ownsMarketplace = previousReceipt !== undefined + const ownsMarketplace = previousReceipt !== undefined && !isRemnantReceipt(previousReceipt) ? previousReceipt.registrations.some((registration) => registration.kind === `${host}-marketplace`) : await readPublicHostMarketplaceState(runner, identity, host, marketplace) === 'absent'; return { @@ -700,7 +701,7 @@ const installPublicCli = async ( // as `already-installed`, and a marketplace without one would be sampled as pre-existing and retained as // user-owned by every later `uninstall`. Plugin first, then the marketplace — the order the host verbs // themselves require. - const createdMarketplace = previousReceipt === undefined && + const createdMarketplace = (previousReceipt === undefined || isRemnantReceipt(previousReceipt)) && recorded.registrations.some((registration) => registration.kind === `${host}-marketplace`); let pluginInstalled = false; let stateRollback: (() => Promise) | undefined; @@ -715,7 +716,7 @@ const installPublicCli = async ( host, mode: 'host-cli', plugin: identity.plugin, - pluginRoot: destination, + pluginRoot: destination ?? predictedDestination, previous: previousReceipt?.state, ...(projectRoot === undefined ? {} : { projectRoot }), scope, diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index d74c5d48b..f855bbca2 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -121,11 +121,15 @@ export const resolveInstalledStateRoots = async ( status: 'resolved' as const, })]); } - return Object.freeze([Object.freeze({ - server: 'default', - source: 'declared' as const, - status: 'unproven' as const, - })]); + if (/\$\{[^}]*\}/u.test(expanded)) { + return Object.freeze([Object.freeze({ + root: installedUserDataStateRoot(canonicalRoot, environment, home), + server: 'default', + source: 'derived' as const, + status: 'resolved' as const, + })]); + } + return Object.freeze([Object.freeze({ server: 'default', source: 'declared' as const, status: 'unproven' as const })]); } return Object.freeze([Object.freeze({ root: installedUserDataStateRoot(canonicalRoot, environment, home), @@ -147,7 +151,12 @@ export const resolveInstalledStateRoots = async ( } const expanded = expandPluginRoot(declared, canonicalRoot); if (/\$\{[^}]*\}/u.test(expanded)) { - return Object.freeze({ server: server.name, source: 'declared' as const, status: 'unproven' as const }); + return Object.freeze({ + root: installedUserDataStateRoot(canonicalRoot, environment, home), + server: server.name, + source: 'derived' as const, + status: 'resolved' as const, + }); } if (isAbsolute(expanded)) { return Object.freeze({ diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 6822d7752..f425af323 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -479,7 +479,10 @@ const cursorUninstallerSource = (): readonly string[] => [ ' } else {', ' let metadata;', " try { metadata = await lstat(resolvedStateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", - " if (metadata?.isDirectory()) retainedState.push({ path: resolvedStateDirectory, reason: 'unproven' });", + " if (metadata?.isDirectory()) {", + " if (receipt !== undefined && resolvedStateSource === 'derived') ownedStatePaths.push(resolvedStateDirectory);", + " else retainedState.push({ path: resolvedStateDirectory, reason: 'unproven' });", + " }", ' }', ' const webDataDirectory = receipt?.webDataRoot ?? resolvedWebDataDirectory;', ' const externalDataPaths = [...ownedStatePaths];', @@ -1117,7 +1120,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " try { document = JSON.parse(await readFile(join(canonical, manifest), 'utf8')); }", " catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) continue; throw error; }", " if (document?.mcpServers !== null && typeof document?.mcpServers === 'object' && !Array.isArray(document.mcpServers)) {", - ' servers = Object.entries(document.mcpServers).sort(([left], [right]) => left.localeCompare(right));', + " servers = Object.entries(document.mcpServers).filter(([, server]) => server !== null && typeof server === 'object' && !Array.isArray(server)).sort(([left], [right]) => left.localeCompare(right));", ' break;', ' }', ' }', @@ -1130,14 +1133,15 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " const inherited = process.env.AGENT_BUNDLE_STATE_ROOT;", " if (typeof inherited !== 'string' || inherited.trim() === '') return [{ root: derived, server: 'default', source: 'derived' }];", ' const expanded = expandStatePath(inherited, canonical);', - " return !/\\$\\{[^}]*\\}/u.test(expanded) && isAbsolute(expanded) ? [{ root: resolve(expanded), server: 'default', source: 'declared' }] : [];", + " if (/\\$\\{[^}]*\\}/u.test(expanded)) return [{ root: derived, server: 'default', source: 'derived' }];", + " return isAbsolute(expanded) ? [{ root: resolve(expanded), server: 'default', source: 'declared' }] : [];", ' }', ' const locations = [];', ' for (const [server, definition] of servers) {', " const value = definition?.env?.AGENT_BUNDLE_STATE_ROOT ?? process.env.AGENT_BUNDLE_STATE_ROOT;", " if (typeof value !== 'string' || value.trim() === '') { locations.push({ root: derived, server, source: 'derived' }); continue; }", ' const expanded = expandStatePath(value, canonical);', - ' if (/\\$\\{[^}]*\\}/u.test(expanded)) continue;', + " if (/\\$\\{[^}]*\\}/u.test(expanded)) { locations.push({ root: derived, server, source: 'derived' }); continue; }", ' if (isAbsolute(expanded)) { locations.push({ root: resolve(expanded), server, source: \'declared\' }); continue; }', " if (typeof definition?.cwd !== 'string') continue;", ' const expandedCwd = expandStatePath(definition.cwd, canonical);', @@ -1161,7 +1165,8 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' const created = [];', ' const markerOwns = async (marker) => {', ' let document;', - " try { document = JSON.parse(await readFile(marker, 'utf8')); } catch { return false; }", + " try { document = JSON.parse(await readFile(marker, 'utf8')); }", + " catch (error) { if (error?.code === 'ENOENT' || error instanceof SyntaxError) return false; throw error; }", ' const actual = document?.owner;', ' return document?.format === 1 && actual?.id === owner.id && actual?.host === owner.host && actual?.mode === owner.mode &&', ' actual?.plugin === owner.plugin && actual?.scope === owner.scope && actual?.projectRoot === owner.projectRoot;', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index c89950e58..22758bab5 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -476,7 +476,12 @@ const cursorLocalData = async ( } else { const observed = receipt?.stateRoot ?? await resolveInstalledStateRoot(destination, 'cursor', environment, home); if (observed.root !== stateDirectory && await realDirectory(observed.root, 'cursor') !== undefined) { - retainedState.push({ path: observed.root, reason: 'unproven' }); + if (receipt !== undefined && observed.source === 'derived') { + paths.push(observed.root); + kinds.push(`derived framework state root ${observed.root}`); + } else { + retainedState.push({ path: observed.root, reason: 'unproven' }); + } } } if (await realDirectory(stateDirectory, 'cursor') !== undefined) { @@ -619,7 +624,8 @@ const uninstallCursorLocal = async ( // 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')); + [...data.report.paths, ...(data.report.retained ?? []).map((entry) => entry.path)] + .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)), @@ -1146,7 +1152,8 @@ const publicHostData = async ( !paths.includes(observed.root) && await realDirectory(observed.root, host) !== undefined ) { - retainedState.push({ path: observed.root, reason: 'unproven' }); + if (receipt !== undefined && observed.source === 'derived') paths.push(observed.root); + else retainedState.push({ path: observed.root, reason: 'unproven' }); } } } diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 74d650ed3..1f5c94a68 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -615,6 +615,41 @@ it('reports a missing derived state root and a declared state-root override', as } }); +it('reports an unresolved relative state override without treating the plugin root as state', async () => { + const fixture = await temporaryDoctor(); + const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'relative-state'); + try { + await Promise.all([ + writeJson(join(pluginRoot, '.cursor-plugin/plugin.json'), { name: 'relative-state', version: '1.0.0' }), + writeJson(join(pluginRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + configured: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: '../state' }, + }, + }, + }), + ]); + const report = await runDoctor({ + endpointDirectory: fixture.endpointDirectory, + home: fixture.home, + hosts: ['cursor'], + }); + const finding = hostReport(report, 'cursor').inventory.findings.find((entry) => entry.entry === 'relative-state'); + expect(finding?.durableState).toMatchObject({ + directory: '', + exists: false, + ownership: 'unrecorded', + ownershipReason: 'relative override has no provable execution directory', + purgeable: false, + servers: ['configured'], + }); + expect(finding?.durableState?.directory).not.toBe(pluginRoot); + } finally { + await fixture.cleanup(); + } +}); + it('reports whether an installed pack carries an operator .env file, never its contents (#469)', async () => { const fixture = await temporaryDoctor(); const pluginRoot = join(fixture.home, '.cursor', 'plugins', 'local', 'configured'); diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index 6a256e6c1..d2f679251 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -717,6 +717,70 @@ it('emitted install.mjs mirrors the core replace policy: no-op, owned-only repla } }, 60_000); +it('emitted install.mjs marks new explicit state roots and retains pre-existing ones', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-state-ownership-mjs-')); + const bundle = join(root, 'bundle'); + const home = join(root, 'home'); + const cursorRoot = join(home, '.cursor'); + const destination = join(cursorRoot, 'plugins', 'local', 'install-fixture'); + const installer = join(bundle, 'install.mjs'); + const ownedRoot = join(root, 'owned-state'); + try { + const writes = writesFor('cursor'); + await Promise.all([ + mkdir(join(bundle, '.cursor-plugin'), { recursive: true }), + mkdir(cursorRoot, { recursive: true }), + ]); + await Promise.all([ + writeFile(installer, writes.get('install.mjs') ?? ''), + writeFile(join(bundle, 'INSTALL.md'), writes.get('INSTALL.md') ?? ''), + writeFile(join(bundle, '.cursor-plugin', 'plugin.json'), JSON.stringify({ name: 'install-fixture', version: '1.2.3' })), + writeFile(join(bundle, '.cursor-plugin', 'mcp.json'), JSON.stringify({ + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: ownedRoot }, + }, + }, + })), + writeFile(join(bundle, 'payload.txt'), 'payload\n'), + ]); + expect((await run(installer, [], home)).code).toBe(0); + expect(await readInstallReceipt(destination)).toMatchObject({ + state: { + roots: [{ + ownership: { kind: 'marker', marker: join(ownedRoot, '.agent-bundle-state-owner.json') }, + root: ownedRoot, + servers: ['stateful'], + }], + }, + }); + await writeFile(join(ownedRoot, 'state.sqlite'), 'owned\n'); + expect((await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home)).code).toBe(0); + await expect(readdir(ownedRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + + const sharedRoot = join(root, 'shared-state'); + const sentinel = join(sharedRoot, 'sentinel.txt'); + await mkdir(sharedRoot); + await writeFile(sentinel, 'keep\n'); + await writeFile(join(bundle, '.cursor-plugin', 'mcp.json'), JSON.stringify({ + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: sharedRoot }, + }, + }, + })); + expect((await run(installer, [], home)).code).toBe(0); + const retained = await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home); + expect(retained.code).toBe(0); + expect(retained.stdout).toContain(`Retained ${sharedRoot} (pre-existing)`); + expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}, 60_000); + it('emitted install.mjs --uninstall mirrors the core lifecycle: plan, receipt-owned removal, data policy, refusals', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-uninstall-mjs-')); const bundle = join(root, 'bundle'); diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index a7d065969..cdbf2e688 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -477,6 +477,20 @@ it('never purges a pre-existing declared state root or its unrelated sentinel', retained: [{ path: sharedRoot, reason: 'pre-existing' }], }); expect(plan.removed.directories).not.toContain(sharedRoot); + const kept = await uninstallBundle(options); + expect(kept.remnantReceipt).toBe(join( + cursorRoot, + 'plugins', + 'local', + 'uninstall-fixture', + installReceiptFile, + )); + expect((await readInstallReceipt(join( + cursorRoot, + 'plugins', + 'local', + 'uninstall-fixture', + )))?.state?.roots[0]).toMatchObject({ root: sharedRoot }); await uninstallBundle({ ...options, confirmPurge: true, purgeData: true }); expect(await readFile(sentinel, 'utf8')).toBe('keep\n'); } finally { @@ -1499,6 +1513,56 @@ it('purges Claude durable state only when confirmed and reports the host-retaine } }); +it('reacquires Claude marketplace ownership after a keep-data remnant reinstall', async () => { + const fixture = await createFixture('claude'); + const hostRoot = join(fixture.cleanupRoot, 'claude-root'); + const installPath = join(hostRoot, 'plugins', 'cache', 'uninstall-fixture-marketplace', 'uninstall-fixture', '1.2.3'); + let installed = false; + let marketplaceRegistered = false; + const { runner } = recordingRunner((call) => { + const verb = call.args.join(' '); + if (verb === 'plugin list --json') { + return claudeListing(installed + ? [{ enabled: true, id: 'uninstall-fixture@uninstall-fixture-marketplace', installPath, scope: 'user', version: '1.2.3' }] + : []); + } + if (verb === 'plugin marketplace list --json') { + return JSON.stringify(marketplaceRegistered ? [{ name: 'uninstall-fixture-marketplace' }] : []); + } + if (verb === `plugin marketplace add ${fixture.bundleRoot}`) marketplaceRegistered = true; + if (verb.startsWith('plugin marketplace remove ')) marketplaceRegistered = false; + if (verb.startsWith('plugin install ')) installed = true; + if (verb.startsWith('plugin uninstall ')) installed = false; + return ''; + }); + const options = { + commandRunner: runner, + environment: { CLAUDE_CONFIG_DIR: hostRoot }, + from: fixture.bundleRoot, + home: fixture.home, + host: 'claude' as const, + }; + try { + await installBundle(options); + await cp(fixture.bundleRoot, installPath, { recursive: true }); + const stateRoot = userDataStateRoot(installPath, options.environment, fixture.home); + await mkdir(stateRoot, { recursive: true }); + await writeFile(join(stateRoot, 'state.sqlite'), 'state\n'); + const kept = await uninstallBundle(options); + expect(kept.receipt.status).toBe('remnant'); + expect(marketplaceRegistered).toBe(false); + + await installBundle(options); + expect(marketplaceRegistered).toBe(true); + const removed = await uninstallBundle(options); + expect(removed.registrations.find((registration) => registration.kind === 'claude-marketplace')) + .toMatchObject({ action: 'removed' }); + expect(marketplaceRegistered).toBe(false); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + 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'); diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index f0d3c0527..3894cdf7b 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -192,6 +192,9 @@ before `add`. Every install writes a lifecycle receipt (format `agent-bundle-ins version, content hash, mode, scope, owned paths, host registrations, timestamps) — in-tree for Cursor local copies, under `/agent-bundle/receipts/` for Claude, Codex, and Cursor marketplace mode — that `uninstall` and `doctor` consume. +During install, an explicit state root that does not yet exist is created and receives +`.agent-bundle-state-owner.json`, which records the installation identity. Parent directories may +be created to reach it but are never claimed or recursively removed. ## uninstall diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 8724b849f..6a91a6394 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -183,6 +183,8 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ 每次安装都会写入生命周期回执(格式 `agent-bundle-install-receipt/2`:版本、内容哈希、模式、作用域、归属路径、 宿主注册、时间戳)——Cursor 本地副本写在树内,Claude、Codex 与 Cursor 市场模式写在 `<宿主根目录>/agent-bundle/receipts/` 下——`uninstall` 与 `doctor` 都消费它。 +安装期间,尚不存在的显式状态根会由安装器创建,并写入记录安装身份的 +`.agent-bundle-state-owner.json`。为到达该根而创建的父目录不会被声明为归属内容,也绝不会被递归删除。 ## uninstall From c3b262368fbe5eb73a5b1083ea46ed347fc07b69 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 21:09:13 +0000 Subject: [PATCH 6/8] fix: close final state ownership warnings --- packages/agent-bundle/src/install/doctor.ts | 11 +- .../agent-bundle/src/install/state-root.ts | 33 ++++- packages/agent-bundle/src/install/surface.ts | 24 ++- .../agent-bundle/src/install/uninstall.ts | 139 +++++++++++++----- .../tests/install-surface.test.ts | 6 + packages/agent-bundle/tests/uninstall.test.ts | 31 ++++ 6 files changed, 196 insertions(+), 48 deletions(-) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 02987468f..6de4438d0 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -1391,11 +1391,18 @@ const publicHostInventory = async ( // hooks, MCP servers, or skills reach a session until it is enabled again (#476). const enabled = typeof row['enabled'] === 'boolean' ? row['enabled'] : undefined; const name = row['id'].slice(0, row['id'].indexOf('@') === -1 ? undefined : row['id'].indexOf('@')); - const receipt = storedReceipts.receipts.find((stored) => + const receiptCandidates = storedReceipts.receipts.filter((stored) => stored.receipt.plugin === name && stored.receipt.scope === row['scope'] && stored.receipt.version === row['version'] - )?.receipt; + ); + const rowProjectRoot = typeof row['projectPath'] === 'string' ? resolve(row['projectPath']) : undefined; + const matchingReceipts = rowProjectRoot === undefined + ? receiptCandidates + : receiptCandidates.filter((stored) => + stored.receipt.projectRoot !== undefined && + resolve(stored.receipt.projectRoot) === rowProjectRoot); + const receipt = matchingReceipts.length === 1 ? matchingReceipts[0]?.receipt : undefined; const durableState = await inspectInstalledDurableState(row['installPath'], host, environment, home, receipt); diagnostics.push(...durableState.diagnostics); findings.push({ diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index f855bbca2..7fbc826f7 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -1,5 +1,5 @@ import { createHash, randomUUID } from 'node:crypto'; -import { lstat, mkdir, open, readFile, realpath, rm, rmdir } from 'node:fs/promises'; +import { lstat, mkdir, open, readFile, readdir, realpath, rm, rmdir } from 'node:fs/promises'; import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; import { isErrno } from '../core/errors.ts'; @@ -251,7 +251,8 @@ export interface RecordedInstalledState { } export interface InstalledStateOwnershipDecision { - readonly action: 'absent' | 'purge' | 'retain'; + readonly action: 'absent' | 'empty' | 'purge' | 'retain'; + readonly marker?: string; readonly path: string; readonly reason?: string; } @@ -285,6 +286,15 @@ export const inspectInstalledStateOwnership = async ( ) { return Object.freeze({ action: 'retain', path: root.root, reason: 'marker-mismatch' }); } + const entries = await readdir(root.root); + if (entries.length === 0) return Object.freeze({ action: 'empty', path: root.root }); + if ( + root.ownership.kind === 'marker' && + entries.length === 1 && + entries[0] === stateOwnershipMarkerFile + ) { + return Object.freeze({ action: 'empty', marker: root.ownership.marker, path: root.root }); + } return Object.freeze({ action: 'purge', path: root.root }); }; @@ -340,17 +350,30 @@ export const recordInstalledState = async ( await mkdir(dirname(root), { recursive: true }); try { await mkdir(root); + created.push(root); const handle = await open(marker, 'wx'); try { await handle.writeFile(markerDocument(owner), 'utf8'); } finally { await handle.close(); } - created.push(root); ownership = Object.freeze({ kind: 'marker' as const, marker }); } catch (error) { - if (!isErrno(error, 'EEXIST')) throw error; - ownership = Object.freeze({ kind: 'unowned' as const, reason: 'pre-existing' as const }); + if (!isErrno(error, 'EEXIST')) { + if (created.at(-1) === root) { + created.pop(); + await rmdir(root).catch((rollbackError: unknown) => { + if (!isErrno(rollbackError, 'ENOENT') && !isErrno(rollbackError, 'ENOTEMPTY')) { + throw rollbackError; + } + }); + } + throw error; + } + if (created.at(-1) === root) created.pop(); + ownership = await markerMatches(marker, owner) + ? Object.freeze({ kind: 'marker' as const, marker }) + : Object.freeze({ kind: 'unowned' as const, reason: 'foreign-marker' as const }); } } else if (await markerMatches(marker, owner)) { ownership = Object.freeze({ kind: 'marker' as const, marker }); diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index f425af323..65ea70e26 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -458,6 +458,8 @@ const cursorUninstallerSource = (): readonly string[] => [ " const [resolvedStateDirectory, stateDirectory, resolvedWebDataDirectory, resolvedStateSource] = await runtimeStateRoots();", ' const retainedState = [];', ' const ownedStatePaths = [];', + ' const emptyOwnedStateFiles = [];', + ' const emptyOwnedStateRoots = [];', ' if (receipt?.state !== undefined) {', ' for (const root of receipt.state.roots) {', ' let metadata;', @@ -474,6 +476,12 @@ const cursorUninstallerSource = (): readonly string[] => [ ' owner?.host !== expected.host || owner?.mode !== expected.mode || owner?.plugin !== expected.plugin ||', " owner?.scope !== expected.scope || owner?.projectRoot !== expected.projectRoot) { retainedState.push({ path: root.root, reason: 'marker-mismatch' }); continue; }", ' }', + ' const entries = await readdir(root.root);', + ' if (entries.length === 0 || (root.ownership.kind === \'marker\' && entries.length === 1 && entries[0] === stateMarkerFile)) {', + ' emptyOwnedStateRoots.push(root.root);', + " if (root.ownership.kind === 'marker') emptyOwnedStateFiles.push(root.ownership.marker);", + ' continue;', + ' }', ' ownedStatePaths.push(root.root);', ' }', ' } else {', @@ -541,7 +549,9 @@ const cursorUninstallerSource = (): readonly string[] => [ ' ...(emptyPluginData === undefined ? [] : [emptyPluginData]),', ' ...(emptyState === undefined ? [] : [emptyState]),', " ...(pluginDataRecorded ? [join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle')] : []),", + ' ...emptyOwnedStateRoots,', ' ].sort((left, right) => right.length - left.length || left.localeCompare(right));', + ' files.push(...emptyOwnedStateFiles);', ' const ownedSet = new Set(owned);', ' const ownedDirectorySet = new Set(ownedDirectories);', ' const remnantOnly = receipt !== undefined && receipt.files.length === 0 && receipt.registrations.length === 0;', @@ -1182,11 +1192,21 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' await mkdir(dirname(location.root), { recursive: true });', ' try {', ' await mkdir(location.root);', + ' created.push(location.root);', " const handle = await open(marker, 'wx');", " try { await handle.writeFile(`${JSON.stringify({ format: 1, owner }, null, 2)}\\n`, 'utf8'); } finally { await handle.close(); }", " ownership = { kind: 'marker', marker };", - ' created.push(location.root);', - " } catch (error) { if (error?.code !== 'EEXIST') throw error; ownership = { kind: 'unowned', reason: 'pre-existing' }; }", + ' } catch (error) {', + " if (error?.code !== 'EEXIST') {", + ' if (created.at(-1) === location.root) {', + ' created.pop();', + " try { await rmdir(location.root); } catch (rollbackError) { if (!['ENOENT', 'ENOTEMPTY'].includes(rollbackError?.code)) throw rollbackError; }", + ' }', + ' throw error;', + ' }', + ' if (created.at(-1) === location.root) created.pop();', + " ownership = await markerOwns(marker) ? { kind: 'marker', marker } : { kind: 'unowned', reason: 'foreign-marker' };", + ' }', ' } else if (await markerOwns(marker)) ownership = { kind: \'marker\', marker };', ' else {', ' let markerExists = true;', diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 22758bab5..bec04a784 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -1,5 +1,5 @@ import type { Stats } from 'node:fs'; -import { lstat, readdir, readFile, rm } from 'node:fs/promises'; +import { lstat, readdir, readFile, rm, rmdir } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -441,6 +441,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; + readonly emptyStateFiles: readonly string[]; + readonly emptyStateRoots: readonly string[]; /** Whether any durable state root exists. */ readonly present: boolean; readonly report: UninstallDataReport; @@ -460,6 +462,8 @@ const cursorLocalData = async ( const webData = receipt?.webDataRoot ?? installedWebDataRoot(destination, home); const paths: string[] = []; const retainedState: { path: string; reason: string }[] = []; + const emptyStateFiles: string[] = []; + const emptyStateRoots: string[] = []; const kinds: string[] = []; let emptyState: string | undefined; if (receipt?.state !== undefined) { @@ -469,6 +473,9 @@ const cursorLocalData = async ( if (decision.action === 'purge') { paths.push(root.root); kinds.push(`${root.source} framework state root ${root.root}`); + } else if (decision.action === 'empty') { + emptyStateRoots.push(root.root); + if (decision.marker !== undefined) emptyStateFiles.push(decision.marker); } else if (decision.action === 'retain') { retainedState.push({ path: root.root, reason: decision.reason ?? 'unproven' }); } @@ -521,6 +528,8 @@ const cursorLocalData = async ( return { ...(emptyPluginData === undefined ? {} : { emptyPluginData }), ...(emptyState === undefined ? {} : { emptyState }), + emptyStateFiles: Object.freeze(emptyStateFiles), + emptyStateRoots: Object.freeze(emptyStateRoots), present: false, report: Object.freeze({ detail: `No durable runtime state exists (${ @@ -541,6 +550,8 @@ const cursorLocalData = async ( return { ...(emptyPluginData === undefined ? {} : { emptyPluginData }), ...(emptyState === undefined ? {} : { emptyState }), + emptyStateFiles: Object.freeze(emptyStateFiles), + emptyStateRoots: Object.freeze(emptyStateRoots), present: true, report: Object.freeze({ detail: policy === 'purge' @@ -620,6 +631,7 @@ const uninstallCursorLocal = async ( if (metadata.isSymbolicLink() || !metadata.isFile()) throw unsupportedEntry(path, 'cursor'); files.push(path); } + files.push(...data.emptyStateFiles); if (ownership.receipt !== undefined || await exists(receiptPath)) files.push(receiptPath); // 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. @@ -635,6 +647,7 @@ const uninstallCursorLocal = async ( // receipts, marketplaces, or another plugin's data keep them alive. ...(data.emptyPluginData === undefined ? [] : [data.emptyPluginData]), ...(data.emptyState === undefined ? [] : [data.emptyState]), + ...data.emptyStateRoots, ...(pluginDataRecorded ? [join(cursorRoot, 'agent-bundle', 'plugin-data'), join(cursorRoot, 'agent-bundle')] : []), ]; const ownedDirectories = new Set(ownership.directories); @@ -1115,6 +1128,12 @@ const marketplaceDependents = async ( }); }; +interface PublicHostData { + readonly emptyStateFiles: readonly string[]; + readonly emptyStateRoots: readonly string[]; + readonly report: UninstallDataReport; +} + const publicHostData = async ( host: Exclude, policy: UninstallDataPolicy, @@ -1125,13 +1144,19 @@ const publicHostData = async ( environment: Readonly, home: string, receipt: InstallReceipt | undefined, -): Promise => { +): Promise => { const paths: string[] = []; const retainedState: { path: string; reason: string }[] = []; + const emptyStateFiles: string[] = []; + const emptyStateRoots: string[] = []; if (receipt?.state !== undefined) { for (const root of receipt.state.roots) { const decision = await inspectInstalledStateOwnership(receipt.state, root); if (decision.action === 'purge') paths.push(decision.path); + if (decision.action === 'empty') { + emptyStateRoots.push(decision.path); + if (decision.marker !== undefined) emptyStateFiles.push(decision.marker); + } if (decision.action === 'retain') { retainedState.push({ path: decision.path, reason: decision.reason ?? 'unproven' }); } @@ -1164,19 +1189,27 @@ const publicHostData = async ( if (paths.length === 0 && retainedState.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, + emptyStateFiles: Object.freeze(emptyStateFiles), + emptyStateRoots: Object.freeze(emptyStateRoots), + report: 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 for the installed copy.', - outcome: 'absent', - paths: Object.freeze([]), - policy, + emptyStateFiles: Object.freeze(emptyStateFiles), + emptyStateRoots: Object.freeze(emptyStateRoots), + report: Object.freeze({ + detail: 'No durable runtime state exists for the installed copy.', + outcome: 'absent', + paths: Object.freeze([]), + policy, + }), }); } if (policy === 'purge' && paths.length > 0 && (sharedWith === 'unknown' || sharedWith.length > 0)) { @@ -1192,25 +1225,29 @@ const publicHostData = async ( ); } return Object.freeze({ - detail: policy === 'purge' - ? `${paths.length === 0 - ? 'No owned durable runtime state is removed.' - : `Owned durable runtime state is removed after the ${host} uninstall returns (--purge-data --confirm-purge).`}${ - retainedState.length === 0 - ? '' - : ` Retained ${retainedState.map((entry) => `${entry.path} (${entry.reason})`).join(', ')} because the receipt does not prove exclusive ownership.` - }` - : 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' - ? paths.length > 0 ? 'purged' : 'kept' - : host === 'claude' ? 'retained-by-host' : 'kept', - paths: Object.freeze(paths), - policy, - ...(retainedState.length === 0 - ? {} - : { retained: Object.freeze(retainedState.map((entry) => Object.freeze(entry))) }), + emptyStateFiles: Object.freeze(emptyStateFiles), + emptyStateRoots: Object.freeze(emptyStateRoots), + report: Object.freeze({ + detail: policy === 'purge' + ? `${paths.length === 0 + ? 'No owned durable runtime state is removed.' + : `Owned durable runtime state is removed after the ${host} uninstall returns (--purge-data --confirm-purge).`}${ + retainedState.length === 0 + ? '' + : ` Retained ${retainedState.map((entry) => `${entry.path} (${entry.reason})`).join(', ')} because the receipt does not prove exclusive ownership.` + }` + : 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' + ? paths.length > 0 ? 'purged' : 'kept' + : host === 'claude' ? 'retained-by-host' : 'kept', + paths: Object.freeze(paths), + policy, + ...(retainedState.length === 0 + ? {} + : { retained: Object.freeze(retainedState.map((entry) => Object.freeze(entry))) }), + }), }); }; @@ -1397,13 +1434,15 @@ const uninstallPublicCli = async ( : `\`${host} ${publicHostMarketplaceRemoveArguments(marketplace).join(' ')}\``, })); } - const purgedDirectories = policy === 'purge' && data.outcome === 'purged' ? data.paths : []; + const purgedDirectories = policy === 'purge' && data.report.outcome === 'purged' + ? data.report.paths + : []; const keepReceipt = policy === 'keep' && receipt !== undefined && - (data.paths.length > 0 || (data.retained?.length ?? 0) > 0); + (data.report.paths.length > 0 || (data.report.retained?.length ?? 0) > 0); const result = { ...base, - data, + data: data.report, ...(entry === undefined ? {} : { destination: entry.installPath }), receipt: receiptReport(receiptPath, receipt, keepReceipt ? 'remnant' : status), registrations: Object.freeze(registrations), @@ -1412,12 +1451,21 @@ const uninstallPublicCli = async ( if (planned) { // The store pruning the run below performs, simulated: the receipt file, then the store directories it // leaves empty, so the plan names every path the completed result would. - const wouldRemove = await simulateRemoveStoredInstallReceipt(receiptPath, hostRoot); + const wouldRemove = keepReceipt + ? Object.freeze([]) + : await simulateRemoveStoredInstallReceipt(receiptPath, hostRoot); return Object.freeze({ ...result, removed: Object.freeze({ - directories: Object.freeze([...purgedDirectories, ...wouldRemove.filter((path) => path !== receiptPath)]), - files: Object.freeze(wouldRemove.filter((path) => path === receiptPath)), + directories: Object.freeze([ + ...purgedDirectories, + ...data.emptyStateRoots, + ...wouldRemove.filter((path) => path !== receiptPath), + ]), + files: Object.freeze([ + ...data.emptyStateFiles, + ...wouldRemove.filter((path) => path === receiptPath), + ]), }), state: 'planned', }); @@ -1429,6 +1477,12 @@ const uninstallPublicCli = async ( await runHostCommand(runner, identity, host, publicHostMarketplaceRemoveArguments(marketplace), 'removal'); } for (const path of purgedDirectories) await rm(path, { force: true, recursive: true }); + for (const path of data.emptyStateFiles) await rm(path, { force: true }); + for (const path of data.emptyStateRoots) { + await rmdir(path).catch((error: unknown) => { + if (!isErrno(error, 'ENOENT')) throw error; + }); + } if (ownershipHeir !== undefined && ownershipHeir !== 'already-recorded' && ownershipHeir !== 'none') { const heirRegistration = publicHostRegistrations(host, id, marketplace, ownershipHeir.receipt.scope) .find((registration) => registration.kind === `${host}-marketplace`); @@ -1457,8 +1511,15 @@ const uninstallPublicCli = async ( return Object.freeze({ ...result, removed: Object.freeze({ - directories: Object.freeze([...purgedDirectories, ...removedReceipt.filter((path) => path !== receiptPath)]), - files: Object.freeze(removedReceipt.filter((path) => path === receiptPath)), + directories: Object.freeze([ + ...purgedDirectories, + ...data.emptyStateRoots, + ...removedReceipt.filter((path) => path !== receiptPath), + ]), + files: Object.freeze([ + ...data.emptyStateFiles, + ...removedReceipt.filter((path) => path === receiptPath), + ]), }), state: 'uninstalled', }); diff --git a/packages/agent-bundle/tests/install-surface.test.ts b/packages/agent-bundle/tests/install-surface.test.ts index d2f679251..6ae781c77 100644 --- a/packages/agent-bundle/tests/install-surface.test.ts +++ b/packages/agent-bundle/tests/install-surface.test.ts @@ -755,6 +755,12 @@ it('emitted install.mjs marks new explicit state roots and retains pre-existing }], }, }); + const markerOnly = await run(installer, ['--uninstall'], home); + expect(markerOnly.code).toBe(0); + expect(markerOnly.stdout).not.toContain('Remnant receipt:'); + await expect(readdir(ownedRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + + expect((await run(installer, [], home)).code).toBe(0); await writeFile(join(ownedRoot, 'state.sqlite'), 'owned\n'); expect((await run(installer, ['--uninstall', '--purge-data', '--confirm-purge'], home)).code).toBe(0); await expect(readdir(ownedRoot)).rejects.toMatchObject({ code: 'ENOENT' }); diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index cdbf2e688..a88450de0 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -498,6 +498,34 @@ it('never purges a pre-existing declared state root or its unrelated sentinel', } }); +it('prunes a newly marked explicit root when no runtime state was written', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const declaredRoot = join(fixture.cleanupRoot, 'unused-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: declaredRoot }, + }, + }, + }), + ]); + await installBundle(options); + expect(await readdir(declaredRoot)).toEqual(['.agent-bundle-state-owner.json']); + const removed = await uninstallBundle(options); + expect(removed.data.outcome).toBe('absent'); + expect(removed.remnantReceipt).toBeUndefined(); + await expect(readdir(declaredRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('purges only the install-time AGENT_BUNDLE_STATE_ROOT when the uninstall environment changes', async () => { const fixture = await createFixture('cursor'); const cursorRoot = join(fixture.home, '.cursor'); @@ -1548,8 +1576,10 @@ it('reacquires Claude marketplace ownership after a keep-data remnant reinstall' const stateRoot = userDataStateRoot(installPath, options.environment, fixture.home); await mkdir(stateRoot, { recursive: true }); await writeFile(join(stateRoot, 'state.sqlite'), 'state\n'); + const plan = await uninstallBundle({ ...options, plan: true }); const kept = await uninstallBundle(options); expect(kept.receipt.status).toBe('remnant'); + expect(plan.removed).toEqual(kept.removed); expect(marketplaceRegistered).toBe(false); await installBundle(options); @@ -1595,6 +1625,7 @@ it('keeps external Codex state while reporting in-tree state only for purge', as mkdir(join(installPath, 'state'), { recursive: true }), mkdir(stateRoot, { recursive: true }), ]); + await writeFile(join(stateRoot, 'state.sqlite'), 'state\n'); expect((await uninstallBundle({ ...options, plan: true })).data).toMatchObject({ outcome: 'kept', paths: [stateRoot], From 41c24c9cccb9702148611b60870277a5ec490d51 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 21:29:52 +0000 Subject: [PATCH 7/8] fix: harden state ownership lifecycle --- packages/agent-bundle/src/install/doctor.ts | 6 +- packages/agent-bundle/src/install/install.ts | 3 +- packages/agent-bundle/src/install/receipt.ts | 2 +- .../agent-bundle/src/install/state-root.ts | 174 +++++++++++------- packages/agent-bundle/src/install/surface.ts | 34 +++- packages/agent-bundle/tests/uninstall.test.ts | 65 +++++++ .../en/guide/distribution/installation.mdx | 4 +- .../zh/guide/distribution/installation.mdx | 5 +- 8 files changed, 205 insertions(+), 88 deletions(-) diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 6de4438d0..1026425ed 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -781,12 +781,12 @@ const inspectInstalledDurableState = async ( 'relative override has no provable execution directory', ); const reportedAll = effectiveAll.length === 0 ? Object.freeze([effective]) : Object.freeze(effectiveAll); + const effectiveDiagnostics = reportedAll.flatMap((entry) => entry.diagnostics); const legacyRoot = join(pluginRoot, 'state'); - if (legacyRoot === effective.directory) { - return { diagnostics: effective.diagnostics, effective, effectiveAll: reportedAll }; + if (reportedAll.some((entry) => entry.directory === legacyRoot)) { + return { diagnostics: freezeDiagnostics(effectiveDiagnostics), effective, effectiveAll: reportedAll }; } const legacy = await inspectDurableState(legacyRoot, 'legacy', host); - const effectiveDiagnostics = reportedAll.flatMap((entry) => entry.diagnostics); if (!legacy.exists) { return { diagnostics: freezeDiagnostics(effectiveDiagnostics), effective, effectiveAll: reportedAll }; } diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 590994d5f..819ae4ea7 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -635,7 +635,8 @@ const installPublicCli = async ( if ( previousReceipt === undefined || previousReceipt.contentHash !== artifact.hash || - previousReceipt.state === undefined + previousReceipt.state === undefined || + isRemnantReceipt(previousReceipt) ) { const receiptIdentityValue = await receiptIdentity(); const state = await recordInstalledState({ diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index c5a0932bf..faf73bf2d 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -510,7 +510,7 @@ const readReceiptState = (value: unknown): InstallReceiptState | undefined => { typeof root['root'] !== 'string' || !isAbsolute(root['root']) || !Array.isArray(root['servers']) || - !root['servers'].every((server) => typeof server === 'string' && server.length > 0) || + !root['servers'].every((server) => typeof server === 'string') || (root['source'] !== 'declared' && root['source'] !== 'derived') || ownershipValue === null || typeof ownershipValue !== 'object' || diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index 7fbc826f7..9ee4a6191 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -324,89 +324,121 @@ export const recordInstalledState = async ( } const created: string[] = []; const recorded: InstallReceiptStateRoot[] = []; - for (const { location, servers } of roots.values()) { - const root = location.root as string; - const canonicalRoot = await canonicalPath(root); - if (location.source === 'derived') { - recorded.push(Object.freeze({ - canonicalRoot, - ownership: Object.freeze({ kind: 'derived' as const }), - root, - servers: Object.freeze(servers), - source: 'derived', - })); - continue; - } - const marker = join(root, stateOwnershipMarkerFile); - let existed = true; - try { - await lstat(root); - } catch (error) { - if (!isErrno(error, 'ENOENT')) throw error; - existed = false; + const rollback = async (): Promise => { + for (const root of [...created].reverse()) { + await rm(join(root, stateOwnershipMarkerFile), { force: true }); + await rmdir(root).catch((error: unknown) => { + if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) throw error; + }); } - let ownership: InstallReceiptStateRoot['ownership']; - if (!existed) { - await mkdir(dirname(root), { recursive: true }); + }; + try { + for (const { location, servers } of roots.values()) { + const root = location.root as string; + if (location.source === 'derived') { + recorded.push(Object.freeze({ + canonicalRoot: await canonicalPath(root), + ownership: Object.freeze({ kind: 'derived' as const }), + root, + servers: Object.freeze(servers), + source: 'derived', + })); + continue; + } try { - await mkdir(root); - created.push(root); - const handle = await open(marker, 'wx'); + const marker = join(root, stateOwnershipMarkerFile); + let existed = true; try { - await handle.writeFile(markerDocument(owner), 'utf8'); - } finally { - await handle.close(); + await lstat(root); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + existed = false; } - ownership = Object.freeze({ kind: 'marker' as const, marker }); - } catch (error) { - if (!isErrno(error, 'EEXIST')) { - if (created.at(-1) === root) { - created.pop(); - await rmdir(root).catch((rollbackError: unknown) => { - if (!isErrno(rollbackError, 'ENOENT') && !isErrno(rollbackError, 'ENOTEMPTY')) { - throw rollbackError; + let ownership: InstallReceiptStateRoot['ownership']; + if (!existed) { + await mkdir(dirname(root), { recursive: true }); + try { + await mkdir(root); + created.push(root); + const handle = await open(marker, 'wx'); + try { + await handle.writeFile(markerDocument(owner), 'utf8'); + } finally { + await handle.close(); + } + ownership = Object.freeze({ kind: 'marker' as const, marker }); + } catch (error) { + if (!isErrno(error, 'EEXIST')) { + if (created.at(-1) === root) { + created.pop(); + await rmdir(root).catch((rollbackError: unknown) => { + if (!isErrno(rollbackError, 'ENOENT') && !isErrno(rollbackError, 'ENOTEMPTY')) { + throw rollbackError; + } + }); } - }); + throw error; + } + if (created.at(-1) === root) created.pop(); + ownership = await markerMatches(marker, owner) + ? Object.freeze({ kind: 'marker' as const, marker }) + : Object.freeze({ kind: 'unowned' as const, reason: 'foreign-marker' as const }); } - throw error; + } else if (await markerMatches(marker, owner)) { + ownership = Object.freeze({ kind: 'marker' as const, marker }); + } else { + let markerExists = true; + try { + await lstat(marker); + } catch (error) { + if (!isErrno(error, 'ENOENT')) throw error; + markerExists = false; + } + ownership = Object.freeze({ + kind: 'unowned' as const, + reason: markerExists ? 'foreign-marker' as const : 'pre-existing' as const, + }); } - if (created.at(-1) === root) created.pop(); - ownership = await markerMatches(marker, owner) - ? Object.freeze({ kind: 'marker' as const, marker }) - : Object.freeze({ kind: 'unowned' as const, reason: 'foreign-marker' as const }); - } - } else if (await markerMatches(marker, owner)) { - ownership = Object.freeze({ kind: 'marker' as const, marker }); - } else { - let markerExists = true; - try { - await lstat(marker); + recorded.push(Object.freeze({ + canonicalRoot: await canonicalPath(root), + ownership, + root, + servers: Object.freeze(servers), + source: 'declared', + })); } catch (error) { - if (!isErrno(error, 'ENOENT')) throw error; - markerExists = false; + if ( + !isErrno(error, 'EACCES') && + !isErrno(error, 'ENOTDIR') && + !isErrno(error, 'EPERM') && + !isErrno(error, 'EROFS') + ) { + throw error; + } + if (created.at(-1) === root) { + created.pop(); + await rm(join(root, stateOwnershipMarkerFile), { force: true }); + await rmdir(root).catch((rollbackError: unknown) => { + if (!isErrno(rollbackError, 'ENOENT') && !isErrno(rollbackError, 'ENOTEMPTY')) { + throw rollbackError; + } + }); + } + recorded.push(Object.freeze({ + canonicalRoot: resolve(root), + ownership: Object.freeze({ kind: 'unowned' as const, reason: 'unproven' as const }), + root, + servers: Object.freeze(servers), + source: 'declared', + })); } - ownership = Object.freeze({ - kind: 'unowned' as const, - reason: markerExists ? 'foreign-marker' as const : 'pre-existing' as const, - }); } - recorded.push(Object.freeze({ - canonicalRoot: await canonicalPath(root), - ownership, - root, - servers: Object.freeze(servers), - source: 'declared', - })); + } catch (error) { + await rollback(); + throw error; } return Object.freeze({ - rollback: async () => { - for (const root of created.reverse()) { - await rm(join(root, stateOwnershipMarkerFile), { force: true }); - await rmdir(root).catch((error: unknown) => { - if (!isErrno(error, 'ENOENT') && !isErrno(error, 'ENOTEMPTY')) throw error; - }); - } - }, + rollback, state: Object.freeze({ owner, roots: Object.freeze(recorded) }), }); }; diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 65ea70e26..9cbb76282 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -485,11 +485,13 @@ const cursorUninstallerSource = (): readonly string[] => [ ' ownedStatePaths.push(root.root);', ' }', ' } else {', + ' const fallbackStateDirectory = receipt?.stateRoot?.root ?? resolvedStateDirectory;', + " const fallbackStateSource = receipt?.stateRoot?.source ?? resolvedStateSource;", ' let metadata;', - " try { metadata = await lstat(resolvedStateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", + " try { metadata = await lstat(fallbackStateDirectory); } catch (error) { if (error?.code !== 'ENOENT') throw error; }", " if (metadata?.isDirectory()) {", - " if (receipt !== undefined && resolvedStateSource === 'derived') ownedStatePaths.push(resolvedStateDirectory);", - " else retainedState.push({ path: resolvedStateDirectory, reason: 'unproven' });", + " if (receipt !== undefined && fallbackStateSource === 'derived') ownedStatePaths.push(fallbackStateDirectory);", + " else retainedState.push({ path: fallbackStateDirectory, reason: 'unproven' });", " }", ' }', ' const webDataDirectory = receipt?.webDataRoot ?? resolvedWebDataDirectory;', @@ -1181,9 +1183,16 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { ' return document?.format === 1 && actual?.id === owner.id && actual?.host === owner.host && actual?.mode === owner.mode &&', ' actual?.plugin === owner.plugin && actual?.scope === owner.scope && actual?.projectRoot === owner.projectRoot;', ' };', + ' const rollbackCreated = async () => {', + ' for (const root of [...created].reverse()) {', + ' await rm(join(root, stateMarkerFile), { force: true });', + " try { await rmdir(root); } catch (error) { if (!['ENOENT', 'ENOTEMPTY'].includes(error?.code)) throw error; }", + ' }', + ' };', + ' try {', ' for (const location of grouped.values()) {', - ' const canonicalRoot = await canonicalPath(location.root);', - " if (location.source === 'derived') { roots.push({ canonicalRoot, ownership: { kind: 'derived' }, root: location.root, servers: location.servers, source: 'derived' }); continue; }", + " if (location.source === 'derived') { roots.push({ canonicalRoot: await canonicalPath(location.root), ownership: { kind: 'derived' }, root: location.root, servers: location.servers, source: 'derived' }); continue; }", + ' try {', ' const marker = join(location.root, stateMarkerFile);', ' let existed = true;', " try { await lstat(location.root); } catch (error) { if (error?.code !== 'ENOENT') throw error; existed = false; }", @@ -1214,14 +1223,21 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " ownership = { kind: 'unowned', reason: markerExists ? 'foreign-marker' : 'pre-existing' };", ' }', ' roots.push({ canonicalRoot: await canonicalPath(location.root), ownership, root: location.root, servers: location.servers, source: \'declared\' });', + ' } catch (error) {', + " if (!['EACCES', 'ENOTDIR', 'EPERM', 'EROFS'].includes(error?.code)) throw error;", + ' if (created.at(-1) === location.root) {', + ' created.pop();', + ' await rm(join(location.root, stateMarkerFile), { force: true });', + " try { await rmdir(location.root); } catch (rollbackError) { if (!['ENOENT', 'ENOTEMPTY'].includes(rollbackError?.code)) throw rollbackError; }", + ' }', + " roots.push({ canonicalRoot: resolve(location.root), ownership: { kind: 'unowned', reason: 'unproven' }, root: location.root, servers: location.servers, source: 'declared' });", + ' }', ' }', + ' } catch (error) { await rollbackCreated(); throw error; }', ' try {', ' await writeReceiptFile(join(destination, receiptFile), `${JSON.stringify({ ...receipt, state: { owner, roots }, updatedAt: new Date().toISOString() }, null, 2)}\\n`);', ' } catch (error) {', - ' for (const root of created.reverse()) {', - ' await rm(join(root, stateMarkerFile), { force: true });', - " try { await rmdir(root); } catch (rollbackError) { if (!['ENOENT', 'ENOTEMPTY'].includes(rollbackError?.code)) throw rollbackError; }", - ' }', + ' await rollbackCreated();', ' throw error;', ' }', '};', diff --git a/packages/agent-bundle/tests/uninstall.test.ts b/packages/agent-bundle/tests/uninstall.test.ts index a88450de0..65ccdaa84 100644 --- a/packages/agent-bundle/tests/uninstall.test.ts +++ b/packages/agent-bundle/tests/uninstall.test.ts @@ -17,6 +17,7 @@ import { readInstallReceipt, readInstallReceiptFile, } from '../src/install/receipt.ts'; +import { recordInstalledState } from '../src/install/state-root.ts'; import { uninstallBundle, type UninstallResult } from '../src/install/uninstall.ts'; import { captureCliTerminal } from './support/cli-terminal.ts'; import { diffTreeSnapshots, snapshotTree, treesIdentical } from './support/tree-snapshot.ts'; @@ -526,6 +527,70 @@ it('prunes a newly marked explicit root when no runtime state was written', asyn } }); +it('records an inaccessible declared root as unproven without failing installation', async () => { + const fixture = await createFixture('cursor'); + const cursorRoot = join(fixture.home, '.cursor'); + const blockedParent = join(fixture.cleanupRoot, 'not-a-directory'); + try { + await Promise.all([ + mkdir(cursorRoot, { recursive: true }), + writeFile(blockedParent, 'file\n'), + writeJson(join(fixture.bundleRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + stateful: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: join(blockedParent, 'state') }, + }, + }, + }), + ]); + const installed = await installBundle({ + from: fixture.bundleRoot, + home: fixture.home, + host: 'cursor', + }); + if (installed.destination === undefined) throw new Error('Cursor install did not report its destination.'); + expect((await readInstallReceipt(installed.destination))?.state?.roots).toMatchObject([{ + ownership: { kind: 'unowned', reason: 'unproven' }, + root: join(blockedParent, 'state'), + }]); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + +it('rolls back earlier state markers when a later root cannot be recorded', async () => { + const fixture = await createFixture('cursor'); + const firstRoot = join(fixture.cleanupRoot, 'first-state'); + const invalidRoot = join(fixture.cleanupRoot, 'x'.repeat(300)); + try { + await writeJson(join(fixture.bundleRoot, '.cursor-plugin/mcp.json'), { + mcpServers: { + first: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: firstRoot }, + }, + second: { + command: 'node', + env: { AGENT_BUNDLE_STATE_ROOT: invalidRoot }, + }, + }, + }); + await expect(recordInstalledState({ + environment: {}, + home: fixture.home, + host: 'cursor', + mode: 'local', + plugin: 'uninstall-fixture', + pluginRoot: fixture.bundleRoot, + scope: 'user', + })).rejects.toMatchObject({ code: 'ENAMETOOLONG' }); + await expect(readdir(firstRoot)).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await rm(fixture.cleanupRoot, { force: true, recursive: true }); + } +}); + it('purges only the install-time AGENT_BUNDLE_STATE_ROOT when the uninstall environment changes', 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 6c84ef1cb..15eff1989 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -105,7 +105,9 @@ 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 --purge-data --confirm-purge` removes it for the installed code root. Claude replacement runs +location); `uninstall --purge-data --confirm-purge` removes only roots whose receipt proves that +installation owns them. Pre-existing, shared, marker-less, and otherwise unproven override roots +are retained. 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. diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 9166f53ee..c0967fa65 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -87,8 +87,9 @@ node ./install.mjs ——不是本插件安装器放置的——无论如何都会被拒绝。Cursor 副本携带安装回执(`.agent-bundle-install.json`: 插件、版本、宿主、内容哈希、归属文件);替换就地进行,只触碰归属文件,绝不动旧版或就地的 `state/` 之类的非归属条目, `--replace` 会接管回执出现之前的副本。本发行版构建的产物把框架状态放在 -`~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置),`uninstall` -配合 `--purge-data --confirm-purge` 会按已安装代码根删除它。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, +`~/.agent-bundle/state/-`(`AGENT_BUNDLE_STATE_ROOT` 覆盖该位置)。`uninstall` +配合 `--purge-data --confirm-purge` 只会删除回执证明归该安装独占的根;预先存在、共享、无标记或其他 +无法证明归属的覆盖根都会保留。Claude 的替换先运行 `claude plugin uninstall --keep-data` 再重新安装, 因为 `plugin update` 受版本门控;Codex 先 `codex plugin remove` 再 `add`。输出的 `INSTALL.md` 按宿主记录了 同样的步骤。 From 622a7ca13a15cd1597ff422f31374994132b3e1f Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Sat, 5 Sep 2026 21:58:23 +0000 Subject: [PATCH 8/8] refactor: centralize state ownership marker --- packages/agent-bundle/src/core/types.ts | 2 ++ packages/agent-bundle/src/install/receipt.ts | 3 ++- packages/agent-bundle/src/install/state-root.ts | 4 +--- packages/agent-bundle/src/install/surface.ts | 4 ++-- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/agent-bundle/src/core/types.ts b/packages/agent-bundle/src/core/types.ts index 828e395ed..e2843bdae 100644 --- a/packages/agent-bundle/src/core/types.ts +++ b/packages/agent-bundle/src/core/types.ts @@ -908,3 +908,5 @@ export const pluginRootEnvAnchor = 'AGENT_BUNDLE_PLUGIN_ROOT'; /** Explicit override of the framework state root; the runtime exports the same name as `PLUGIN_STATE_ROOT_ENV_ANCHOR`. */ export const pluginStateRootEnvAnchor = 'AGENT_BUNDLE_STATE_ROOT'; + +export const stateOwnershipMarkerFile = '.agent-bundle-state-owner.json'; diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index a2088af1d..750867023 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -19,6 +19,7 @@ import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; import { exists, installReceiptFile, isInstallReceiptEntry, isPreservedRuntimeRoot } from '../core/paths.ts'; +import { stateOwnershipMarkerFile } from '../core/types.ts'; import { artifactManifestName, type ArtifactManifest } from '../build/manifest.ts'; import { OPERATOR_ENV_FILE_NAMES } from '../launch-env.ts'; @@ -574,7 +575,7 @@ const readReceiptState = (value: unknown): InstallReceiptState | undefined => { ? Object.freeze({ kind: 'derived' as const }) : ownershipRecord['kind'] === 'marker' && typeof ownershipRecord['marker'] === 'string' && - ownershipRecord['marker'] === join(root['root'], '.agent-bundle-state-owner.json') + ownershipRecord['marker'] === join(root['root'], stateOwnershipMarkerFile) ? Object.freeze({ kind: 'marker' as const, marker: ownershipRecord['marker'] }) : ownershipRecord['kind'] === 'unowned' && (ownershipRecord['reason'] === 'foreign-marker' || diff --git a/packages/agent-bundle/src/install/state-root.ts b/packages/agent-bundle/src/install/state-root.ts index c085ef790..515a15fe8 100644 --- a/packages/agent-bundle/src/install/state-root.ts +++ b/packages/agent-bundle/src/install/state-root.ts @@ -5,7 +5,7 @@ import { basename, dirname, isAbsolute, join, resolve } from 'node:path'; import { readArtifactManifest } from '../build/manifest-file.ts'; import { isErrno } from '../core/errors.ts'; import { isRecord } from '../core/strict-json.ts'; -import { pluginStateRootEnvAnchor } from '../core/types.ts'; +import { pluginStateRootEnvAnchor, stateOwnershipMarkerFile } from '../core/types.ts'; import { webPluginDataRoot } from '../web-host/launch.ts'; import type { InstallHost } from './install.ts'; import type { @@ -28,8 +28,6 @@ export interface InstalledStateLocation { readonly status: 'resolved' | 'unproven'; } -export const stateOwnershipMarkerFile = '.agent-bundle-state-owner.json'; - 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 diff --git a/packages/agent-bundle/src/install/surface.ts b/packages/agent-bundle/src/install/surface.ts index 2493ca9b0..678ee8ccd 100644 --- a/packages/agent-bundle/src/install/surface.ts +++ b/packages/agent-bundle/src/install/surface.ts @@ -1,4 +1,4 @@ -import type { NormalizedPlugin } from '../core/types.ts'; +import { stateOwnershipMarkerFile, type NormalizedPlugin } from '../core/types.ts'; import { preservedRuntimeEntries } from '../core/paths.ts'; import { type BuiltInHost, builtInHostNames } from '../adapters/composite-layout.ts'; import { sourceInputs, type TargetArtifactWrite } from '../adapters/types.ts'; @@ -1118,7 +1118,7 @@ const cursorInstallerSource = (model: NormalizedPlugin): string => { " }, null, 2) + '\\n';", '};', '', - "const stateMarkerFile = '.agent-bundle-state-owner.json';", + `const stateMarkerFile = ${JSON.stringify(stateOwnershipMarkerFile)};`, "const expandStatePath = (value, root) => value.replaceAll('${CURSOR_PLUGIN_ROOT}', root).replaceAll('${PLUGIN_ROOT}', root);", 'const canonicalPath = async (path) => {', ' try { return await realpath(path); }',