diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/challenge.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/challenge.md new file mode 100644 index 000000000..bd4a19f6a --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/challenge.md @@ -0,0 +1,17 @@ +My confidence level of correctness now: 68% + +# Correctness (100%) + +- The A/B ownership and foreign-state behavior is backed by 6,464 passing tests, byte-for-byte collision smoke, and an independent checker with no remaining confirmed security blocker. +- The core #829 behavior is supported, but the strict delivery claim is not correct yet: `framework` mutation scored 86.7% against its declared 93% floor, while 13 final-tree scopes remain unchecked. The user authorized a partial draft, not a green gate or merge. +- Copilot 1.0.83 fails closed on native source proof. That preserves existing state but does not deliver fresh native activation on that installed version. + +# Deal breakers + +- Do not mark the issue closed or the draft PR merge-ready under strict SDLC. At least 515 additional framework mutants must be detected to meet the existing floor; targeted witnesses or a simpler proof flow need a new validated candidate. +- Partial Copilot 1.0.83 functionality must remain explicit in the draft. Native fresh activation is not verified, so end-to-end satisfaction remains uncertain. + +# Suggestions (enhancements only) + +- Inventory changed-line mutants before the expensive whole-scope campaign, then test each negative and positive ownership branch; retain the whole-scope gate for the final tree. +- Keep a single source/test SHA and Node/HOME fixture contract through full gates and mutation. Reuse a green report only when those inputs are identical. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/frame.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/frame.md new file mode 100644 index 000000000..8da930c2c --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/frame.md @@ -0,0 +1,11 @@ +# #829 Check follow-up contract + +Source: [issue #829](https://github.com/ai-driven-dev/framework/issues/829), `review.md`, and the second read-only preflight (blocked, 2026-09-15). The user will handle legacy state manually. + +1. Project sync never refreshes a host marketplace without proving the exact host catalogue is AIDD-owned and contains no foreign plugin refs. No unnamed/global host refresh. A project-labelled catalogue is not proven by a plugin claim alone; without canonical catalogue provenance, skip refresh, preserve host state, and name the limitation. Fresh install must still activate AIDD's plugin without taking over a preexisting catalogue. +2. A conflicting reserved `aidd-framework` host catalogue is never force-removed or rebuilt without canonical AIDD ownership and absence of foreign refs, even when the host calls its registration dead or unknown. Otherwise refuse and leave host state intact. +3. Project `marketplace remove` and `uninstall plugin` remove only A's project-local hooks/scripts and any tool-specific local MCP projection created by that flow before deleting A's manifest projection or detaching A's machine claim where one exists. Cursor's MCP file is user-global, not project-local, and stays for B. Failure retains the truthful local projection and any existing machine claim for a safe retry; B and all machine-owned files/refs remain intact. Do not invent an OpenCode machine claim. +4. A prior AIDD hostName/ref claim is not proof of the current host catalogue source. Native sync, targeted update/remove, and project/user clean compare the effective source read from the host with exact AIDD provenance before any host mutation. Even an owned catalogue may contain a foreign installed ref: catalogue removal also requires the full host ref list and refuses a noncanonical ref. Fresh absence is read from the host; an unreadable/unsupported source fails closed. The installed Copilot 1.0.83 lacks documented structured listing, so it must not activate partially or fabricate ownership. +5. A name/path is not proof of current local file contents. Use installed digests for Cursor user-scope files, OpenCode MCP entries, and Cursor project hook entries/scripts before update/unmerge/delete; a changed or legacy-unproven contribution remains in place and retains truthful claims for manual reconciliation. + +Proof: test-first A/B and foreign fake-HOME host assertions, targeted mutation of each destructive guard, then all required CLI gates and a fresh independent Check. No automated legacy migration, no floor reductions, no force bypass. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-1.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-1.md new file mode 100644 index 000000000..687669de8 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-1.md @@ -0,0 +1,64 @@ +--- +status: in-progress +--- + +# Instruction: guard native activation + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ + src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts ✏️ provenance before upgrade/reclaim + src/contexts/tools/domain/ports/native-plugin-activator.ts ✏️ exact catalogue refresh contract if supported + src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts ✏️ exact host CLI dispatch if supported + tests/contexts/framework/application/flows/ ✏️ foreign collision and A/B activation + tests/contexts/tools/infrastructure/ ✏️ exact CLI process arguments + scripts/smoke-collision.sh ✏️ catalogue and cache witnesses +``` + +## User Journey + +```mermaid +flowchart TD + Fixture["Foreign catalogue and plugin in fake HOME"] --> Sync["A syncs its plugin"] + Sync --> Choice{"AIDD proves catalogue ownership?"} + Choice -- "No" --> Refuse["Name collision; foreign state unchanged"] + Choice -- "Yes" --> Exact["Refresh exact AIDD catalogue only"] +``` + +## Test Scope + +```mermaid +--- +title: Native activation test scope +--- +journey + section Setup + Seed foreign catalogue and cache => witness hashes recorded: 5: cli + section Happy path + Sync AIDD owned exact catalogue => only that catalogue refreshes: 5: cli + section Edge case - foreign collision + Foreign same name or ref => sync refuses and hashes stay equal: 1: cli + section Edge case - stale registration + Unproven dead host registration => no force remove or rebuild: 1: cli + section Teardown + Clean fake HOME => real HOME unchanged: 5: cli +``` + +## Tasks to do + +### `1)` Prove ownership before host mutation + +> No unscoped refresh or forced takeover can run on foreign state. + +1. Write failing fake-host collision tests and targeted mutation checks. +2. Limit refresh to exact proven host catalogues; refuse when the host cannot target safely. +3. Guard reserved-name reclaim with canonical ownership and no foreign refs; update older migration expectations. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | A sync preserves foreign catalogue registration, plugin ref, and cache bytes; only a proven AIDD catalogue changes, with exact host arguments. | diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-2.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-2.md new file mode 100644 index 000000000..5acff6950 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-2.md @@ -0,0 +1,64 @@ +--- +status: in-progress +--- + +# Instruction: finish project-local removal + +## Architecture projection + +> Tree of the final files. ✅ create · ✏️ modify · ❌ delete + +```txt +cli/ + src/contexts/framework/application/flows/marketplace-remove-use-case.ts ✏️ local cleanup before orphan detach + src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts ✏️ local cleanup before uninstall detach + src/contexts/framework/application/ownership/project-plugin-cleanup.ts ✏️ reuse or narrow shared cleanup + tests/contexts/framework/application/flows/ ✏️ Cursor hook and MCP orphan removal + tests/contexts/framework/application/ ✏️ uninstall failure and B survival +``` + +## User Journey + +```mermaid +flowchart TD + Shared["A and B share a machine plugin"] --> Remove["A removes a marketplace or uninstalls"] + Remove --> Local["Remove A's local hooks and any local MCP projection"] + Local --> Choice{"Local cleanup succeeded?"} + Choice -- "No" --> Retain["Keep truthful local projection and any existing claim"] + Choice -- "Yes" --> Detach["Detach A; B and machine plugin remain"] +``` + +## Test Scope + +```mermaid +--- +title: Project removal test scope +--- +journey + section Setup + Install shared Cursor plugin in A and B => local hook and global MCP witnesses ready: 5: cli + section Happy path + Remove from A => A integration gone and B still works: 5: cli + section Edge case - local failure + Hook or MCP delete fails => A projection and any claim stay for retry: 1: cli + section Edge case - tool-specific MCP + Remove OpenCode flat plugin => only A's local MCP entry unmerged: 1: cli + section Teardown + Remove B then user clean => machine plugin removed only after both detach: 5: cli +``` + +## Tasks to do + +### `1)` Complete local cleanup before detachment + +> Do not leave A's local projections behind without a plugin record; do not remove Cursor's global MCP. + +1. Write failing `marketplace remove` and `uninstall plugin` integration tests. +2. Reuse project-local cleanup for hooks/MCP before manifest removal and claim detach. +3. Prove failure retains A's claim and B's global plugin remains intact. + +## Test acceptance criteria + +| Task | Acceptance criteria | +| --- | --- | +| 1 | Both commands remove A's project hooks/scripts and any local MCP projection created by that flow before detach; failure retains A's projection and any existing machine claim; Cursor's global MCP, B, and machine-owned state remain unchanged. | diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-3.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-3.md new file mode 100644 index 000000000..6581179e9 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-3.md @@ -0,0 +1,26 @@ +--- +status: in-progress +--- + +# Instruction: prove current host source + +## Outcome + +For Codex/Copilot, read the effective host catalogue source before add, refresh, update, project removal, or project/user clean. An AIDD machine claim alone cannot authorize a mutation when the source has been repointed. Before unregistering even an owned catalogue, prove its full host ref list contains no foreign ref. Fresh absence permits registration; an unreadable or foreign source refuses with a named manual remedy. Record exact source only after a proven fresh add. No legacy claim inference. + +Apply this gate to project plugin removal even when an old manifest lacks a native registration, if that removal would reach the host. Project clean may still detach a shared user-scope claim and clean local state without catalogue proof, because it must not mutate that shared catalogue. An unproven plugin ref is left enabled rather than uninstalled. [Copilot repository `enabledPlugins` is declarative auto-install](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference), so even writing settings is an activation side effect: defer that projection until the catalogue and plugin ref are proven and activated. + +Before `clean --scope user`, map every canonical machine claim to exactly one registered host catalogue with current source/ref proof. An orphan or ambiguous claim cannot be uninstalled through a different proven catalogue. A machine-global ref without a canonical claim is likewise not exclusive to this project, even if its host source is AIDD-owned. + +User clean preflights availability of every native activator it will need before uninstalling the first tool. A known missing second binary must not leave the first tool already changed while machine claims still record the old state. + +Codex 0.151.0 exposes `marketplace list --json` with `name`, `root`, and `marketplaceSource`. The installed Copilot 1.0.83 refuses `--json` despite current official docs; its repository overlay can replace the user source. Do not parse human text as an ownership proof. Feature-probe structured output and fail closed on unsupported versions until an exact source witness can be measured. + +## Verification + +- A/B fresh registration works; a foreign same-name source and cache stay byte-identical through sync/update/remove/clean. +- Copilot activation refusal leaves foreign repository settings, including `extraKnownMarketplaces` and `enabledPlugins`, byte-identical. +- Native CLI readers and source parsing use installed binary/official contracts, fail closed on unknown shapes. +- User clean refuses an orphan `x@B` claim when only catalogue A is proven; project clean never uninstalls an unclaimed machine-global ref used manually by B. +- Multi-tool user clean with the second binary unavailable refuses before the first host catalogue or ref changes. +- Full tests, architecture, build, smoke, targeted destructive mutants, then all declared mutation scopes. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-4.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-4.md new file mode 100644 index 000000000..ff31a14b0 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-4.md @@ -0,0 +1,19 @@ +--- +status: in-progress +--- + +# Instruction: preserve edited local integration + +## Outcome + +OpenCode MCP and Cursor project hooks/scripts are user-owned after installation. Compare each current contribution with its recorded install digest before unmerge or deletion. Edited or legacy-unproven entries refuse local detach and preserve claims for manual reconciliation. Reinstall must treat edited MCP as a user collision. Do not delete an entire script directory by plugin name. + +Content digest is not path confinement: reject a hooks config, script/parent directory, or MCP output whose resolved path leaves canonical projectRoot, even if the external bytes match the install digest. Preflight before native/shared claims move, then revalidate immediately before write/delete. + +## Verification + +- A/B remove and clean preserve B and edited project integration; unedited A contributions are removed precisely. +- A failed read/write or digest mismatch leaves A projection/claim truthful and retry-safe. +- Project clean preflights edited hooks before dropping a shared-source reference or undoing any native registration; removal rechecks immediately before write. +- Symlinked hooks config, script parent, and MCP output outside the project remain byte-identical; claims and B remain untouched. +- Targeted RED→GREEN tests and destructive mutation witnesses; full gates run on integrated tree. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-5.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-5.md new file mode 100644 index 000000000..e78cc03bc --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/phase-5.md @@ -0,0 +1,24 @@ +--- +status: in-progress +--- + +# Instruction: guard user-scope files + +## Outcome + +Before plugin update, remove, marketplace remove, or global user clean touches a Cursor user-scope file, compare current bytes with the installed digest already in the manifest. A changed, unreadable, or legacy-unproven file blocks that file's mutation and keeps ownership records truthful for manual reconciliation. Containment checks remain mandatory but are not content proof. + +On update, a new path absent from the previous owned-file list must also be absent on disk. An existing user-created path is not an AIDD file merely because a later plugin version wants to write it. + +For a plugin with both native refs and user files, prepare every file update and validate all old digests/new-path collisions before any native update. Applying a validated plan can still fail through unexpected I/O; retain truthful claims and name manual reconciliation rather than assert cross-host atomicity. + +Recheck all planned old paths just before the file write. If a tracked path or parent becomes a symlink outside the user plugin root between planning and application, refuse rather than writing through it; a reduced safe-file map is not permission to continue. + +## Verification + +- Edited `plugin.json`/script octets survive all four operations; an unedited A contribution is still removable without harming B. +- A user-created `new.md` that collides with a new plugin-version file survives update byte-identically. +- A mixed native+file update collision causes no host call, no file change, and no claim change. +- A symlink substituted after planning cannot overwrite external bytes, even if the native host update has already succeeded. +- A partial failure does not detach a project or machine claim while its local state remains unproven. +- Targeted tests and destructive mutant witnesses, then the integrated full gates. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/plan.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/plan.md new file mode 100644 index 000000000..2f98eeb21 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/plan.md @@ -0,0 +1,44 @@ +--- +objective: "Project A can install or remove a plugin without changing foreign machine state or stranding A's local integration while B remains dependent." +status: in-progress +--- + +# Plan: #829 Check repairs + +## Overview + +| Field | Value | +| --- | --- | +| **Goal** | Prove native catalogue ownership before host mutation; complete local cleanup before claim detach. | +| **Source** | [#829](https://github.com/ai-driven-dev/framework/issues/829), [`frame.md`](./frame.md), [`review.md`](./review.md) | + +## Phases + +| # | Phase | File | +| --- | --- | --- | +| 1 | Guard native activation | [`phase-1.md`](./phase-1.md) | +| 2 | Finish project-local removal | [`phase-2.md`](./phase-2.md) | +| 3 | Prove current host source | [`phase-3.md`](./phase-3.md) | +| 4 | Preserve edited local integration | [`phase-4.md`](./phase-4.md) | +| 5 | Guard user-scope files | [`phase-5.md`](./phase-5.md) | + +## Resources + +| Source | Verified | +| --- | --- | +| `codex plugin marketplace upgrade --help` | No name updates all configured Git marketplaces. | +| `copilot plugin marketplace update --help` | No name updates all registered marketplaces. | +| [`docs/ARCHITECTURE.md`](../../../../docs/ARCHITECTURE.md) | Host CLI calls stay behind the native activator port; application owns provenance. | + +## Decisions + +| Decision | Why | +| --- | --- | +| Refuse an unproven host catalogue; no `force` takeover | #829 requires preexisting user state untouched, including same-name collisions. | +| No unproven native legacy migration | Local registry scope normalization can continue; ambiguous host state is for manual reconciliation. | +| Skip automatic refresh of unproven project-labelled catalogues | A plugin claim does not prove machine catalogue ownership; preserve foreign state before optimizing refresh. | +| Local cleanup before machine claim detach | A failure must leave a truthful claim so retry cannot harm B. | +| A past machine claim is not current host source proof | Same-name catalogues can be repointed after AIDD registration; refuse host mutations without a current source witness. | +| Current source proof is not full ref ownership | An AIDD-owned catalogue can contain foreign host refs; read and partition host refs before unregistering it. | +| Compare local integration to install digests | Hooks and MCP in user-owned project files may be edited; never remove by name alone. | +| Compare user-scope files to install digests | Path containment is not ownership of current contents; edited files must not be updated or deleted by explicit user-scope operations. | diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/retrospective.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/retrospective.md new file mode 100644 index 000000000..22916e893 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/retrospective.md @@ -0,0 +1,51 @@ +# #829 delivery retrospective + +## Observation and limits + +This records the delivery after the request to implement #829 from `origin/next`. The original dirty checkout was not edited; implementation used `framework-829`. Measurements below come from 141 completed command logs dated 2026-09-15 13:58:50–19:07:39 UTC. That 5 h 08 m 49 s window is not the total task duration, and overlapping command durations must not be added to infer elapsed wall time. Final gate results and later work must be appended before closing this report. + +## Measured verification cost + +| Completed runs | Cumulative command time | Meaning | +| --- | ---: | --- | +| 30 Stryker campaigns | 45 m 02 s | 11 framework runs: 26 m 36 s; five changed-lines runs: 9 m 38 s; 14 other targeted runs: 8 m 48 s. | +| 17 full Vitest passes, excluding Stryker dry-runs | 9 m 06 s | Mean 32.17 s. Ten green passes after the fourth delivery candidate: 5 m 17 s across different trees, not ten equivalent proofs. | +| First framework mutation campaign | 6 m 44 s | Score 90.1%, below the required 93% floor. Ten subsequent framework relaunches consumed another 19 m 52 s before scores near 93.0–93.1%. | + +A later global Stryker attempt aborted at dry-run after 49 s and 4% progress. Reused mutants and static mutants did not make it a valid final report. The team stopped further campaigns until the source and test contract were stable. + +## Bottlenecks and their causes + +1. The initial destructive-effect matrix omitted native same-name collisions, current effective source changes, foreign host references under an AIDD-owned catalogue, edited project hooks/MCP entries, and edited user-scope files. The first independent checker and the next preflight found these after expensive green gates. A third preflight found `clean` overblocking a shared catalogue, project `plugin remove` still able to uninstall a repointed ref, a new user-created file still able to be overwritten on update, and [Copilot's repository `enabledPlugins` declarative auto-install](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference) possible before the CLI activation refusal. Earlier passing gates were real for their candidate, but not proof for the repaired candidate. +2. The proof interface changed while old fixtures were being adapted. One change yielded 84 red flow tests; moving the `references.json` write until *proven* registration reopened four reference tests. The contract should have been frozen before bulk fixture work. Positive test doubles must express independently observed host state, not copy the project's claims into the host reader. +3. The environment was not pinned end to end. Installed Homebrew Node 25 failed to start because `libsimdjson.30.dylib` was missing. Pinning only the parent Node executable was insufficient; spawned tools also needed a Node 24-first `PATH`. A smoke pass reported green while `md5` was missing and both fingerprints were empty. Replacing it with Node SHA-256 exposed a Bash 3.2 empty-array failure, then fixed it. Smoke now hashes path *and content*, rejects failed hash computation, and runs under the system Bash. +4. Mutation reports were sometimes compared after edits or a changed mutant inventory. Stryker's runner reuse and static `HOME` test fixtures also produced false survivors. Dependency-injected home lookup and targeted single-runner tests corrected this, but they do not validate an old report on a new tree. Mutation output must be bound to an immutable source hash and fixed environment. +5. Bundle growth was measurable rather than free: first candidate 681.8/682 KB, repaired source/local guards 703.8/710 KB, later path guards 714.2 KB. The final measured budget is 722 KB, 1.1% headroom. This is a reviewable cost of the added guards, not a reason to loosen another gate. +6. The late checker pass still found an ordering hazard: project clean detached the shared source claim before local file cleanup completed. A later local error could leave the project installed but unclaimed, making user-scope clean appear safe. The Copilot 1.0.83 source listing cannot supply structured proof, so fresh native registration remains intentionally unavailable on that installed version. These are separate findings: the first requires a code repair and failure-injection test; the second requires an explicit functional caveat, not fabricated native source data. + +## Next delivery loop, without relaxing quality or determinism + +1. Before costly checks, inventory the **installed** host binaries and their documented structured outputs. Run a fake-home, byte-for-byte foreign-state matrix for `sync`, targeted update/remove, uninstall, project clean, and user clean. Include A/B sharing, current source, all host refs, modified hooks/MCP, existing new-path collisions, and indirect repository settings effects. Fail closed where Copilot 1.0.83 cannot prove its current source; do not invent a text parser or upgrade the user's binary. +2. Freeze the ports, provenance schema, operation ordering, and reference-write point. Adapt legacy fixtures once, with explicit future verified-reader test doubles separate from installed-binary E2E checks. Run targeted red→green tests for each destructive branch and compare untouched paths and contents, not only exit codes. +3. On a frozen tree, run full Vitest, coverage floors, architecture ratchets, typecheck, lint, duplicate/dead-code checks, build budget, multi-tool smoke and collision smoke. An independent read-only preflight must clear all destructive paths before mutation. A source edit invalidates the complete-tree result for that path; rerun the relevant gates before proceeding. +4. Run targeted destructive mutants, then all 14 mutation scopes **exclusively** on the frozen source hash under the same Node 24-first `PATH`, `HOME`, and `CODEX_THREAD_ID` fixture contract. Preserve the existing mutation floors; never promote an aborted, stale, or wrong-hash report. A final independent checker inspects both behavior and evidence before delivery. + +No saved-time estimate is justified yet: the data measures repeated work, not the counterfactual runtime of the improved loop. The proposed gain is fewer invalidated expensive passes, not fewer acceptance gates. + +## Pre-remedy checkpoint, not a completed delivery + +The staged diff before the safe-remedy wording change had SHA-256 `e9cab56dbcb0f4a3a164a7e664620439e178d2de84f213c74f6bb8db111e63b6`. Full Vitest passed 521/521 suites and 6,464/6,464 tests in 35.44 s. Coverage passed the same 6,464 tests in 44.25 s: 98.92% lines, 96.51% branches, 99.68% functions. Architecture 131/131, typecheck, lint, knip, jscpd, build 714.3/722 KB, 33/33 command smoke, and foreign-state collision smoke all exited zero. The independent checker found no remaining confirmed security blocker in the implementation, but warned that Copilot 1.0.83 cannot prove a fresh native catalogue source and therefore refuses native activation even for a new profile. The wording change creates a new source SHA; these gates must not be presented as proofs of that newer tree until rerun. + +The `framework` mutation scope on that pre-remedy SHA completed in 6 m 49 s with **86.7%**, below its declared **93%** floor. Its report has 7,127 killed, 11 timed out, 895 survived, and 196 uncovered mutants: 8,229 scored mutants, 1,091 undetected. At least 515 more must be detected to reach 93%. Of the undetected mutants, 747 lie on lines changed from `origin/next`; 239 are in `marketplace-sync-settings-use-case.ts`, 108 in `plugin-remove-use-case.ts`, and 64 in `clean-use-case.ts`. The report is real for that earlier tree, not for the later wording-only SHA, and cannot be promoted to green by reusing a prior candidate's score. The remaining 13 scopes were not run after the first required floor failed. No final repairs had been committed or pushed and no draft PR had been opened at this checkpoint. + +The account-wide Codex weekly window read 80% used at the last check; no pre-task baseline exists, so attributing that percentage to this delivery would be false. There are no reset credits. This budget constraint does not change the mutation floor; it makes an explicit product decision necessary before any urgent partial delivery. + +## What the late failure changes in the proposed loop + +The early preflight should include a mutation-capacity estimate on **changed lines** before 14 complete scopes, not a substitute for them. A full-framework run should occur before polishing delivery documents, and its mutant inventory must be checked against added safety branches. This candidate added extensive fail-closed paths whose ordinary and collision tests verify outcome, yet often do not distinguish mutated guards. The remedy is focused negative and positive witness tests around each decision boundary, and possibly simpler control flow where branches duplicate a proof; not raising a threshold, mutating fewer files, or calling the code done on a below-floor score. We cannot quantify how long 515 additional detections would take from current measurements. + +## Authorized partial-draft checkpoint + +The user authorized a partial draft only if the original #829 preservation behavior is correct. A fresh independent checker compared the staged source with the issue's literal Cursor/Codex/Copilot collision and clean cases, reran 127 targeted tests on eight files, and found no remaining confirmed blocker to those preservation outcomes. This is not certification of the undetected mutants or of fresh Copilot 1.0.83 activation. The checker assigned 80/100 for a draft, not merge approval. + +After replacing an inherited destructive, Claude-specific conflict remedy with a host-neutral manual-reconciliation warning, the new source tree passed 521/521 suites and 6,464/6,464 tests in 37.05 s; coverage passed the same tests in 39.87 s at 98.92% lines, 96.51% branches and 99.68% functions. Build measured 714.3/722 KB, command smoke exercised 33/33 leaves with no failure, and foreign-state collision smoke had no failure. The earlier 86.7% framework mutation report does **not** measure this later wording-only tree. Under the explicit waiver, a draft PR may be prepared, but it must not be merged, #829 must not be closed, and the declared mutation floor remains 93% until a fresh campaign meets it. diff --git a/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/review.md b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/review.md new file mode 100644 index 000000000..3fe5702e9 --- /dev/null +++ b/aidd_docs/tasks/2026_09/2026_09_15_fix-829-shared-scope/review.md @@ -0,0 +1,89 @@ +# Review: #829 shared user-scope plugin safety + +- **Verdict**: blocked +- **Diff**: `origin/next...codex/fix-829-shared-scope`; mutation hardening published in `58b5e92b`, CI corrections under verification +- **Axes run**: code, functional, relevancy +- **Date**: 2026_09_17 +- **Findings**: 1 critical, 2 warnings, 0 minor + +## Phases + +### Phase 1 — Guard native activation + +- [x] Sync preserves foreign catalogue source, plugin ref, and cache bytes; refresh targets only proven AIDD catalogues — `cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts:775`, `cli/scripts/smoke-collision.sh:69`. + +### Phase 2 — Finish project-local removal + +- [x] Marketplace removal and plugin uninstall clean A's local integration before detachment, retaining claims on local failure and leaving B/machine files intact — `cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts:106`, `cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts:72`. + +### Phase 3 — Prove current host source + +- [ ] Fresh native registration across A/B is partial: Codex has structured source proof, but Copilot 1.0.83 cannot activate even on a new profile — `cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts:4`. +- [x] Copilot refusal leaves foreign repository settings and native state untouched — `cli/scripts/smoke-collision.sh:81`. +- [x] Native readers use verified structured shapes and refuse unsupported outputs — `cli/src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.ts:45`. +- [x] User clean rejects orphan/unclaimed machine-global refs and project clean leaves another user's ref enabled — `cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts:316`, `cli/src/contexts/framework/application/clean-use-case.ts:248`. +- [x] Multi-tool user clean checks required binary availability before the first host mutation — `cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts:1158`. +- [ ] Final-head CI remains pending. Local `framework` passed at 93.5837%; the preceding published head passed twelve other mutation scopes in CI, but `tools-codex` failed at 90% against its 94% floor — `cli/mutation-scopes.json`. + +### Phase 4 — Preserve edited local integration + +- [x] Edited Cursor hooks/scripts and OpenCode MCP contributions are kept; clean/remove only unedited A entries without touching B — `cli/src/contexts/framework/application/shared/remove-project-hooks.ts:79`, `cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts:1`. +- [x] Failed local cleanup preserves A's reference and blocks shared user clean — `cli/tests/contexts/framework/application/clean-use-case.unit.test.ts:796`. +- [x] Hook preflight precedes source-claim detachment and host changes — `cli/src/contexts/framework/application/clean-use-case.ts:204`. +- [x] Symlinked project integration outside the canonical root is refused without changing external bytes — `cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts:62`. +- [x] Targeted behavior tests, full functional gates, and the whole-framework mutation floor pass at 93.5837% — `cli/reports/mutation/framework/mutation.json`. + +### Phase 5 — Guard user-scope files + +- [x] Edited user plugin files survive update, remove, marketplace remove, and user clean; A's unedited contribution can still detach — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:257`, `cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts:112`. +- [x] A user-created new-path collision is refused before update overwrites it — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:279`. +- [x] A mixed native/file collision prevents host call, file writes, and claim movement — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:305`. +- [x] A substituted symlink is rechecked before file I/O; external bytes survive deterministic tested cases — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:332`. +- [x] Partial update failure retains truthful claims for manual reconciliation — `cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts:380`. +- [x] Targeted behavior tests, integrated functional gates, and the whole-framework mutation floor pass at 93.5837% — `cli/reports/mutation/framework/mutation.json`. + +## Findings + +| Sev | Kind | Phase | Location | Issue | Fix | +| --- | --- | --- | --- | --- | --- | +| 🔴 critical | functional | 3 | `cli/mutation-scopes.json` | Framework hardening is published in `58b5e92b`, with a local 93.5837% result and matching source. The preceding head passed twelve other mutation scopes in CI; Codex, Smoke, and Windows failed. Final-head gates are not yet certified. | Correct the failing contracts and verify CI on the final published head before a merge-ready verdict. | +| 🟡 warning | functional | 3 | `cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts:4` | Copilot 1.0.83 safely refuses even fresh native activation; #829's preservation need is met, but end-to-end Copilot install is partial. | Verify a structured source reader on a supported binary in a separate follow-up before claiming full support. | +| 🟡 warning | functional | 5 | `cli/src/contexts/framework/application/plugin/plugin-helpers.ts:58` | User clean validates paths with an injected home, but deletion resolves OS home. Normal OS-home deletion passes; custom injected-home deletion is not certified. | Align validation and deletion home resolution in a bounded follow-up with a custom-home witness. | + +## Verification + +| Metric | Value | +| --- | --- | +| Verified | 89.5% (17/19) | +| Files checked | `cli/src/contexts/framework/application/{flows,clean,plugin,ownership,shared,uninstall}`, `cli/src/contexts/tools/{domain,infrastructure}`, relevant E2E/integration tests, `cli/scripts/smoke-collision.sh`, task plan and issue #829 | +| Unchecked | Phase 3 fresh Copilot — follow-up; final-head CI, including Codex mutation, Smoke, and Windows — verification pending. Twelve other mutation scopes passed on the preceding head, not a fresh certification of the final tree. Custom-home cleanup is a separate diagnostic finding. | +| Unplanned | Measured bundle-budget increase and smoke harness adaptation support delivery verification; no unrelated feature found. | +| Draft disposition | User-authorized partial draft only; do not merge or close #829 until the strict gates and Copilot scope decision are resolved. | + +## Incremental local test hardening — 2026_09_16 + +- Tests only: 236 additional cases since the 6,464-test snapshot; production source is unchanged during these lots. No commit or push. +- Latest functional run: `pnpm exec vitest run --reporter=dot` passed 524 files / 6,700 tests in 35.16s. Coverage was last measured before the 143 latest tests: 99.09% lines, 96.80% branches, 99.68% functions, with 6,557 tests passing in 74.95s. `pnpm typecheck`, `pnpm lint`, and `git diff --check` passed; lint retains only the existing unused constructor-property warning at `uninstall-use-case.ts:33`. +- Twenty-one targeted reports under `cli/reports/mutation/829-*/mutation.json` show 465 unique additional detections against the original baseline, without double counting overlapping campaigns. The original full report and incremental state are preserved under `cli/reports/mutation/framework-baseline-829/`; the preceding 90.0595% snapshot is preserved under `cli/reports/mutation/framework-90-before93/`. +- The final whole-framework incremental run used the existing runner's exported scope arguments, pruning, scoring, and floor check, with concurrency limited to two. It reused 5,954 results, replayed 2,275 mutants, and finished in 9m01s: 7,688 killed, 13 timed out, 465 survived, 63 uncovered; score 93.5836675173168% on 8,229 mutants. The declared 93% gate passed. All 115 reported source files match the frozen working tree; production source, mutation configuration, exclusions, and scripts were unchanged during hardening. +- Exact comparison with the original baseline gives 563 additional detections and no detection regression. Two unmatched mutation keys concern the previously documented sync warning wording. Against the preceding 90.0595% snapshot, 291 newly detected mutants and one detection loss give a net gain of 290. The retained loss is a static empty-string mutant in `plugin-distribution-reader-adapter.ts:20`; it was also undetected in the original baseline. Its status is not replaced with an earlier favorable result. The framework gate is passed, but the strict overall delivery verdict remains blocked by the other thirteen scopes and the documented partial Copilot verification. +- Covered additions: post-add source/root/type mismatches and unreadable proof, local alias versus host identity, project versus machine catalogue proof, exact attachment to an already-enabled machine ref while preserving B, exclusive-access update preflight, affected-project warnings, and original I/O error causes. + +## Publication and CI corrections — 2026_09_17 + +- User-authorized publication: `58b5e92b` and `3946be7f` pushed to PR #870 with hooks active. Pre-commit architecture and lint, commitlint, and pre-push Knip and the full functional suite passed. The latest suite passed 525 files / 6,726 tests. The PR remains draft; no merge or issue closure. +- Windows reproduction: the file doubles registered `/project/link`, while callers used a resolved drive-qualified path. Two isolated Windows-path regression tests failed before correction. Symlink and `realpath` fault keys now resolve paths consistently; file-content keys remain unchanged. The focused four-file run passed 57 tests, including external-symlink refusal and project-boundary cases. +- Smoke now selects the native refusal contract only when Copilot is available; without it, local removal must succeed and warn that the native CLI is unavailable, followed by local reinstall. Both available-host and missing-host runs passed all 33 command families with zero failures. The native refusal run preserved exact project/home bytes. +- The previous Codex report matched its six source files but omitted the new native marketplace source parser, so its 96.8504% was not a current whole-scope certificate. Added malformed listing/source, mixed-row rejection, and actionable diagnostic witnesses through the public source reader; its 31 integration tests pass. The first fresh complete Codex run passed at 94.1176% on 459 mutants (430 killed, two timed out, 27 survived), covering all seven matching source files. A final run including four added diagnostic cases is under verification, with the 94% floor unchanged. +- Codex consolidation: the diagnostic cases reduce survivors to 21. A high-concurrency run overlapping the full suite reported 11 timeouts; it is not used as evidence of stability. A subsequent complete-scope run at concurrency two passed at 95.4248366% in 4m02s, with zero timeouts and the 94% floor unchanged. +- CI on `3946be7f` confirms Smoke passes, but Windows reports 37 failures because resolving every virtual `realpath` identity changed seeded project-reference identities. Two Windows-model tests reproduce the regression. The correction resolves symlink lookup keys while preserving unlinked paths and declared target identities; no production behavior or refusal assertion is relaxed. Final-head Windows verification remains pending. +- CI on `95497fee` confirms the external-symlink refusals and project-reference identity cases pass. Three Windows failures remain in assertions introduced by the mutation hardening: two cache-warning expectations hardcode POSIX separators, and a cache-neighbor list expectation ignores the double's slash-normalized keys. Expected diagnostics now use platform `join`; the neighbor-list expectation uses the documented normalization while retaining exact path and byte-preservation assertions. Final-head CI remains pending. +- Runtime bottleneck: in the final run Stryker estimated that 91 static mutants (4% of mutants scheduled for replay) would account for 61% of execution time. None were ignored. Harness optimization must preserve their witness coverage and sound cache invalidation. +- Marketplace-removal additions cover reserved shared-catalogue refusal before registry access, exact user-catalogue selection among competing entries, exact native scope and host name, implicit host ref scope, a proven empty catalogue without plugin claims, missing canonical ledger, and project cleanup without a native mapping or orphan. The two targeted campaigns took 17s and 21s; the final report contains 202 killed / 10 survived across 212 mutants. The surviving empty file-scope literal still resolves to the same user directory; no invalid runtime scope was introduced just to distinguish it. +- Synchronization additions in `cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts` and `marketplace-sync-source-provenance.integration.test.ts` cover implicit/explicit project synchronization inside the machine lock, user-scope non-reentrancy, lock failure before project reads or host writes, missing/unreadable plugin registries with and without a diagnostic, explicitly absent plugin registries followed by post-add source proof, and Claude catalogue absence versus late unreadability or a newly appearing same-name foreign catalogue. The two targeted campaigns took 50s and 53s; the latest report contains 96 killed / 16 survived / 0 uncovered across 112 mutants. Their overlapping gains were counted once. +- Late-refusal/projection additions in `cli/tests/contexts/framework/application/flows/marketplace-sync-native-provenance.integration.test.ts` cover a transient source-read refusal followed by recovered proof, foreign/missing source, foreign refs or unreadable refs, for Codex and the future-supported Copilot test double. Existing project references and B's machine claims survive invalid proof; recovered proof attaches only this project. Narrowed sync drops obsolete target references only after valid proof and retains references when another alias still owns the same host catalogue. The Claude late-collision test also explicitly asserts an empty project projection. `829-sync-late-refusal-projections/mutation.json` matches current source: 130 killed / 17 survived / 1 uncovered across 148 mutants, in 44s; 19 detections beyond the complete snapshot, of which two overlap the preceding registry-proof lot, so the net addition is 17. No claim of real Copilot support follows from these doubles. +- Catalogue-versus-plugin additions in `cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts` cover project/user Claude catalogue refusal without phantom machine claims, a catalogue containing an undeclared plugin without enabling or claiming that plugin, scope-specific machine catalogue ownership for Claude/Codex, idempotent ownership saves, preservation of B's existing claims and tool version, another alias pointing at the same host, and fresh machine tool/version initialization. `829-sync-catalogue-plugin-claims/mutation.json` matches current source: 109 killed / 13 survived / 0 uncovered across 122 mutants, in 41s; 14 detections beyond the complete snapshot, of which five overlap preceding lots, so the net addition is nine. +- The first threshold-93 batch, `829-threshold93-native-recovery/mutation.json`, measured 152 novel detections beyond every preceding targeted lot: clean 39, sync 32, plugin removal 27, hooks 28, plugin add 7, init 7, native registration gate 7, file updater 5. It completed in 2m58s across 1,034 mutants. Fourteen formerly detected mutations survived in this narrower run; these discrepancies are retained for the whole-framework replay, not omitted from the verdict. Added witnesses assert preflight state preservation, native cache retention on binary loss, exact host diagnostics, recovery races and scope, public upgrade/no-op behavior, and hook contribution ownership. The test skill's behavioral contract ruled out contrived invalid inputs for unreachable branches. +- Separate diagnostic finding: `CleanUserScopeUseCase` checks user files with its injected home directory, but `deletePluginFilesForTool` resolves deletion through OS `nodeHomedir` (`cli/src/contexts/framework/application/plugin/plugin-helpers.ts:58`). The truthful OS-home test proves normal user-file deletion; it does not certify custom injected-home deletion. No production workaround was added to raise the mutation score. +- The final targeted batch, `829-threshold93-final-contracts/mutation.json`, added another 61 novel detections: user clean 21, sync 17, native source proof 5, cache purge 6, built materialization 3, runtime settings 3, plugin removal 2, tool uninstall 4. It completed in 1m45s across 508 mutants. All narrow-run discrepancies were left for the complete replay; the reported final global score, not the sum or average of targeted scores, is the certified result. +- Threshold-93 objective complete. Remaining delivery work: verification of the other thirteen scope gates and the existing Copilot follow-up; custom-home cleanup is separately documented. No legacy migration, commit, or push was performed. diff --git a/cli/aidd_docs/memory/codebase-map.md b/cli/aidd_docs/memory/codebase-map.md index 90fe9aac2..c3e1eda72 100644 --- a/cli/aidd_docs/memory/codebase-map.md +++ b/cli/aidd_docs/memory/codebase-map.md @@ -21,6 +21,7 @@ src/ │ │ │ ├── global/ │ │ │ ├── install/ │ │ │ │ └── content/ +│ │ │ ├── ownership/ # machine plugin claims and safe project detach │ │ │ ├── plugin/ │ │ │ ├── restore/ │ │ │ ├── setup/ diff --git a/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md b/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md index 0d6f926f4..911afb58f 100644 --- a/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md +++ b/cli/aidd_docs/memory/internal/decisions/clean-drives-the-host-cli.md @@ -8,14 +8,16 @@ 2. Tracked files, merge files, plugin files. 3. `.aidd/` itself. A host needs `.aidd/cache/` alive during step 1. 4. Machine-local files no `plugins[].files` tracks: `.claude/settings.local.json`, a project-merged `.cursor/hooks.json` and its `.cursor/hooks//`, through `application/shared/remove-project-hooks.ts`. -5. A user-scope plugin directory (`~/.cursor/plugins/local/`) only once `realpath` proves it strictly inside the tool's declared user-scope directory (`domain/plugins/user-scope-containment.ts`). A `..` segment or a post-install symlink is left and named. +5. No user-scope plugin directory: the user manifest owns those files. Project clean detaches its canonical root only after local cleanup succeeds. A `..` segment or a post-install symlink is refused by explicit user-scope deletion. 6. Right after step 1, per tool driven: the cache root its profile declares (`NativeActivation.pluginCacheDir`), `/`, under the same containment. - A binary off `PATH` is named and left alone. - The shared `aidd-framework` registration is the one exception: `undoMarketplaceRegistration` refuses on the scope and warns, naming the host registration, the `marketplaces.json` entry and the tool's cache path. -- This project's refs are still uninstalled, except one a machine-global host (codex, copilot) enables while `references.json` names another project: left enabled and named. +- A native ref with an exact `@` claim in the user manifest is machine-owned: project clean never uninstalls it, including when this is the last project. It also leaves a project-labelled Codex/Copilot catalogue registered while an exact machine ref still claims that host catalogue; for non-framework catalogues, no user manifest to prove it unshared also leaves the registration for manual host cleanup. The framework source has its separate `references.json` authority and may not have a user manifest. Project clean detaches this project's root after local cleanup. A preexisting host ref without a machine claim is foreign and never adopted. +- Targeted user-scope `plugin update` rematerializes machine-owned files for Cursor. Copilot's native `plugin update @` updates an AIDD-owned exact ref through its binary, with dependent projects named and no manual host-cache writes. Codex has no targeted update verb; this command refuses for Codex rather than claiming `upgradeMarketplaces()` updated that one plugin. Use `marketplace refresh` or `framework update` for its catalogue/version workflow. - The warning names how many other projects still reference the source, or that `clean --scope user` purges it. - A dry-run reports the same list without dropping anything. +- Machine claims are changed under an inter-process manifest lock covering load, mutation and save. `plugin add` also holds it while fetching/building and writing the project; a slow operation may make another project wait up to 30 seconds, then fail with a named busy-lock error and require a retry. The lock is never stolen solely because it has aged, so contention cannot silently lose B's claim. ## Cache purge @@ -25,13 +27,13 @@ ## `clean --scope user` -- The one command that purges the shared source. +- The one command that purges the shared source and AIDD-owned user-scope plugin files/native refs, after every dependent project has detached. - The user manifest is optional: a project-scope `setup` never writes one yet leaves the whitelist behind. -- Without it, steps 1–3 are skipped and said so; other projects in `references.json` are named with the order to run `aidd clean` in each first. -- Steps 1–2 run at scope `"user"` always, never guessed from the host default. +- Without it, steps 1–3 are skipped and said so. A live project in `references.json` still blocks source/cache purge, even with `--force`. +- Steps 1–2 run at scope `"user"` only for native refs AIDD enabled and claimed; a foreign preexisting ref is never uninstalled. Active per-plugin dependents in the user manifest block removal, separately from the `references.json` source claim, even with `--force`. An absent binary, failed host unregister, unreadable source claim or unsafe tracked plugin path aborts without deleting the canonical manifest. - Step 3: `purgeAllNativeCaches`, shared with project-scope `clean`. - Step 4, always: a hardcoded whitelist under `userConfigDir()`: `cache/built/` in full, `cache/update-check.json`, root `update-check.json`, the `cache/` shell once a fresh `listDirectory` proves it empty, `references.json`; each re-resolved through `realpath` and `isStrictlyWithinUserScope` before deletion. - `manifest.json` goes through its repository, the `aidd-framework` entry alone out of `marketplaces.json` through the registry; neither takes a path from the manifest. - `userConfigDir()` itself is never a candidate. - Confirmation, unless `--force`, names the source, every version under `cache/built/`, every live project in `references.json`, and the no-registration note. -- A project pointed at the purged source repairs itself on its next `aidd sync`. +- A project must clean/detach before this purge; automatic repair of a still-dependent project is not a safety mechanism. diff --git a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md index f0597c8d1..be260e1e8 100644 --- a/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md +++ b/cli/aidd_docs/memory/internal/decisions/framework-source-is-machine-scope.md @@ -16,18 +16,18 @@ One registration of `aidd-framework` per machine, shared by every project. Read ## Migration -- `MarketplaceRegisterFrameworkUseCase` retires a project-scope entry to the shared one on every `setup` or `sync`. -- Unconditional, not behind `--force`: `MarketplaceRegistryAdapter.list()` answers project-scope entries first, so a leftover would win forever. -- The migration carries the entry's own recorded source, never the local-path default. -- It also repoints a host still tracking *another* project's pre-migration cache, without breaking that project, and records both claims. -- Codex and copilot have no readable marketplace registry; on a refusal at the reserved name and scope they reclaim it, `remove` then `add`. Never for an arbitrary marketplace. +- `MarketplaceRegisterFrameworkUseCase` normalizes a project-scope `aidd-framework` entry to the shared registry on `setup` or `sync`. This is local metadata, not authority to take over the native host. +- `MarketplaceRegistryAdapter.list()` answers project-scope entries first, so a leftover would otherwise shadow the shared entry. Normalization carries the entry's own recorded source, never the local-path default. +- A Claude host still tracking a pre-migration cache is repointed only if the old machine manifest, current host registry source, old catalogue/version, and complete host refs prove the exact AIDD cache with no foreign ref. It then records both projects' claims. +- Unproven legacy host state is left for manual reconciliation. Codex and Copilot do not reclaim a reserved name by force when the current source cannot be proved. +- Native hosts are reconciled only after their current marketplace source and complete plugin references prove AIDD ownership. A reserved-name collision without that proof is left for manual reconciliation; a host without a readable source refuses mutation. - This project's stale `.aidd/cache/built/aidd-framework/` is deleted only once the run reports no error, no missing binary, no failed build. A host needs that tree to resolve what it unregisters. ## `references.json` - `userConfigDir()/references.json`: `{ "": ["", …] }` (`contexts/framework/domain/ports/user-source-references.ts`). -- Written by `setup` and `sync` whenever the framework marketplace resolves to scope `"user"`. -- `clean` drops only this project's claim, once, never the registration. +- Written by `setup` and `sync` only after the selected native host registration is proven; a refusal does not create a new claim. +- `clean` drops only this project's claim, once. It proves current source and host references before any project-scoped host removal; a shared registration is left untouched even if its source cannot be read. A machine-global plugin ref without a canonical machine claim is left enabled for manual reconciliation, not treated as exclusively owned by the last project in `references.json`. - A help, not an authority: a `projectRoot` deleted with `rm -rf` is ignored at read. - At zero claims, `clean` names `clean --scope user` as the purge. - `aidd marketplace remove aidd-framework` refuses the same way; it carries no `--scope user` flag. @@ -42,12 +42,12 @@ One registration of `aidd-framework` per machine, shared by every project. Read ## `sync`'s write path - Refuses to write to a host already ahead. -- Brings a host behind forward as an ordinary update. +- Brings a host behind forward only after its current source and plugin refs prove AIDD ownership; unsupported Copilot versions refuse native activation. - Same version: no-op. ## `setup --scope user` -- Registers the shared source and drives native activation machine-wide. +- Registers the shared local source; native machine-wide activation requires verified current host source/ref proof. Unsupported Copilot versions refuse it. - Writes nothing under `projectRoot`: no content, no plugin prompt, no gitignore touch. - Its manifest: `userManifestPath(userConfigDir())`, `userConfigDir()/manifest.json`, same schema and version as the project one. - `UserManifestRepositoryAdapter` reuses `Manifest.fromJSON`/`toJSON`; its `delete()` removes that one file only. diff --git a/cli/package.json b/cli/package.json index 6757193fb..d474ce703 100644 --- a/cli/package.json +++ b/cli/package.json @@ -36,7 +36,7 @@ "node": ">=22.12" }, "packageManager": "pnpm@12.3.4", - "bundleBudgetKB": 654, + "bundleBudgetKB": 722, "scripts": { "build": "tsup && node scripts/check-bundle-size.mjs", "build:check-size": "node scripts/check-bundle-size.mjs", diff --git a/cli/scripts/check-bundle-size.mjs b/cli/scripts/check-bundle-size.mjs index 69a7864aa..01a5ab0f1 100644 --- a/cli/scripts/check-bundle-size.mjs +++ b/cli/scripts/check-bundle-size.mjs @@ -6,7 +6,7 @@ const root = resolve(fileURLToPath(import.meta.url), "../.."); const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // The budget makes growth visible rather than walling it off: a raise is deliberate, is -// what a reviewer sees, and leaves ~2 % headroom over what was measured, never more. +// what a reviewer sees, and leaves at most ~2 % headroom over what was measured. // The registry of every raise, budget then measurement then what landed: // 560 KB: 500.8 KB, measurement across five tools. // 590 KB: 567.7 KB, one person resolved across tools and machines. @@ -22,6 +22,12 @@ const pkg = JSON.parse(readFileSync(resolve(root, "package.json"), "utf8")); // 625 KB: 612.56 KB, `--scope user` on `setup`, `doctor` and `sync`. // 641 KB: 628.26 KB, `clean --scope user` and sync's migration of a pre-shared-source project. // 654 KB: 641.0 KB, the shared-plugin, narrowing, hook and Windows-lookup passes. +// 682 KB: 675.5 KB, canonical per-plugin machine claims, scoped user operations and +// inter-process ownership guards for Cursor, Codex and Copilot (+31.3 KB over next's 644.2). +// 710 KB: 703.8 KB, current native host-source proof and exact hook/MCP/user-file provenance +// guards for #829 (+22.0 KB over the first 829 candidate's 681.8); 0.9% measured headroom. +// 722 KB: 714.2 KB, exact native-claim partition, multihost preflight, and symlink boundary +// guards for #829 (+10.4 KB over the preceding 703.8 KB); 1.1% measured headroom. const budgetKB = pkg.bundleBudgetKB ?? 500; const budgetBytes = budgetKB * 1024; diff --git a/cli/scripts/smoke-collision.sh b/cli/scripts/smoke-collision.sh index 2fba0ef9e..4ff431807 100755 --- a/cli/scripts/smoke-collision.sh +++ b/cli/scripts/smoke-collision.sh @@ -27,32 +27,62 @@ export CODEX_HOME="$HOME/.codex" unset XDG_CONFIG_HOME AIDD_TELEMETRY_DIR CODEX_CONFIG="$CODEX_HOME/config.toml" +CODEX_CACHE_WITNESS="$CODEX_HOME/plugins/cache/aidd-framework/foreign-payload/bytes" COPILOT_SETTINGS="$HOME/.copilot/settings.json" CURSOR_MANIFEST="$HOME/.cursor/plugins/local/$PLUGIN/.cursor-plugin/plugin.json" CODEX_SECTION="[plugins.\"$REF\"]" CURSOR_MINE='{"name":"aidd-vcs","version":"0.0.1","mine":true}' +CODEX_CACHE_MINE='foreign host cache bytes must remain unchanged' +CODEX_FOREIGN_FIXTURE="$ROOT/tests/fixtures/plugins/codex-format/marketplace-foreign-aidd" +COPILOT_FOREIGN_FIXTURE="$ROOT/tests/fixtures/plugins/copilot-format/marketplace-multi-sample" TOOLS=(cursor) for t in codex copilot; do if command -v "$t" >/dev/null 2>&1; then TOOLS+=("$t"); else skip "$t not installed on PATH"; fi done -mkdir -p "$CODEX_HOME" "$(dirname "$COPILOT_SETTINGS")" "$(dirname "$CURSOR_MANIFEST")" +mkdir -p "$CODEX_HOME" "$(dirname "$CODEX_CACHE_WITNESS")" "$(dirname "$COPILOT_SETTINGS")" "$(dirname "$CURSOR_MANIFEST")" printf '%s\nenabled = true\n' "$CODEX_SECTION" > "$CODEX_CONFIG" -printf '{"enabledPlugins":{"%s":true}}\n' "$REF" > "$COPILOT_SETTINGS" +printf '%s\n' "$CODEX_CACHE_MINE" > "$CODEX_CACHE_WITNESS" +if command -v codex >/dev/null 2>&1; then + codex plugin marketplace add "$CODEX_FOREIGN_FIXTURE" --json >"$TMPROOT/codex-seed.log" 2>&1 \ + && ok "codex: foreign same-name catalogue seeded" || bad "codex: foreign catalogue seed failed" +fi +if command -v copilot >/dev/null 2>&1; then + copilot plugin marketplace add "$COPILOT_FOREIGN_FIXTURE" >"$TMPROOT/copilot-seed.log" 2>&1 \ + && ok "copilot: foreign same-name catalogue seeded" || bad "copilot: foreign catalogue seed failed" +fi +node -e 'const fs=require("fs");const p=process.argv[1],r=process.argv[2];let s={};try{s=JSON.parse(fs.readFileSync(p,"utf8"))}catch{}s.enabledPlugins={...(s.enabledPlugins||{}),[r]:true};fs.writeFileSync(p,JSON.stringify(s)+"\n")' "$COPILOT_SETTINGS" "$REF" printf '%s\n' "$CURSOR_MINE" > "$CURSOR_MANIFEST" copilot_key() { node -e "const s=JSON.parse(require(\"fs\").readFileSync(process.argv[1],\"utf8\"));console.log(String(s.enabledPlugins && s.enabledPlugins[process.argv[2]]))" "$COPILOT_SETTINGS" "$REF" 2>/dev/null || echo unreadable } +codex_catalog_source() { + codex plugin marketplace list --json 2>/dev/null | node -e 'let s="";process.stdin.on("data",b=>s+=b);process.stdin.on("end",()=>{try{const m=JSON.parse(s).marketplaces.find(x=>x.name==="aidd-framework");console.log(m?.marketplaceSource?.source??"unproven")}catch{console.log("unreadable")}})' 2>/dev/null || echo unreadable +} + +copilot_catalog_source() { + node -e 'try{const s=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(s.extraKnownMarketplaces?.["aidd-framework"]?.source?.path??"unproven")}catch{console.log("unreadable")}' "$COPILOT_SETTINGS" 2>/dev/null || echo unreadable +} + still_theirs() { local step="$1" t for t in "${TOOLS[@]}"; do case "$t" in codex) grep -qxF "$CODEX_SECTION" "$CODEX_CONFIG" && ok "codex: the person's $REF is still enabled after $step" \ - || bad "codex: the person's $REF section is gone after $step" ;; + || bad "codex: the person's $REF section is gone after $step" + [[ "$(codex_catalog_source)" == "$CODEX_FOREIGN_FIXTURE" ]] \ + && ok "codex: foreign same-name catalogue source unchanged after $step" \ + || bad "codex: foreign same-name catalogue source changed after $step" + [[ "$(cat "$CODEX_CACHE_WITNESS" 2>/dev/null)" == "$CODEX_CACHE_MINE" ]] \ + && ok "codex: foreign marketplace cache bytes are unchanged after $step" \ + || bad "codex: foreign marketplace cache bytes changed or vanished after $step" ;; copilot) [[ "$(copilot_key)" == "true" ]] && ok "copilot: the person's $REF is still enabled after $step" \ - || bad "copilot: the person's $REF reads '$(copilot_key)' after $step" ;; + || bad "copilot: the person's $REF reads '$(copilot_key)' after $step" + [[ "$(copilot_catalog_source)" == "$COPILOT_FOREIGN_FIXTURE" ]] \ + && ok "copilot: foreign same-name catalogue source unchanged after $step" \ + || bad "copilot: foreign same-name catalogue source changed after $step" ;; cursor) [[ "$(cat "$CURSOR_MANIFEST" 2>/dev/null)" == "$CURSOR_MINE" ]] && ok "cursor: the person's plugin.json is untouched after $step" \ || bad "cursor: the person's plugin.json changed or vanished after $step" ;; esac diff --git a/cli/scripts/smoke-tools.sh b/cli/scripts/smoke-tools.sh index da217b49c..c42275f90 100755 --- a/cli/scripts/smoke-tools.sh +++ b/cli/scripts/smoke-tools.sh @@ -42,6 +42,27 @@ ok() { PASS=$((PASS+1)); echo " ✓ $1"; } bad() { FAIL=$((FAIL+1)); FAILURES+=("$1"$'\n'"${2:-}"); echo " ✗ $1"; } skip() { SKIP=$((SKIP+1)); echo " ~ $1"; } section() { echo; echo "=== $1 === [$(date +%H:%M:%S)]"; } +project_tree_hash() { + (cd "$1" && node -e ' + const fs = require("node:fs"); + const path = require("node:path"); + const hash = require("node:crypto").createHash("sha256"); + function walk(dir) { + for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0)) { + const absolute = path.join(dir, entry.name); + if (entry.isDirectory()) walk(absolute); + else if (entry.isFile()) { + hash.update(path.relative(process.cwd(), absolute)); + hash.update("\0"); + hash.update(fs.readFileSync(absolute)); + hash.update("\0"); + } + } + } + walk(process.cwd()); + process.stdout.write(hash.digest("hex")); + ' ) +} PARENTS=" plugin marketplace auth framework telemetry " # A parent one level deeper than PARENTS, so a covered key needs three words there, not two. @@ -180,14 +201,15 @@ section "update --check" out=$(cd "$ROOT" && node "$CLI" update --check 2>&1); rc=$? if [[ "$rc" -eq 0 || "$rc" -eq 1 ]]; then mark_covered "update"; ok "update --check (exit $rc)"; else bad "update crashed (exit $rc)" "$out"; fi -# Comparing the file list before and after is the only assertion that proves `--dry-run` -# wrote nothing. +# Compare exact file paths and contents before/after: `--dry-run` must write nothing. P_DRY=$(new_project) (cd "$P_DRY" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai claude --plugins none --yes >/dev/null 2>&1) -before_dry=$(cd "$P_DRY" && find . -type f | sort | md5) +before_dry=$(project_tree_hash "$P_DRY"); before_dry_rc=$? run "update --dry-run" "0|1" "" "$P_DRY" -- update --dry-run -after_dry=$(cd "$P_DRY" && find . -type f | sort | md5) -if [[ "$before_dry" == "$after_dry" ]]; then +after_dry=$(project_tree_hash "$P_DRY"); after_dry_rc=$? +if [[ "$before_dry_rc" -ne 0 || "$after_dry_rc" -ne 0 ]]; then + bad "--dry-run project tree hash failed" +elif [[ "$before_dry" == "$after_dry" ]]; then ok "--dry-run wrote nothing" else bad "--dry-run changed the project tree" @@ -278,8 +300,13 @@ if true; then run "setup --release (local source)" 0 "" "$P_REL" -- \ setup --source local --path "$FRAMEWORK_FIXTURE" --release v1.0.0 --ai claude --plugins none --yes for t in "${AI_TOOLS[@]}"; do - [[ -d "$BASE/.${t}" || ( "$t" == copilot && -d "$BASE/.github" ) ]] \ - && ok "$t dir present" || bad "$t dir missing after --ai all" + if [[ "$t" == copilot ]]; then + [[ ! -d "$BASE/.github" ]] \ + && ok "copilot declarative settings absent without native source proof" \ + || bad "copilot declarative settings written without native source proof" + else + [[ -d "$BASE/.${t}" ]] && ok "$t dir present" || bad "$t dir missing after --ai all" + fi done [[ -d "$BASE/.vscode" ]] && ok "vscode dir present" || bad "vscode dir missing" # Cursor is `installScope: "user"`, so its plugin files land under $HOME and never under @@ -362,10 +389,34 @@ if true; then (cd "$P_PLUG" && node "$CLI" setup --source local --path "$FRAMEWORK_FIXTURE" --ai all --plugins none --yes >/dev/null 2>&1) for t in "${AI_TOOLS[@]}"; do run "plugin install aidd-test → $t" 0 "" "$P_PLUG" -- plugin install aidd-test --tool "$t" --yes - run "plugin remove → $t" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool "$t" - # `--from` names the marketplace explicitly. - run "plugin install --from → $t" 0 "" "$P_PLUG" -- \ - plugin install aidd-test --tool "$t" --from aidd-framework --yes + # An absent host permits local removal with a warning; an available host must first + # prove its native source. Exercise the matching contract, never infer native absence. + if [[ "$t" == copilot ]] && command -v copilot >/dev/null 2>&1; then + copilot_project_before=$(project_tree_hash "$P_PLUG") + copilot_home_before=$(project_tree_hash "$HOME") + run "plugin remove → copilot (unproven source refusal)" 1 \ + "no recorded native catalogue source" "$P_PLUG" -- plugin remove aidd-test --tool copilot + copilot_project_after=$(project_tree_hash "$P_PLUG") + copilot_home_after=$(project_tree_hash "$HOME") + [[ -n "$copilot_project_before" && -n "$copilot_home_before" && \ + "$copilot_project_before" == "$copilot_project_after" && \ + "$copilot_home_before" == "$copilot_home_after" ]] \ + && ok "copilot refusal wrote no project or home bytes" \ + || bad "copilot refusal changed project or home bytes" + run "plugin install --from → copilot (no duplicate after refusal)" 1 \ + "already installed" "$P_PLUG" -- \ + plugin install aidd-test --tool copilot --from aidd-framework --yes + elif [[ "$t" == copilot ]]; then + run "plugin remove → copilot (missing host warns, local removal succeeds)" 0 \ + "copilot CLI not found on PATH" "$P_PLUG" -- plugin remove aidd-test --tool copilot + run "plugin install --from → copilot (local reinstall without host)" 0 "" "$P_PLUG" -- \ + plugin install aidd-test --tool copilot --from aidd-framework --yes + else + run "plugin remove → $t" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool "$t" + # `--from` names the marketplace explicitly. + run "plugin install --from → $t" 0 "" "$P_PLUG" -- \ + plugin install aidd-test --tool "$t" --from aidd-framework --yes + fi done run "plugin remove aidd-test (claude)" 0 "" "$P_PLUG" -- plugin remove aidd-test --tool claude @@ -547,14 +598,14 @@ has_subcommands() { } leaves_under() { - local path=("$@") name + local name # `cut -d'|'`: commander prints an alias as `update|upgrade`, one command with two names. - for name in $(node "$CLI" "${path[@]}" --help 2>/dev/null | awk '/^Commands:/{f=1;next} f && /^ [a-z]/{print $1}' | cut -d'|' -f1); do + for name in $(node "$CLI" "$@" --help 2>/dev/null | awk '/^Commands:/{f=1;next} f && /^ [a-z]/{print $1}' | cut -d'|' -f1); do [[ "$name" == "help" ]] && continue - if has_subcommands "${path[@]}" "$name"; then - leaves_under "${path[@]}" "$name" + if has_subcommands "$@" "$name"; then + leaves_under "$@" "$name" else - echo "${path[*]} $name" | sed 's/^ *//' + echo "$* $name" | sed 's/^ *//' fi done } diff --git a/cli/src/contexts/distribution/application/marketplace-add-use-case.ts b/cli/src/contexts/distribution/application/marketplace-add-use-case.ts index 456ca7e4e..986bcffe5 100644 --- a/cli/src/contexts/distribution/application/marketplace-add-use-case.ts +++ b/cli/src/contexts/distribution/application/marketplace-add-use-case.ts @@ -7,7 +7,15 @@ import { import type { Prompter } from "../../../kernel/ports/prompter.js"; import type { MarketplaceScope } from "../../../kernel/scope.js"; import type { PluginSource } from "../../../kernel/source.js"; -import type { MarketplaceRemoveUseCase } from "../../framework/application/flows/marketplace-remove-use-case.js"; +import type { + MarketplaceRemoveOptions, + MarketplaceRemoveResult, +} from "../../framework/application/flows/marketplace-remove-use-case.js"; + +export interface MarketplaceRemover { + execute(options: MarketplaceRemoveOptions): Promise; +} + import { FRAMEWORK_MARKETPLACE_NAME, Marketplace } from "../domain/marketplace.js"; import type { MarketplaceRegistry } from "../domain/ports/marketplace-registry.js"; import type { MarketplaceTrustStore } from "../domain/ports/marketplace-trust-store.js"; @@ -32,7 +40,7 @@ export class MarketplaceAddUseCase { private readonly trustStore: MarketplaceTrustStore, private readonly resolveMarketplace: ResolveMarketplaceUseCase, private readonly prompter: Prompter, - private readonly removeUseCase: MarketplaceRemoveUseCase + private readonly removeUseCase: MarketplaceRemover ) {} async execute(options: MarketplaceAddOptions): Promise { @@ -69,7 +77,7 @@ export class MarketplaceAddUseCase { const found = existing.find((m) => m.name === name); if (!found) return; if (!overwrite) throw new MarketplaceAlreadyRegisteredError(name); - await this.removeUseCase.execute({ name, projectRoot, autoConfirm: true }); + await this.removeUseCase.execute({ name, projectRoot, autoConfirm: true, scope: found.scope }); } private async ensureTrust(options: MarketplaceAddOptions): Promise { diff --git a/cli/src/contexts/framework/application/clean-use-case.ts b/cli/src/contexts/framework/application/clean-use-case.ts index 0e59ccdb6..f9c53fd2d 100644 --- a/cli/src/contexts/framework/application/clean-use-case.ts +++ b/cli/src/contexts/framework/application/clean-use-case.ts @@ -19,9 +19,11 @@ import { resolveHomeDir } from "../../../kernel/reading/home-dir.js"; import type { MarketplaceScope } from "../../../kernel/scope.js"; import type { AiToolId, ToolId } from "../../../kernel/tool.js"; import { isAiToolId } from "../../../kernel/tool.js"; +import { FRAMEWORK_MARKETPLACE_NAME } from "../../distribution/domain/marketplace.js"; import type { MarketplaceRegistry } from "../../distribution/domain/ports/marketplace-registry.js"; import type { HostMarketplaceRegistryReader } from "../../tools/domain/ports/host-marketplace-registry-reader.js"; import type { HostPluginRegistryReader } from "../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../tools/domain/ports/native-plugin-activator.js"; import { machineLocalFilesOf, @@ -36,13 +38,23 @@ import type { InstalledPlugin } from "../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../domain/ports/user-source-references.js"; import type { GitignoreUseCase } from "./gitignore-use-case.js"; +import { + assertNoForeignNativeRefs, + inspectNativeMarketplaceSource, +} from "./ownership/native-marketplace-source-proof.js"; +import { + detachNativePluginRefs, + machineNativePluginClaim, +} from "./ownership/native-plugin-ownership.js"; +import { assertProjectMcpEntriesRemovable } from "./ownership/project-plugin-cleanup.js"; +import { detachUserPlugin } from "./ownership/user-plugin-ownership.js"; import { deletePluginFilesForTool } from "./plugin/plugin-helpers.js"; import { bestEffortNativeCall } from "./shared/best-effort-native-call.js"; import { purgeAllNativeCaches, type UndoneToolRegistrations, } from "./shared/purge-native-marketplace-cache.js"; -import { removeProjectHooks } from "./shared/remove-project-hooks.js"; +import { assertProjectHooksRemovable, removeProjectHooks } from "./shared/remove-project-hooks.js"; import { resolveUninstallScopeOrder } from "./shared/resolve-uninstall-scope.js"; import { describeGuardedPluginRefMessage, @@ -54,7 +66,7 @@ import { } from "./shared/shared-source-reference-support.js"; import { userScopeFilesSafeToDelete } from "./shared/user-scope-plugin-files.js"; -/** What dropping this project's own reference to the shared source found — `undefined` only +/** What reading this project's shared source reference found — `undefined` only * when there is nothing to guard on at all: the port is absent, or no shared, machine-scope * marketplace is registered locally. `otherProjects` is read regardless of whether this * project's own claim was there to drop, since another project's claim is a fact worth @@ -62,6 +74,7 @@ import { userScopeFilesSafeToDelete } from "./shared/user-scope-plugin-files.js" * per tool into that tool's `hostName` — never the alias, which a host never learns. */ interface SharedSourceReferenceOutcome { readonly alias: string; + readonly resolvedRoot: string; readonly otherProjects: readonly string[]; } @@ -134,7 +147,9 @@ export class CleanUseCase { private readonly hostPluginRegistries: ReadonlyMap< AiToolId, HostPluginRegistryReader - > = new Map() + > = new Map(), + private readonly userManifestRepo?: ManifestRepository, + private readonly nativeSources: ReadonlyMap = new Map() ) {} async execute(options: CleanOptions): Promise { @@ -147,10 +162,26 @@ export class CleanUseCase { const preview = await this.buildPreview(manifest, home, options.projectRoot); const dryRunResult = await this.confirmOrDryRun(options, preview); if (dryRunResult !== null) return dryRunResult; - // Decremented exactly once per run, before the per-tool loop: the shared source's reference - // count is a project-level fact, and claude, codex and copilot can each carry their own ref, - // so decrementing inside that loop would drop this project's claim once per tool. - const sharedSourceOutcome = await this.dropSharedSourceReference(options.projectRoot); + await this.assertNativeSourcesUnchanged(manifest, options.projectRoot); + for (const toolId of manifest.getInstalledToolIds()) { + if (!isAiToolId(toolId)) continue; + for (const plugin of manifest.getPlugins(toolId)) { + if (projectHooksFileOf(toolId) !== undefined) { + await assertProjectHooksRemovable(this.fs, plugin, toolId, options.projectRoot); + } + await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, options.projectRoot); + } + } + // Read the other projects before host cleanup, but keep this project's claim until every + // local cleanup step succeeds. A failed file or gitignore cleanup must not free the source. + const sharedSourceOutcome = await this.withSharedSourceClaims( + options.projectRoot, + async (references, alias, resolvedRoot) => ({ + alias, + resolvedRoot, + otherProjects: await otherProjectsReferencing(references, resolvedRoot), + }) + ); // Undoing a host's own registration must happen before any of the rest: the tool's CLI // resolves the marketplace name against the built tree under `.aidd/cache/`, which // `removeAiddState` deletes next, and a host may refuse to unregister a source that is gone. @@ -169,9 +200,76 @@ export class CleanUseCase { await this.removeAiddState(options.projectRoot); // Exactly what the pipeline added on install, never a subset of it. await this.gitignoreUseCase.remove(options.projectRoot, aiddGitignoreEntries(manifest)); + // One project-level decrement, only after the local projection has been cleaned successfully. + await this.dropSharedSourceReference(sharedSourceOutcome?.resolvedRoot); + const nativeRefs = new Map(); + for (const toolId of manifest.getInstalledToolIds()) { + const refs = manifest.getNativeRegistrations(toolId)?.pluginRefs; + if (refs !== undefined && refs.length > 0) nativeRefs.set(toolId, refs); + } + await detachNativePluginRefs(this.userManifestRepo, this.fs, options.projectRoot, nativeRefs); + for (const toolId of manifest.getInstalledToolIds()) { + if (!isAiToolId(toolId)) continue; + for (const plugin of manifest.getPlugins(toolId)) { + if (plugin.scope === "user") { + await detachUserPlugin( + this.userManifestRepo, + this.fs, + toolId, + plugin.name, + options.projectRoot + ); + } + } + } return { dryRun: false, manifestFound: true, preview, fileCount: deleted }; } + /** A project clean must not decrement shared claims before proving the host names it may undo. */ + private async assertNativeSourcesUnchanged( + manifest: Manifest, + projectRoot: string + ): Promise { + const projectMarketplaces = (await this.marketplaceRegistry?.list(projectRoot)) ?? []; + const machineManifest = await this.userManifestRepo?.load(); + for (const toolId of manifest.getInstalledToolIds()) { + if (!isAiToolId(toolId)) continue; + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + const activator = this.activators.get(registrations.binary); + if (activator === undefined || !activator.isAvailable()) continue; + for (const registration of registrations.marketplaces) { + if ( + projectMarketplaces.find((marketplace) => marketplace.name === registration.alias) + ?.scope !== "project" + ) { + continue; + } + const proof = await inspectNativeMarketplaceSource( + this.nativeSources.get(toolId), + projectRoot, + registration + ); + if (proof.status !== "owned") { + throw new Error( + proof.reason ?? + `${toolId}: catalogue '${registration.hostName}' is absent on the host; clean refused until manual reconciliation.` + ); + } + const machineRefs = + machineManifest + ?.getNativeRegistrations(toolId) + ?.pluginClaims?.map((claim) => claim.ref) ?? []; + await assertNoForeignNativeRefs( + this.hostPluginRegistries.get(toolId), + registration.hostName, + new Set([...registrations.pluginRefs, ...machineRefs]), + projectRoot + ); + } + } + } + // `config.json` is the committed telemetry switch: a file clean did not write, so clean never // removes it. Everything AIDD did write must go before the emptiness check, or its own presence // blocks a removal that should happen — the registry `marketplace add` writes included. @@ -306,36 +404,57 @@ export class CleanUseCase { ); return false; } - return bestEffortNativeCall( - this.logger, - () => activator.removeMarketplace(hostName, marketplace.scope), - `${binary} marketplace remove '${hostName}'` - ); + const removeProjectRegistration = async (): Promise => { + if (pluginEnablementIsMachineGlobal(toolId)) { + const machine = await this.userManifestRepo?.load(); + if (machine === undefined || machine === null) { + if (marketplace.name !== FRAMEWORK_MARKETPLACE_NAME) { + this.logger.warn( + `${binary}: '${hostName}' is machine-global but no user manifest can prove no other project still uses it — left registered for manual cleanup.` + ); + return false; + } + } + const claims = + machine + ?.getNativeRegistrations(toolId) + ?.pluginClaims?.filter((claim) => claim.ref.endsWith(`@${hostName}`)) ?? []; + if (claims.length > 0) { + const others = [ + ...new Set( + claims.flatMap((claim) => + claim.dependents.filter((dependent) => dependent !== projectRoot) + ) + ), + ]; + this.logger.warn( + `${binary}: '${hostName}' carries AIDD-owned machine plugin refs — left registered for explicit user-scope removal.${others.length > 0 ? ` Still needed by ${others.join(", ")}.` : ""}` + ); + return false; + } + } + return bestEffortNativeCall( + this.logger, + () => activator.removeMarketplace(hostName, marketplace.scope), + `${binary} marketplace remove '${hostName}'` + ); + }; + return this.userManifestRepo?.withExclusiveAccess === undefined + ? removeProjectRegistration() + : this.userManifestRepo.withExclusiveAccess(removeProjectRegistration); } - /** Decrements this project's own claim exactly once per `clean` run, independent of how many - * tools' registrations name it: the count in `references.json` is per project, never per tool. - * Never reads a "current" CLI version to decide which key to touch, so a self-update between - * the `sync` that wrote the reference and this `clean` cannot strand it. `undefined` only when - * the port was never wired in, or no shared marketplace is registered locally — never merely - * because this project's own claim was already missing. */ - private async dropSharedSourceReference( - projectRoot: string - ): Promise { - return this.withSharedSourceClaims( - projectRoot, - async (userSourceReferences, alias, resolvedRoot) => { - // A no-op when this project's own registry never held the shared entry, which must never - // collapse into "no other projects": another project's claim is worth guarding on - // regardless, so `otherProjects` is always read in full. - await userSourceReferences.removeReference(resolvedRoot); - const otherProjects = await otherProjectsReferencing(userSourceReferences, resolvedRoot); - return { alias, otherProjects }; - } + /** Decrements this project's claim exactly once after local cleanup. The resolved root was + * captured before `.aidd/` and its local marketplace registry were deleted. */ + private async dropSharedSourceReference(resolvedRoot: string | undefined): Promise { + const references = this.userSourceReferences; + if (references === undefined || resolvedRoot === undefined) return; + await toleratingUnreadableSourceReferences(this.logger, undefined, () => + references.removeReference(resolvedRoot) ); } - /** Shared preamble behind `dropSharedSourceReference` and `previewSharedSourceOtherProjects`: + /** Shared preamble behind the cleanup read and `previewSharedSourceOtherProjects`: * `undefined` when the port was never wired in or no shared, machine-scope registration exists * to act on. `action` alone decides whether the run only reads or also writes. */ private async withSharedSourceClaims( @@ -417,6 +536,27 @@ export class CleanUseCase { registrations: NativeRegistrations, sharedSourceOutcome: SharedSourceReferenceOutcome | undefined ): Promise { + const claim = await machineNativePluginClaim(this.userManifestRepo, toolId, ref); + if (claim !== undefined) { + const others = claim.dependents.filter((dependent) => dependent !== projectRoot); + this.logger.warn( + `${binary}: '${ref}' is an AIDD-owned machine plugin ref — left enabled; project claim detached after local clean.${others.length > 0 ? ` Still needed by ${others.join(", ")}.` : ""}` + ); + return; + } + if (pluginEnablementIsMachineGlobal(toolId)) { + const sharedHostName = registrations.marketplaces.find( + (registration) => registration.alias === sharedSourceOutcome?.alias + )?.hostName; + const otherProjects = + sharedHostName !== undefined && ref.endsWith(`@${sharedHostName}`) + ? (sharedSourceOutcome?.otherProjects ?? []) + : []; + this.logger.warn( + `${binary}: '${ref}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.${otherProjects.length > 0 ? ` Still needed by ${otherProjects.join(", ")}.` : ""}` + ); + return; + } const guardMessage = this.describeGuardedPluginRef( binary, toolId, @@ -428,7 +568,40 @@ export class CleanUseCase { this.logger.warn(guardMessage); return; } + const sourceRegistration = registrations.marketplaces.find((registration) => + ref.endsWith(`@${registration.hostName}`) + ); + if (sourceRegistration === undefined) { + this.logger.warn(`${binary}: '${ref}' has no recorded host catalogue — left enabled.`); + return; + } + const proof = await inspectNativeMarketplaceSource( + isAiToolId(toolId) ? this.nativeSources.get(toolId) : undefined, + projectRoot, + sourceRegistration + ); + if (proof.status !== "owned") { + this.logger.warn( + `${binary}: '${ref}' left enabled because its current host catalogue is unproven. ${proof.reason ?? "Reconcile manually."}` + ); + return; + } const reader = isAiToolId(toolId) ? this.hostPluginRegistries.get(toolId) : undefined; + const machineRefs = + (await this.userManifestRepo?.load()) + ?.getNativeRegistrations(toolId) + ?.pluginClaims?.map((machineClaim) => machineClaim.ref) ?? []; + try { + await assertNoForeignNativeRefs( + reader, + sourceRegistration.hostName, + new Set([...registrations.pluginRefs, ...machineRefs]), + projectRoot + ); + } catch (error) { + this.logger.warn(`${binary}: '${ref}' left enabled; ${String(error)}`); + return; + } const manifestScope = this.manifestScopeForRef(manifest, toolId, registrations, ref); const order = await resolveUninstallScopeOrder(reader, ref, projectRoot, manifestScope); let lastMessage = ""; @@ -535,7 +708,7 @@ export class CleanUseCase { if (projectHooksFileOf(toolId) === undefined || !isAiToolId(toolId)) return 0; let count = 0; for (const plugin of manifest.getPlugins(toolId)) { - if (await removeProjectHooks(this.fs, plugin.name, toolId, projectRoot)) count++; + if (await removeProjectHooks(this.fs, plugin, toolId, projectRoot)) count++; } return count; } @@ -624,6 +797,7 @@ export class CleanUseCase { ): Promise { let count = 0; for (const plugin of manifest.getPlugins(toolId)) { + if (plugin.scope === "user") continue; const files = await this.filesSafeToDelete(plugin, toolId); const deleted = await deletePluginFilesForTool( files, diff --git a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts index 37f138110..e02784dd0 100644 --- a/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts +++ b/cli/src/contexts/framework/application/clean/clean-user-scope-use-case.ts @@ -1,4 +1,5 @@ import { join } from "node:path"; +import { ActiveMachineDependentsError } from "../../../../kernel/errors.js"; import { USER_SOURCE_REFERENCES_FILENAME, userBuiltCacheRoot } from "../../../../kernel/paths.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; @@ -9,12 +10,18 @@ import { type AiToolId, isAiToolId, type ToolId } from "../../../../kernel/tool. import { FRAMEWORK_MARKETPLACE_NAME } from "../../../distribution/domain/marketplace.js"; import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js"; +import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; import { nativeActivationOf } from "../../../tools/domain/registry.js"; import type { NativeRegistrations } from "../../domain/manifest/native-registrations.js"; import type { Manifest } from "../../domain/manifest.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; +import { + assertNoForeignNativeRefs, + inspectNativeMarketplaceSource, +} from "../ownership/native-marketplace-source-proof.js"; import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; import { bestEffortNativeCall } from "../shared/best-effort-native-call.js"; import { resolveCacheCandidate } from "../shared/purge-declared-cache.js"; @@ -22,10 +29,7 @@ import { purgeAllNativeCaches, type UndoneToolRegistrations, } from "../shared/purge-native-marketplace-cache.js"; -import { - describeFullRemovalInstruction, - toleratingUnreadableSourceReferences, -} from "../shared/shared-source-reference-support.js"; +import { describeFullRemovalInstruction } from "../shared/shared-source-reference-support.js"; import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js"; export interface CleanUserScopeOptions { @@ -39,6 +43,7 @@ export interface CleanUserScopeOptions { export interface CleanUserScopePreview { toolIds: readonly ToolId[]; + activePluginDependents: readonly string[]; /** Every version directory found under `userConfigDir()/cache/built/` — read structurally, * never trusted from any one tool's own manifest entry. */ builtVersions: readonly string[]; @@ -82,18 +87,48 @@ export class CleanUserScopeUseCase { private readonly homeDir: () => string = resolveHomeDir, /** Absent reports no referencing project at all rather than guessing one. */ private readonly userSourceReferences?: UserSourceReferences, - private readonly prompter?: Prompter + private readonly prompter?: Prompter, + private readonly nativeSources: ReadonlyMap< + AiToolId, + NativeMarketplaceSourceReader + > = new Map(), + private readonly hostPluginRegistries: ReadonlyMap< + AiToolId, + HostPluginRegistryReader + > = new Map() ) {} async execute(options: CleanUserScopeOptions): Promise { + if (this.userManifestRepo.withExclusiveAccess !== undefined) { + return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(options)); + } + return this.executeLocked(options); + } + + private async executeLocked(options: CleanUserScopeOptions): Promise { const manifest = await this.userManifestRepo.load(); const manifestFound = manifest !== null; const preview = await this.buildPreview(manifest); if (!manifestFound) this.logger.info(this.describeNoUserRegistration(preview)); const dryRunResult = await this.confirmOrDryRun(options, preview, manifestFound); if (dryRunResult !== null) return dryRunResult; + if (preview.activePluginDependents.length > 0) { + throw new ActiveMachineDependentsError( + "user-scope plugin files", + preview.activePluginDependents + ); + } + if (preview.referencingProjects.length > 0) { + throw new ActiveMachineDependentsError( + "the shared framework source", + preview.referencingProjects + ); + } if (manifest !== null) { + await this.assertTrackedUserPluginFilesSafe(manifest); + this.assertNativeActivatorsAvailable(manifest); + await this.assertNativeSourcesProven(manifest, options.projectRoot); // Undoing a host's own registration must happen before any purge: a host's own CLI resolves // what it is unregistering against the built tree still on disk, and the purge below removes // exactly that tree. Absent a manifest there is nothing recorded to undo. @@ -115,6 +150,23 @@ export class CleanUserScopeUseCase { private async buildPreview(manifest: Manifest | null): Promise { return { toolIds: manifest?.getInstalledToolIds() ?? [], + activePluginDependents: [ + ...new Set( + manifest?.getInstalledToolIds().flatMap((toolId) => + isAiToolId(toolId) + ? [ + ...manifest + .getPlugins(toolId) + .filter((plugin) => plugin.scope === "user") + .flatMap((plugin) => plugin.dependents), + ...(manifest.getNativeRegistrations(toolId)?.pluginClaims ?? []).flatMap( + (claim) => claim.dependents + ), + ] + : [] + ) ?? [] + ), + ], builtVersions: await this.listBuiltVersions(), referencingProjects: await this.listReferencingProjects(), }; @@ -150,10 +202,28 @@ export class CleanUserScopeUseCase { private async listReferencingProjects(): Promise { if (this.userSourceReferences === undefined) return []; - const userSourceReferences = this.userSourceReferences; - return toleratingUnreadableSourceReferences(this.logger, [], () => - userSourceReferences.listAllReferencingProjects() - ); + return this.userSourceReferences.listAllReferencingProjects(); + } + + private async assertTrackedUserPluginFilesSafe(manifest: Manifest): Promise { + for (const toolId of manifest.getInstalledToolIds()) { + if (!isAiToolId(toolId)) continue; + for (const plugin of manifest.getPlugins(toolId)) { + if (plugin.scope !== "user") continue; + const safe = await userScopeFilesSafeToDelete( + this.fs, + this.logger, + plugin, + toolId, + this.homeDir() + ); + if (safe.size !== plugin.files.size) { + throw new Error( + `User clean refused: a tracked file of '${plugin.name}' escaped its user-scope boundary; canonical claims retained.` + ); + } + } + } } private async confirmOrDryRun( @@ -196,6 +266,79 @@ export class CleanUserScopeUseCase { return undone; } + private assertNativeActivatorsAvailable(manifest: Manifest): void { + for (const toolId of manifest.getInstalledToolIds()) { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + const activator = this.activators.get(registrations.binary); + if (activator === undefined || !activator.isAvailable()) + throw new Error( + `${this.describeBinaryAbsent(toolId, registrations)} User clean refused; canonical claims retained.` + ); + } + } + + private async assertNativeSourcesProven(manifest: Manifest, projectRoot: string): Promise { + for (const toolId of manifest.getInstalledToolIds()) { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + if ( + !isAiToolId(toolId) || + ((registrations.pluginClaims?.length ?? 0) > 0 && registrations.marketplaces.length === 0) + ) + throw new Error( + `${toolId}: native refs lack a canonical catalogue source; user clean refused.` + ); + const hostNames = new Set(); + for (const { hostName } of registrations.marketplaces) { + if (hostNames.has(hostName)) + throw new Error( + `${toolId}: ambiguous canonical host catalogue '${hostName}'; user clean refused.` + ); + hostNames.add(hostName); + } + const claims = registrations.pluginClaims ?? []; + const claimRefs = new Set(claims.map((claim) => claim.ref)); + if (claimRefs.size !== claims.length) + throw new Error(`${toolId}: duplicate canonical native ref claims; user clean refused.`); + const refsByHost = new Map>( + [...hostNames].map((hostName) => [hostName, new Set()]) + ); + for (const ref of new Set([...registrations.pluginRefs, ...claimRefs])) { + const matches = [...hostNames].filter((hostName) => ref.endsWith(`@${hostName}`)); + if (matches.length !== 1) + throw new Error( + `${toolId}: native ref '${ref}' lacks exactly one canonical catalogue; user clean refused.` + ); + refsByHost.get(matches[0])?.add(ref); + } + for (const registration of registrations.marketplaces) { + const proof = await inspectNativeMarketplaceSource( + this.nativeSources.get(toolId), + projectRoot, + registration + ); + if (proof.status !== "owned") + throw new Error(proof.reason ?? `${toolId}: catalogue source unproven.`); + const owned = new Set( + [...claimRefs].filter((ref) => ref.endsWith(`@${registration.hostName}`)) + ); + const hostRefs = await assertNoForeignNativeRefs( + this.hostPluginRegistries.get(toolId), + registration.hostName, + owned, + projectRoot + ); + for (const ref of refsByHost.get(registration.hostName) ?? []) { + if (hostRefs.get(ref)?.enabled !== true) + throw new Error( + `${toolId}: owned host ref '${ref}' is not enabled; user clean refused.` + ); + } + } + } + } + private async undoToolNativeRegistrations( toolId: ToolId, registrations: NativeRegistrations @@ -203,15 +346,20 @@ export class CleanUserScopeUseCase { const { binary } = registrations; const activator = this.activators.get(binary); if (activator === undefined || !activator.isAvailable()) { - this.logger.warn(this.describeBinaryAbsent(toolId, registrations)); - return undefined; + throw new Error( + `${this.describeBinaryAbsent(toolId, registrations)} User clean refused; canonical claims retained.` + ); } - for (const ref of registrations.pluginRefs) { - bestEffortNativeCall( + for (const { ref } of registrations.pluginClaims ?? []) { + const removed = bestEffortNativeCall( this.logger, () => activator.uninstallPlugin(ref, "user"), `${binary} plugin uninstall '${ref}'` ); + if (!removed) + throw new Error( + `${binary}: user clean refused after plugin uninstall '${ref}' failed; canonical claims retained.` + ); } const removedHostNames = new Set(); for (const { hostName } of registrations.marketplaces) { @@ -220,7 +368,11 @@ export class CleanUserScopeUseCase { () => activator.removeMarketplace(hostName, "user"), `${binary} marketplace remove '${hostName}'` ); - if (removed) removedHostNames.add(hostName); + if (!removed) + throw new Error( + `${binary}: user clean refused after marketplace remove '${hostName}' failed; canonical claims retained.` + ); + removedHostNames.add(hostName); } return removedHostNames; } @@ -233,7 +385,7 @@ export class CleanUserScopeUseCase { const base = `${binary}: registration left in place, the ${binary} CLI is not on the PATH. ` + `It would have unregistered ${registrations.marketplaces.length} marketplace(s) ` + - `and ${registrations.pluginRefs.length} plugin ref(s).`; + `and ${registrations.pluginClaims?.length ?? 0} plugin ref(s).`; if (!isAiToolId(toolId)) return base; const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(this.homeDir()); if (cacheRoot === undefined) return base; @@ -258,7 +410,7 @@ export class CleanUserScopeUseCase { toolId, this.homeDir() ); - await deletePluginFilesForTool(files, plugin.scope, toolId, projectRoot, this.fs); + await deletePluginFilesForTool(files, plugin.scope, toolId, projectRoot, this.fs, "user"); } } } @@ -288,8 +440,8 @@ export class CleanUserScopeUseCase { ); // The manifest's own repository is the single writer of `manifest.json` — deleting through // it, never a second path to the same file, is what keeps that true. - await this.userManifestRepo.delete(); await this.marketplaceRegistry.delete(projectRoot, FRAMEWORK_MARKETPLACE_NAME, "user"); + await this.userManifestRepo.delete(); } /** `cache/built/` and `cache/update-check.json` are this whitelist's only occupants of diff --git a/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts index 5f5f0fb22..3655045ad 100644 --- a/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts +++ b/cli/src/contexts/framework/application/flows/marketplace-remove-use-case.ts @@ -2,8 +2,8 @@ import { InvalidMarketplaceNameError, MarketplaceNotFoundError, } from "../../../../kernel/errors.js"; -import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Prompter } from "../../../../kernel/ports/prompter.js"; +import type { ToolId } from "../../../../kernel/tool.js"; import { AI_TOOL_IDS, type AiToolId } from "../../../../kernel/tool.js"; import { FRAMEWORK_MARKETPLACE_NAME, @@ -13,12 +13,13 @@ import type { MarketplaceRegistry } from "../../../distribution/domain/ports/mar import type { Manifest } from "../../domain/manifest.js"; import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; -import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; +import type { ProjectPluginCleanup } from "../ownership/project-plugin-cleanup.js"; export interface MarketplaceRemoveOptions { name: string; projectRoot: string; autoConfirm: boolean; + scope?: "project" | "user"; } export interface MarketplaceRemoveResult { @@ -34,7 +35,7 @@ interface OrphanRef { export class MarketplaceRemoveUseCase { constructor( - private readonly fs: FileWriter, + private readonly cleanup: ProjectPluginCleanup, private readonly manifestRepo: ManifestRepository, private readonly registry: MarketplaceRegistry, private readonly prompter: Prompter @@ -49,21 +50,40 @@ export class MarketplaceRemoveUseCase { `"${FRAMEWORK_MARKETPLACE_NAME}" is shared by every project on this machine and is not removed with \`aidd marketplace remove\` — it is removed with the framework itself, by \`aidd clean\`, once machine scope lands there.` ); } - const marketplace = await this.findOrThrow(options.projectRoot, options.name); + if (options.scope === "user") + throw new Error("User-scope marketplace removal requires UserMarketplaceRemoveUseCase."); + const marketplace = await this.findOrThrow(options.projectRoot, options.name, "project"); const manifest = await this.manifestRepo.load(); const orphans = manifest ? this.collectOrphans(manifest, options.name) : []; + const nativeRefs = new Map(); + if (manifest !== null) { + for (const { toolId, plugin } of orphans) { + const hostName = manifest + .getNativeRegistrations(toolId) + ?.marketplaces.find((m) => m.alias === plugin.marketplace)?.hostName; + if (hostName !== undefined) + nativeRefs.set(toolId, [...(nativeRefs.get(toolId) ?? []), `${plugin.name}@${hostName}`]); + } + } const cleanup = await this.shouldCleanup(orphans.length, options.autoConfirm); let removed = 0; if (cleanup && manifest) { removed = await this.removeOrphans(manifest, orphans, options.projectRoot); } await this.registry.delete(options.projectRoot, marketplace.name, marketplace.scope); + if (cleanup) { + await this.cleanup.detachClaims(options.projectRoot, nativeRefs, orphans); + } return { marketplace, removedPluginCount: removed, orphanCount: orphans.length }; } - private async findOrThrow(projectRoot: string, name: string): Promise { + private async findOrThrow( + projectRoot: string, + name: string, + scope: "project" | "user" + ): Promise { const list = await this.registry.list(projectRoot); - const found = list.find((m) => m.name === name); + const found = list.find((m) => m.name === name && m.scope === scope); if (!found) throw new MarketplaceNotFoundError(name); return found; } @@ -90,7 +110,22 @@ export class MarketplaceRemoveUseCase { projectRoot: string ): Promise { for (const { toolId, plugin } of orphans) { - await deletePluginFilesForTool(plugin.files, plugin.scope, toolId, projectRoot, this.fs); + await this.cleanup.assertLocalIntegrationRemovable(toolId, plugin, projectRoot); + } + for (const { toolId, plugin } of orphans) { + await this.cleanup.removeLocalIntegration(toolId, plugin, projectRoot); + await this.cleanup.deleteLocalFiles(toolId, plugin, projectRoot); + const registrations = manifest.getNativeRegistrations(toolId); + const hostName = registrations?.marketplaces.find( + (m) => m.alias === plugin.marketplace + )?.hostName; + if (registrations !== undefined && hostName !== undefined) + manifest.setNativeRegistrations(toolId, { + ...registrations, + pluginRefs: registrations.pluginRefs.filter( + (ref) => ref !== `${plugin.name}@${hostName}` + ), + }); manifest.removePlugin(toolId, plugin.name); } await this.manifestRepo.save(manifest); diff --git a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts index 9bfcd82cc..458151b01 100644 --- a/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts +++ b/cli/src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts @@ -4,7 +4,13 @@ import { NativePluginCliError, UnreadableBuiltCatalogError, } from "../../../../kernel/errors.js"; -import { BUILT_CACHE_SUBDIR } from "../../../../kernel/paths.js"; +import { + BUILT_CACHE_SUBDIR, + parseBuiltMarketplaceDir, + parseUserBuiltMarketplaceDir, + samePath, + samePathSegment, +} from "../../../../kernel/paths.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Hasher } from "../../../../kernel/ports/hasher.js"; @@ -26,16 +32,28 @@ import { } from "../../../tools/domain/marketplace-source-conflict.js"; import type { HostMarketplaceRegistryReader } from "../../../tools/domain/ports/host-marketplace-registry-reader.js"; import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, +} from "../../../tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; -import { nativeActivationOf, resolvePluginsCapability } from "../../../tools/domain/registry.js"; +import { + nativeActivationOf, + pluginEnablementIsMachineGlobal, + resolvePluginsCapability, +} from "../../../tools/domain/registry.js"; import type { FrameworkBuildTarget } from "../../../translate/domain/build-target.js"; import type { NativeMarketplaceRegistration, NativeRegistrations, } from "../../domain/manifest/native-registrations.js"; -import type { Manifest } from "../../domain/manifest.js"; +import { Manifest } from "../../domain/manifest.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; +import { + inspectNativeMarketplaceSource, + sameNativeMarketplaceSource, +} from "../ownership/native-marketplace-source-proof.js"; import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js"; import { hostMarketplaceSourceConflict, @@ -81,12 +99,24 @@ export interface MarketplaceSyncSettingsOptions { interface ActivationOutcome { marketplaces: readonly NativeMarketplaceRegistration[]; + preservedMarketplaces: readonly NativeMarketplaceRegistration[]; + catalogClaims: readonly NativeMarketplaceRegistration[]; pluginRefs: readonly string[]; /** A marketplace whose build failed was warned about and left unregistered this run — the * host's own registration for it is wherever it was before. */ buildFailed: boolean; } +interface NativeCatalogProof { + readonly canonical: boolean; + readonly hostReadable: boolean; + readonly sourceStatus: "absent" | "owned" | "unproven"; + readonly source?: NativeMarketplaceSource; + readonly sourceReason?: string; + readonly foreignRef?: string; + readonly unreadable?: string; +} + export interface MarketplaceSyncSettingsResult { /** Tools whose own CLI actually ran, whether or not every step inside it succeeded. */ activated: readonly ToolId[]; @@ -148,10 +178,24 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { private readonly hostPluginRegistries: ReadonlyMap< AiToolId, HostPluginRegistryReader - > = new Map() + > = new Map(), + private readonly userManifestRepo?: ManifestRepository, + private readonly nativeSources: ReadonlyMap = new Map() ) {} async execute(options: MarketplaceSyncSettingsOptions): Promise { + if ( + (options.scope ?? "project") === "project" && + this.userManifestRepo?.withExclusiveAccess !== undefined + ) { + return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(options)); + } + return this.executeLocked(options); + } + + private async executeLocked( + options: MarketplaceSyncSettingsOptions + ): Promise { const { projectRoot } = options; const manifestRepo = options.manifestRepo ?? this.manifestRepo; const scope = options.scope ?? "project"; @@ -168,14 +212,16 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ? recreatedMarketplaces : recreatedMarketplaces.filter((m) => options.marketplaceNames?.includes(m.name)); if (marketplaces.length === 0) return EMPTY_RESULT; - // A user-scope run has no project-scope manifest for a later `clean` to decrement this - // claim from. - if (scope !== "user") await this.recordSharedSourceReference(projectRoot, marketplaces); const toolIds = this.selectToolIds(manifest, options.toolIds); let anyToolUpdated = false; // A user-scope run lands nothing under `projectRoot`, so no project settings file mirrors it. if (scope === "project") { for (const toolId of toolIds) { + if ( + resolvePluginsCapability(toolId)?.marketplaceSettings + ?.declarativePluginActivationRequiresNativeProof === true + ) + continue; if (await this.syncTool(toolId, projectRoot, manifest, marketplaces)) anyToolUpdated = true; } } @@ -187,6 +233,35 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolIds, scope ); + let declarativeChanged = false; + if (scope === "project") { + for (const [toolId, outcome] of activation.outcomes) { + const settings = resolvePluginsCapability(toolId)?.marketplaceSettings; + if (settings?.declarativePluginActivationRequiresNativeProof !== true) continue; + const hostNames = new Set(outcome.marketplaces.map((entry) => entry.hostName)); + const provenRefs = outcome.pluginRefs.filter((ref) => + [...hostNames].some((hostName) => ref.endsWith(`@${hostName}`)) + ); + if ( + provenRefs.length > 0 && + (await this.syncEnabledPluginsFile( + toolId, + projectRoot, + manifest, + marketplaces, + settings, + provenRefs + )) + ) + declarativeChanged = true; + } + } + if (declarativeChanged) await manifestRepo.save(manifest); + // A refused or unproven host registration cannot establish a new shared-source claim. + // User-scope runs have no project manifest for a later `clean` to decrement it. + if (scope !== "user") { + await this.recordSharedSourceReference(projectRoot, marketplaces, activation.outcomes); + } const wroteHashes = await this.recordWhatActivationWrote(projectRoot, manifest, [ ...activation.outcomes.keys(), ]); @@ -196,6 +271,13 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { options.marketplaceNames !== undefined ); if (wroteHashes || wroteRegistrations) await manifestRepo.save(manifest); + await this.recordMachinePluginClaims( + manifest, + activation.outcomes, + marketplaces, + scope, + scope === "project" ? projectRoot : undefined + ); if (options.recreateFrameworkIfMissing === true && scope === "project") { await this.purgeStaleProjectCache(projectRoot, marketplaces, activation); } @@ -271,18 +353,24 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { } /** - * Refreshed on every run that finds the shared source registered, not only the one that had to - * recreate it: this project's own reference is still missing the first time its `sync` runs here. + * Refreshed only after native activation proves that the host uses the shared source. */ private async recordSharedSourceReference( projectRoot: string, - marketplaces: readonly Marketplace[] + marketplaces: readonly Marketplace[], + outcomes: ReadonlyMap ): Promise { if (this.userSourceReferences === undefined || this.currentVersionProvider === undefined) { return; } const framework = marketplaces.find((m) => frameworkSourceIsShared(m.name, m.scope)); if (framework === undefined) return; + if ( + ![...outcomes.values()].some((outcome) => + outcome.marketplaces.some((registration) => registration.alias === framework.name) + ) + ) + return; await this.recordReferenceForRoot(projectRoot); } @@ -363,8 +451,9 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { const binary = this.nativeActivationBinary(toolId); if (binary === undefined) continue; const existing = manifest.getNativeRegistrations(toolId); - const touchedAliases = new Set(outcome.marketplaces.map((m) => m.alias)); - const touchedHostNames = new Set(outcome.marketplaces.map((m) => m.hostName)); + const projected = [...outcome.marketplaces, ...outcome.preservedMarketplaces]; + const touchedAliases = new Set(projected.map((m) => m.alias)); + const touchedHostNames = new Set(projected.map((m) => m.hostName)); const retainedMarketplaces = narrowed ? (existing?.marketplaces ?? []).filter((m) => !touchedAliases.has(m.alias)) : []; @@ -377,10 +466,15 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ) ) : []; + const preservedHostNames = new Set(outcome.preservedMarketplaces.map((m) => m.hostName)); + const preservedRefs = (existing?.pluginRefs ?? []).filter((ref) => + [...preservedHostNames].some((hostName) => ref.endsWith(`@${hostName}`)) + ); const registrations: NativeRegistrations = { binary, - marketplaces: [...retainedMarketplaces, ...outcome.marketplaces], - pluginRefs: [...new Set([...retainedRefs, ...outcome.pluginRefs])], + marketplaces: [...retainedMarketplaces, ...projected], + pluginRefs: [...new Set([...retainedRefs, ...preservedRefs, ...outcome.pluginRefs])], + ...(existing?.pluginClaims === undefined ? {} : { pluginClaims: existing.pluginClaims }), }; if (nativeRegistrationsEqual(existing, registrations)) continue; manifest.setNativeRegistrations(toolId, registrations); @@ -443,8 +537,10 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { // // Each step is independently best-effort: one failing plugin or marketplace must warn and // let the others through, never abort the whole activation. - const registeredMarketplaces: NativeMarketplaceRegistration[] = []; const ownMarketplaces: NativeMarketplaceRegistration[] = []; + const preservedMarketplaces: NativeMarketplaceRegistration[] = []; + const catalogClaims: NativeMarketplaceRegistration[] = []; + const addedHostNames = new Set(); const recorded = manifest.getNativeRegistrations(toolId)?.marketplaces; let buildFailed = false; for (const marketplace of marketplaces) { @@ -453,29 +549,90 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId, marketplace, projectRoot, - warnings + warnings, + catalogClaims ); if (registration.outcome === "build-failed") buildFailed = true; - const entry = { alias: marketplace.name, hostName: registration.hostName }; - registeredMarketplaces.push(entry); + if (registration.addedThisRun === true) addedHostNames.add(registration.hostName); + const entry = { + alias: marketplace.name, + hostName: registration.hostName, + ...(registration.provenance === undefined ? {} : { provenance: registration.provenance }), + }; const earlier = recorded?.find((m) => m.alias === marketplace.name); - if (registration.outcome === "registered") ownMarketplaces.push(entry); - else if (earlier !== undefined) ownMarketplaces.push(earlier); + if (registration.outcome === "registered" && registration.claimable) + ownMarketplaces.push(entry); + if (registration.claimable) catalogClaims.push(entry); + else if (earlier !== undefined) { + const proof = await this.nativeCatalogProof(toolId, earlier.hostName, projectRoot); + if ( + proof.canonical && + proof.hostReadable && + proof.sourceStatus === "owned" && + proof.foreignRef === undefined + ) + ownMarketplaces.push(earlier); + else preservedMarketplaces.push(earlier); + } } if (!activator.enablesPlugins()) - return { marketplaces: ownMarketplaces, pluginRefs: [], buildFailed }; - this.bestEffort(() => activator.upgradeMarketplaces(), "upgrade marketplaces", warnings); - const hostNameByAlias = new Map(registeredMarketplaces.map((m) => [m.alias, m.hostName])); - const refs = await this.refsThisProjectEnables( + return { + marketplaces: ownMarketplaces, + preservedMarketplaces, + catalogClaims, + pluginRefs: [], + buildFailed, + }; + const stillProven: NativeMarketplaceRegistration[] = []; + for (const registration of ownMarketplaces) { + const fresh = catalogClaims.find((claim) => claim.hostName === registration.hostName); + const proof = await this.nativeCatalogProof( + toolId, + registration.hostName, + projectRoot, + fresh + ); + if ( + !proof.canonical || + !proof.hostReadable || + proof.sourceStatus !== "owned" || + proof.foreignRef !== undefined + ) + continue; + stillProven.push(registration); + if (!addedHostNames.has(registration.hostName)) + this.bestEffort( + () => activator.upgradeMarketplaces(registration.hostName), + `upgrade marketplace '${registration.hostName}'`, + warnings + ); + } + const hostNameByAlias = new Map(stillProven.map((m) => [m.alias, m.hostName])); + const { refsToEnable, alreadyOwnedRefs } = await this.refsThisProjectEnables( toolId, this.pluginRefsToEnable(toolId, manifest, marketplaces, hostNameByAlias), manifest, projectRoot ); - for (const ref of refs) { - this.bestEffort(() => activator.enablePlugin(ref, scope), `enable plugin '${ref}'`, warnings); + const enabledRefs = [...alreadyOwnedRefs]; + for (const ref of refsToEnable) { + if ( + this.bestEffort( + () => activator.enablePlugin(ref, scope), + `enable plugin '${ref}'`, + warnings + ) + ) { + enabledRefs.push(ref); + } } - return { marketplaces: ownMarketplaces, pluginRefs: refs, buildFailed }; + return { + marketplaces: ownMarketplaces, + preservedMarketplaces, + catalogClaims, + pluginRefs: enabledRefs, + buildFailed, + }; } private async refsThisProjectEnables( @@ -483,18 +640,103 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { refs: string[], manifest: Manifest, projectRoot: string - ): Promise { + ): Promise<{ refsToEnable: string[]; alreadyOwnedRefs: string[] }> { const hostRegistry = isAiToolId(toolId) ? this.hostPluginRegistries.get(toolId) : undefined; - if (hostRegistry === undefined) return refs; + if (hostRegistry === undefined) return { refsToEnable: refs, alreadyOwnedRefs: [] }; const ownRefs = manifest.getNativeRegistrations(toolId)?.pluginRefs; const onHost = (await hostRegistry.read(projectRoot)).refs; - return refs.filter((ref) => { - if (ownRefs?.includes(ref) === true || onHost?.get(ref)?.enabled !== true) return true; + const machine = await this.userManifestRepo?.load(); + const machineClaims = machine?.getNativeRegistrations(toolId)?.pluginClaims ?? []; + const refsToEnable: string[] = []; + const alreadyOwnedRefs: string[] = []; + for (const ref of refs) { + if (onHost?.get(ref)?.enabled !== true) { + refsToEnable.push(ref); + continue; + } + if (machineClaims.some((claim) => claim.ref === ref)) { + alreadyOwnedRefs.push(ref); + continue; + } + if (ownRefs?.includes(ref) === true) { + if (!pluginEnablementIsMachineGlobal(toolId) || this.userManifestRepo === undefined) + alreadyOwnedRefs.push(ref); + continue; + } this.logger.info( `${toolId}: '${ref}' was already enabled before this project asked for it — left as it is, and this project's clean will leave it enabled.` ); - return false; - }); + } + return { refsToEnable, alreadyOwnedRefs }; + } + + private async recordMachinePluginClaims( + project: Manifest, + outcomes: ReadonlyMap, + marketplaces: readonly Marketplace[], + scope: MarketplaceScope, + projectRoot?: string + ): Promise { + if (this.userManifestRepo === undefined) return; + const machine = (await this.userManifestRepo.load()) ?? Manifest.create(); + const root = projectRoot === undefined ? undefined : await this.fs.realpath(projectRoot); + let changed = false; + for (const [toolId, outcome] of outcomes) { + const machineRefs = + scope === "user" || pluginEnablementIsMachineGlobal(toolId) ? outcome.pluginRefs : []; + const machineMarketplaces = outcome.catalogClaims.filter((registration) => + marketplaces.some( + (marketplace) => + marketplace.name === registration.alias && + (marketplace.scope === "user" || pluginEnablementIsMachineGlobal(toolId)) + ) + ); + if (machineRefs.length === 0 && machineMarketplaces.length === 0) continue; + if (!machine.hasTool(toolId)) { + const version = project.getToolVersion(toolId); + if (version === undefined) continue; + machine.addTool(toolId, version, []); + } + const existing = machine.getNativeRegistrations(toolId); + const registered = [...(existing?.marketplaces ?? [])]; + for (const registration of machineMarketplaces) { + const index = registered.findIndex( + (entry) => entry.alias === registration.alias && entry.hostName === registration.hostName + ); + if (index >= 0) { + if ( + registration.provenance === undefined || + (registered[index].provenance !== undefined && + sameNativeMarketplaceSource(registered[index].provenance, registration.provenance)) + ) + continue; + registered[index] = registration; + } else registered.push(registration); + changed = true; + } + const claims = [...(existing?.pluginClaims ?? [])].map((claim) => ({ + ref: claim.ref, + dependents: [...claim.dependents], + })); + for (const ref of machineRefs) { + const claim = claims.find((candidate) => candidate.ref === ref); + if (claim === undefined) { + claims.push({ ref, dependents: root === undefined ? [] : [root] }); + changed = true; + } else if (root !== undefined && !claim.dependents.includes(root)) { + claim.dependents.push(root); + changed = true; + } + } + if (changed) + machine.setNativeRegistrations(toolId, { + binary: existing?.binary ?? this.nativeActivationBinary(toolId) ?? toolId, + marketplaces: registered, + pluginRefs: existing?.pluginRefs ?? [], + pluginClaims: claims, + }); + } + if (changed) await this.userManifestRepo.save(machine); } private bestEffort(action: () => void, label: string, warnings: string[]): boolean { @@ -531,7 +773,61 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { return refs; } - // Native tools must read the BUILT (transformed) tree, not the raw Claude-format source. + private async nativeCatalogProof( + toolId: ToolId, + hostName: string, + projectRoot: string, + fresh?: NativeMarketplaceRegistration + ): Promise { + if (!isAiToolId(toolId)) + return { canonical: false, hostReadable: false, sourceStatus: "unproven" }; + const machine = await this.userManifestRepo?.load(); + const registrations = machine?.getNativeRegistrations(toolId); + const project = await this.manifestRepo.load(); + const localRegistration = pluginEnablementIsMachineGlobal(toolId) + ? undefined + : project + ?.getNativeRegistrations(toolId) + ?.marketplaces.find((entry) => entry.hostName === hostName); + const canonical = + fresh !== undefined || + registrations?.marketplaces.some((entry) => entry.hostName === hostName) === true || + localRegistration !== undefined; + const registration = fresh ?? + registrations?.marketplaces.find((entry) => entry.hostName === hostName) ?? + localRegistration ?? { + alias: hostName, + hostName, + }; + const sourceProof = await inspectNativeMarketplaceSource( + this.nativeSources.get(toolId), + projectRoot, + registration + ); + const reader = this.hostPluginRegistries.get(toolId); + const base = { + canonical, + sourceStatus: sourceProof.status, + ...(sourceProof.current === undefined ? {} : { source: sourceProof.current }), + ...(sourceProof.reason === undefined ? {} : { sourceReason: sourceProof.reason }), + }; + if (reader === undefined) return { ...base, hostReadable: false }; + const reading = await reader.read(projectRoot); + if (reading.absent === true) return { ...base, hostReadable: true }; + if (reading.refs === undefined) + return { ...base, hostReadable: false, unreadable: reading.unreadable ?? reading.location }; + const claimed = new Set([ + ...(registrations?.pluginClaims ?? []).map((claim) => claim.ref), + ...(pluginEnablementIsMachineGlobal(toolId) + ? [] + : (project?.getNativeRegistrations(toolId)?.pluginRefs ?? [])), + ]); + const foreignRef = [...reading.refs.keys()].find( + (ref) => ref.endsWith(`@${hostName}`) && !claimed.has(ref) + ); + return { ...base, hostReadable: true, ...(foreignRef === undefined ? {} : { foreignRef }) }; + } + // Returns the host's own catalog name, never this project's local alias: a catalog this // project just built and cannot read back is not registered at all (see the throw below). private async registerMarketplace( @@ -539,10 +835,18 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { toolId: ToolId, marketplace: Marketplace, projectRoot: string, - warnings: string[] - ): Promise<{ hostName: string; outcome: "registered" | "refused" | "build-failed" }> { + warnings: string[], + freshClaims: readonly NativeMarketplaceRegistration[] + ): Promise<{ + hostName: string; + outcome: "registered" | "refused" | "build-failed"; + claimable: boolean; + addedThisRun?: boolean; + provenance?: NativeMarketplaceSource; + }> { const builtDir = await this.buildForTool(toolId, marketplace, projectRoot); - if (builtDir === null) return { hostName: marketplace.name, outcome: "build-failed" }; + if (builtDir === null) + return { hostName: marketplace.name, outcome: "build-failed", claimable: false }; const requestedIdentity = await readMarketplaceCatalogIdentity(this.fs, toolId, builtDir); if (requestedIdentity === undefined) { throw new UnreadableBuiltCatalogError( @@ -550,6 +854,81 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ); } const hostName = requestedIdentity.name; + const proof = await this.nativeCatalogProof( + toolId, + hostName, + projectRoot, + freshClaims.find((claim) => claim.hostName === hostName) + ); + if ( + proof.sourceStatus === "unproven" || + (proof.sourceStatus === "owned" && proof.source === undefined) + ) { + if (isAiToolId(toolId) && nativeActivationOf(toolId)?.marketplaceRegistry !== undefined) { + const existing = await this.hostMarketplaceRegistries.get(toolId)?.read(); + if (existing?.entries?.has(hostName) === true) + await this.guardAgainstConflict( + toolId, + builtDir, + requestedIdentity, + marketplace, + projectRoot, + warnings + ); + } + const message = `${toolId}: catalogue '${hostName}' host source unproven (${proof.sourceReason ?? "unknown"}); registration left untouched.`; + this.logger.warn(message); + warnings.push(message); + return { hostName, outcome: "refused", claimable: false }; + } + const hostMarketplaceReading = + isAiToolId(toolId) && nativeActivationOf(toolId)?.marketplaceRegistry !== undefined + ? await this.hostMarketplaceRegistries.get(toolId)?.read() + : undefined; + if (hostMarketplaceReading?.entries?.has(hostName) === true && !proof.canonical) { + await this.guardAgainstConflict( + toolId, + builtDir, + requestedIdentity, + marketplace, + projectRoot, + warnings + ); + const message = `${toolId}: catalogue '${hostName}' is already registered on the host without a canonical AIDD claim; same-name collision left untouched.`; + this.logger.warn(message); + warnings.push(message); + return { hostName, outcome: "refused", claimable: false }; + } + if ( + hostMarketplaceReading !== undefined && + hostMarketplaceReading.entries === undefined && + hostMarketplaceReading.absent !== true + ) { + const message = `${toolId}: catalogue '${hostName}' host registry is unreadable; ownership unproven and registration left untouched.`; + this.logger.warn(message); + warnings.push(message); + return { hostName, outcome: "refused", claimable: false }; + } + if (!proof.hostReadable || proof.foreignRef !== undefined || proof.unreadable !== undefined) { + const message = + proof.foreignRef === undefined + ? `${toolId}: catalogue '${hostName}' collides with unreadable host plugin registry (${proof.unreadable}); registration left untouched.` + : `${toolId}: catalogue '${hostName}' carries foreign host ref '${proof.foreignRef}'; registration left untouched.`; + this.logger.warn(message); + warnings.push(message); + return { hostName, outcome: "refused", claimable: false }; + } + const requestedSource = await this.fs.realpath(builtDir).catch(() => builtDir); + if ( + proof.sourceStatus === "owned" && + proof.source?.source !== undefined && + samePath(proof.source.source, requestedSource) && + (proof.source.kind === "registry" || + (proof.source.kind === "effective-list" && + samePath(proof.source.root, requestedSource) && + proof.source.sourceType === "local")) + ) + return { hostName, outcome: "registered", claimable: true, provenance: proof.source }; const decision = await this.guardAgainstConflict( toolId, builtDir, @@ -560,23 +939,75 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { ); // A "skip" is a host already following a newer shared build, never this project's own // pre-migration cache, so it counts as registered. - if (decision === "skip") return { hostName, outcome: "registered" }; + if (decision === "skip") + return { + hostName, + outcome: "registered", + claimable: proof.sourceStatus === "owned", + provenance: proof.source, + }; try { activator.addMarketplace(builtDir, marketplace.scope); } catch (error) { if (!(error instanceof NativePluginCliError)) throw error; - const reclaimed = this.reclaimOrReport( + const reclaimed = await this.reclaimOrReport( toolId, activator, marketplace, hostName, builtDir, + projectRoot, error, warnings ); - return { hostName, outcome: reclaimed ? "registered" : "refused" }; + if (!reclaimed) return { hostName, outcome: "refused", claimable: false }; + const afterReclaim = await this.proveAddedSource( + toolId, + hostName, + builtDir, + projectRoot, + warnings + ); + return { + hostName, + outcome: afterReclaim === undefined ? "refused" : "registered", + claimable: afterReclaim !== undefined, + addedThisRun: afterReclaim !== undefined, + provenance: afterReclaim, + }; } - return { hostName, outcome: "registered" }; + const afterAdd = await this.proveAddedSource(toolId, hostName, builtDir, projectRoot, warnings); + return { + hostName, + outcome: afterAdd === undefined ? "refused" : "registered", + claimable: afterAdd !== undefined, + addedThisRun: afterAdd !== undefined, + provenance: afterAdd, + }; + } + + private async proveAddedSource( + toolId: ToolId, + hostName: string, + builtDir: string, + projectRoot: string, + warnings: string[] + ): Promise { + if (!isAiToolId(toolId)) return undefined; + const reading = await this.nativeSources.get(toolId)?.read(projectRoot); + const current = reading?.entries?.get(hostName); + const expected = await this.fs.realpath(builtDir).catch(() => builtDir); + const sourceMatches = + current?.source === expected && + (current.kind === "registry" || + (current.kind === "effective-list" && + current.sourceType === "local" && + current.root === expected)); + if (sourceMatches) return current; + const message = `${toolId}: catalogue '${hostName}' add returned but effective host source could not be proven as '${expected}'; no AIDD claim or enablement recorded. Reconcile manually.`; + this.logger.warn(message); + warnings.push(message); + return undefined; } /** @@ -620,29 +1051,23 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { `Marketplace '${check.name}' is already registered from a different catalog: ` + `${check.registeredSource} differs from the one requested, ${check.requestedSource} ` + `— plugins ${describePluginDiff(diff)}, per ${check.location}. ` + - `Run \`claude plugin marketplace remove ${check.name}\`, then \`aidd sync\` again ` + - `to re-register it for this project.` + `Do not remove a catalogue another user or project may depend on. ` + + `Reconcile the host source manually before retrying \`aidd sync\`.` ); } /** * A host already following a newer build is never written backward — warned, not thrown, so a * caller iterating several tools still proceeds. A host tracking *another* project's - * pre-migration cache is no refusal: the repoint completes that migration and its claim is - * recorded alongside this project's, but only once that root is proven to still exist — - * `resolveProjectRootForReferences` falls back to the path as given on `ENOENT`. + * pre-migration cache can proceed only through the separate host-source proof; discovering a + * path here never creates a shared-source claim before registration succeeds. */ private async decideOnDrift( toolId: ToolId, found: MarketplaceSourceDriftFound, warnings: string[] ): Promise<"proceed" | "skip"> { - if (found.drift.kind === "unmigrated-foreign-project-source") { - if (await this.fs.fileExists(found.drift.projectRoot)) { - await this.recordReferenceForRoot(found.drift.projectRoot); - } - return "proceed"; - } + if (found.drift.kind === "unmigrated-foreign-project-source") return "proceed"; if (found.drift.kind !== "version-behind") return "proceed"; const { registeredVersion, requestedVersion } = found.drift; const message = @@ -655,44 +1080,85 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { return "skip"; } - // `add` refused, which for a global registry means the name is already held. A registration that - // still resolves belongs to a live project and taking it would break that project; one whose - // source is gone belongs to nobody. `hostName`, never `marketplace.name`, drives every host-facing - // call below: the alias would answer "dead" for a live registration whenever the two differ. The - // reserved framework name at `"user"` scope is reclaimed on any refusal, not only a proven-dead - // one — codex answers `"unknown"` for every name, copilot `"live"` — safe only because every - // registration under that name is this CLI's own packaged catalog. - private reclaimOrReport( + // Reclaim only a dead Claude registration whose current source is recognisably AIDD-owned. + // `hostName`, never the project's alias, names every host-facing call. + private async reclaimOrReport( toolId: ToolId, activator: NativePluginActivator, marketplace: Marketplace, hostName: string, builtDir: string, + projectRoot: string, addError: NativePluginCliError, warnings: string[] - ): boolean { - const state = activator.registrationState(hostName); - const isUnguardedFrameworkMarketplace = - marketplace.name === FRAMEWORK_MARKETPLACE_NAME && - marketplace.scope === "user" && - (!isAiToolId(toolId) || nativeActivationOf(toolId)?.marketplaceRegistry === undefined); - if (state !== "dead" && !isUnguardedFrameworkMarketplace) { + ): Promise { + const proof = await this.nativeCatalogProof(toolId, hostName, projectRoot); + if ( + !proof.canonical || + !proof.hostReadable || + proof.sourceStatus !== "owned" || + proof.foreignRef !== undefined + ) { + const message = + `Native plugin activation — catalogue '${hostName}' collides with unproven AIDD ownership` + + `${proof.foreignRef === undefined ? "" : ` and foreign ref '${proof.foreignRef}'`}; host registration left untouched. ${addError.message}`; + this.logger.warn(message); + warnings.push(message); + return false; + } + const registeredSource = isAiToolId(toolId) + ? (await this.hostMarketplaceRegistries.get(toolId)?.read())?.entries?.get(hostName) + : undefined; + const source = + registeredSource === undefined + ? undefined + : await this.fs.realpath(registeredSource).catch(() => registeredSource); + const projectSource = + source === undefined + ? undefined + : parseBuiltMarketplaceDir( + await this.fs.realpath(projectRoot).catch(() => projectRoot), + source + ); + const userRoot = this.userCacheRoot(); + const userSource = + source === undefined || userRoot === "" + ? undefined + : parseUserBuiltMarketplaceDir( + await this.fs.realpath(userRoot).catch(() => userRoot), + source + ); + const provenSource = [projectSource, userSource].some( + (location) => + location !== undefined && + samePathSegment(location.marketplaceName, marketplace.name) && + samePathSegment(location.target, toolId) + ); + if (!provenSource) { + const message = + `Native plugin activation — catalogue '${hostName}' host catalogue source is unproven; ` + + `host registration left untouched. ${addError.message}`; + this.logger.warn(message); + warnings.push(message); + return false; + } + if (activator.registrationState(hostName) !== "dead") { const message = `Native plugin activation — register marketplace '${hostName}' skipped: ${addError.message}`; this.logger.warn(message); warnings.push(message); return false; } - const reclaimMessage = - state === "dead" - ? `Marketplace '${hostName}' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back.` - : `Marketplace '${hostName}' is registered from a different source and ${toolId} refuses to overwrite it in place; removing and re-registering it from the shared, machine-scope build. Plugins installed from it are removed and the ones this CLI manages are put back.`; + const reclaimMessage = `Marketplace '${hostName}' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back.`; this.logger.warn(reclaimMessage); warnings.push(reclaimMessage); - this.bestEffort( - () => activator.removeMarketplace(hostName, marketplace.scope, { force: true }), - `unregister stale marketplace '${hostName}'`, - warnings - ); + if ( + !this.bestEffort( + () => activator.removeMarketplace(hostName, marketplace.scope, { force: true }), + `unregister stale marketplace '${hostName}'`, + warnings + ) + ) + return false; return this.bestEffort( () => activator.addMarketplace(builtDir, marketplace.scope), `register marketplace '${hostName}'`, @@ -805,11 +1271,13 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { projectRoot: string, manifest: Manifest, marketplaces: readonly Marketplace[], - settings: MarketplaceSettings + settings: MarketplaceSettings, + provenRefs?: readonly string[] ): Promise { const pluginsPath = resolve(projectRoot, settings.settingsPath); const json = await this.loadSettings(pluginsPath); - if (!this.mergeEnabledPlugins(json, settings, toolId, manifest, marketplaces)) return false; + if (!this.mergeEnabledPlugins(json, settings, toolId, manifest, marketplaces, provenRefs)) + return false; const content = JSON.stringify(json, null, 2); await this.fs.writeFile(pluginsPath, content); manifest.updateTrackedFileHash(toolId, settings.settingsPath, this.hasher.hash(content)); @@ -821,12 +1289,19 @@ export class MarketplaceSyncSettingsUseCase implements MarketplaceSyncSettings { settings: MarketplaceSettings, toolId: ToolId, manifest: Manifest, - marketplaces: readonly Marketplace[] + marketplaces: readonly Marketplace[], + provenRefs?: readonly string[] ): boolean { const pluginsKey = settings.enabledPluginsKey; if (pluginsKey == null) return false; const existing = this.existingRecord(json, pluginsKey); const toAdd: Record = {}; + if (provenRefs !== undefined) { + for (const ref of provenRefs) if (!(ref in existing)) toAdd[ref] = true; + if (Object.keys(toAdd).length === 0) return false; + json[pluginsKey] = { ...existing, ...toAdd }; + return true; + } const marketplaceByName = new Map(marketplaces.map((m) => [m.name, m])); for (const plugin of manifest.getPlugins(toolId)) { if (plugin.marketplace == null) continue; @@ -896,9 +1371,14 @@ function marketplaceRegistrationsEqual( ): boolean { return ( a.length === b.length && - a.every( - (value, index) => value.alias === b[index]?.alias && value.hostName === b[index]?.hostName - ) + a.every((value, index) => { + const other = b[index]; + if (other === undefined || value.alias !== other.alias || value.hostName !== other.hostName) + return false; + if (value.provenance === undefined || other.provenance === undefined) + return value.provenance === other.provenance; + return sameNativeMarketplaceSource(value.provenance, other.provenance); + }) ); } diff --git a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts index 4d5d63561..66fc81e0f 100644 --- a/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts +++ b/cli/src/contexts/framework/application/framework/translator/built-tree-materialization-translator.ts @@ -20,7 +20,10 @@ import { import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js"; import type { Manifest } from "../../../domain/manifest.js"; -import { InstalledPlugin } from "../../../domain/plugins/installed-plugin.js"; +import { + InstalledPlugin, + type ProjectHooksProvenance, +} from "../../../domain/plugins/installed-plugin.js"; import { isPluginFileAtDesiredState } from "../../plugin/plugin-helpers.js"; import { resolveBaseDirFromRecord, @@ -52,7 +55,8 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { manifest: Manifest, marketplace: string | undefined, previousMcpEntries: ReadonlyMap = new Map(), - userScopeDirTaken = false + userScopeDirTaken = false, + previousProjectHooks?: ProjectHooksProvenance ): Promise<{ skipped: ReadonlySkipList; written?: number }> { const resolved = marketplace === undefined ? null : await this.findMarketplace(marketplace, projectRoot); @@ -65,7 +69,8 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { manifest, marketplace, previousMcpEntries, - userScopeDirTaken + userScopeDirTaken, + previousProjectHooks ); } const mode = frameworkBuildModeFor(toolId); @@ -78,17 +83,19 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { const builtFiles = mode === "flat" ? await this.readFlatFiles(builtDir, dist, toolId) - : await this.readBuiltFiles( - join(builtDir, "plugins", dist.manifest.name), - dist.manifest.name - ); + : await readBuiltUserPluginFiles(this.fs, this.hasher, builtDir, dist.manifest.name); // The built tree still carries a plugin-scoped `hooks/hooks.json` for a capability declaring // `hooksDestination: "project"` — dropped here and materialized through the same project-hooks // side channel the local-source route uses, so both land where the tool's own declaration says. const deliversHooksToProject = resolvePluginsCapability(toolId)?.hooksDestination === "project"; - const hooksSkips = deliversHooksToProject - ? await this.projectHooks.materialize(dist, toolId, projectRoot) - : []; + const hooks = deliversHooksToProject + ? await this.projectHooks.materializeWithProvenance( + dist, + toolId, + projectRoot, + previousProjectHooks + ) + : { skipped: [] as ReadonlySkipList }; const files = deliversHooksToProject ? withoutHooksPrefix(builtFiles, dist.manifest.name) : builtFiles; @@ -101,9 +108,12 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { const written = await this.writeChangedFiles(owned, baseDir); manifest.addPlugin( toolId, - InstalledPlugin.fromDistribution(dist, source, owned, scope, new Map(), marketplace) + InstalledPlugin.withProjectHooks( + InstalledPlugin.fromDistribution(dist, source, owned, scope, new Map(), marketplace), + hooks.projectHooks + ) ); - return { skipped: hooksSkips, written }; + return { skipped: hooks.skipped, written }; } // Skips a file already matching the built content on disk, so a no-op restore reports (and @@ -121,26 +131,6 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { return written; } - // Marketplace build emits plugins//; user-scope tools install at - // //, so the manifest relativePath keeps the / prefix. - private async readBuiltFiles(pluginSrc: string, name: string): Promise { - const absPaths = await this.fs.listFilesRecursive(pluginSrc); - return Promise.all( - absPaths.map(async (abs) => { - const rel = posixRelative(pluginSrc, abs); - const content = await this.fs.readFile(abs); - return new InstallationFile({ - // relativePath is always "/"-separated (see withoutHooksPrefix and - // belongsToPlugin below, both string-matching on "/") - node:path's platform - // `join` would answer with "\" on win32, breaking both. - relativePath: posix.join(name, rel), - content, - hash: this.hasher.hash(content), - }); - }) - ); - } - // Flat build emits the whole marketplace into one workspace. Agents are namespaced by // `-`; skills instead nest the whole subtree under `skills//`, since a // skill's own script can `require()` a sibling by relative path, which only keeps resolving while @@ -205,9 +195,33 @@ export class BuiltTreeMaterializationTranslator implements PluginTranslator { } } +export async function readBuiltUserPluginFiles( + fs: FileReader, + hasher: Hasher, + builtDir: string, + name: string +): Promise { + const pluginSrc = join(builtDir, "plugins", name); + const absPaths = await fs.listFilesRecursive(pluginSrc); + return Promise.all( + absPaths.map(async (abs) => { + const rel = posixRelative(pluginSrc, abs); + const content = await fs.readFile(abs); + return new InstallationFile({ + relativePath: posix.join(name, rel), + content, + hash: hasher.hash(content), + }); + }) + ); +} + // `readBuiltFiles` prefixes every path with `/`, so a built-tree hooks file always reads // `/hooks/`. -function withoutHooksPrefix(files: InstallationFile[], pluginName: string): InstallationFile[] { +export function withoutHooksPrefix( + files: InstallationFile[], + pluginName: string +): InstallationFile[] { const hooksPrefix = `${pluginName}/hooks/`; return files.filter((f) => !f.relativePath.startsWith(hooksPrefix)); } diff --git a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts index a7a89c4c4..2e2b6ed46 100644 --- a/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts +++ b/cli/src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.ts @@ -17,7 +17,11 @@ import type { ReadonlySkipList, } from "../../../../translate/domain/plugin-translation-skip.js"; import type { Manifest } from "../../../domain/manifest.js"; -import { InstalledPlugin, type PluginScope } from "../../../domain/plugins/installed-plugin.js"; +import { + InstalledPlugin, + type PluginScope, + type ProjectHooksProvenance, +} from "../../../domain/plugins/installed-plugin.js"; import { writePluginFiles } from "../../plugin/plugin-helpers.js"; import { isFrameworkPrimeFlatMcp, @@ -52,20 +56,28 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { manifest: Manifest, marketplace: string | undefined, previousMcpEntries: ReadonlyMap = new Map(), - userScopeDirTaken = false + userScopeDirTaken = false, + previousProjectHooks?: ProjectHooksProvenance ): Promise<{ skipped: ReadonlySkipList }> { const ctx = this.resolveFlatToolContext(toolId, dist, projectRoot); if (ctx === null) return { skipped: [] }; const mcp = await this.resolveMcp(dist, toolId, projectRoot, previousMcpEntries); - const hooksSkips = await this.projectHooks.materialize(dist, toolId, projectRoot); - const allSkipped: ReadonlySkipList = [...ctx.skipped, ...mcp.mcpSkips, ...hooksSkips]; - if (ctx.files.length === 0 && mcp.mcpEntries.size === 0) return { skipped: allSkipped }; + const hooks = await this.projectHooks.materializeWithProvenance( + dist, + toolId, + projectRoot, + previousProjectHooks + ); + const allSkipped: ReadonlySkipList = [...ctx.skipped, ...mcp.mcpSkips, ...hooks.skipped]; + if (ctx.files.length === 0 && mcp.mcpEntries.size === 0 && hooks.projectHooks === undefined) + return { skipped: allSkipped }; await this.writeAndRegisterPlugin( dist, toolId, source, userScopeDirTaken ? [] : ctx.files, mcp.mcpEntries, + hooks.projectHooks, ctx.componentPaths, marketplace, ctx.baseDir, @@ -124,6 +136,7 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { source: PluginSource, files: InstallationFile[], mcpEntries: ReadonlyMap, + projectHooks: ProjectHooksProvenance | undefined, componentPaths: ReadonlyMap, marketplace: string | undefined, baseDir: string, @@ -140,7 +153,7 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { componentPaths, marketplace ); - manifest.addPlugin(toolId, plugin); + manifest.addPlugin(toolId, InstalledPlugin.withProjectHooks(plugin, projectHooks)); } private async mergeOpencodeMcpEntries( @@ -156,12 +169,13 @@ export class ModeBFlatMaterializationTranslator implements PluginTranslator { const existingContent = await this.readExistingJson(outputPath); const rawMcp = dist.components.mcp[0].content; const transformed = mcpCap.transform(rawMcp); - const { mergedContent, contributedEntries, collisions } = mergeOpencodeMcp( - existingContent, - transformed, - previousMcpEntries, - this.hasher - ); + const { mergedContent, contributedEntries, collisions, editedPreviousEntries } = + mergeOpencodeMcp(existingContent, transformed, previousMcpEntries, this.hasher); + if (editedPreviousEntries.length > 0) { + throw new Error( + `OpenCode MCP server '${editedPreviousEntries[0]}' was edited after install; reinstall refused.` + ); + } if (contributedEntries.size > 0 || previousMcpEntries.size > 0) { await this.fs.writeFile(outputPath, mergedContent); } diff --git a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts index a5a50540f..b287cb23e 100644 --- a/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts +++ b/cli/src/contexts/framework/application/framework/translator/plugin-translator.ts @@ -4,6 +4,7 @@ import type { PluginTranslationMode } from "../../../../tools/domain/plugin-tran import type { PluginDistribution } from "../../../../translate/domain/plugin-distribution.js"; import type { ReadonlySkipList } from "../../../../translate/domain/plugin-translation-skip.js"; import type { Manifest } from "../../../domain/manifest.js"; +import type { ProjectHooksProvenance } from "../../../domain/plugins/installed-plugin.js"; /** A translator strategy contract, not a hexagonal port adapter. */ export interface PluginTranslator { @@ -24,6 +25,7 @@ export interface PluginTranslator { manifest: Manifest, marketplace: string | undefined, previousMcpEntries?: ReadonlyMap, - userScopeDirTaken?: boolean + userScopeDirTaken?: boolean, + previousProjectHooks?: ProjectHooksProvenance ): Promise<{ skipped: ReadonlySkipList; written?: number }>; } diff --git a/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts b/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts index 1a8735048..792d10837 100644 --- a/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts +++ b/cli/src/contexts/framework/application/framework/translator/project-hooks-materializer.ts @@ -1,4 +1,4 @@ -import { join } from "node:path"; +import { dirname, join } from "node:path"; import { cursorProjectHooksScriptPath, mergeCursorProjectHooksJson, @@ -15,6 +15,12 @@ import type { PluginTranslationSkip, ReadonlySkipList, } from "../../../../translate/domain/plugin-translation-skip.js"; +import type { ProjectHooksProvenance } from "../../../domain/plugins/installed-plugin.js"; +import { + assertProjectHooksUnchanged, + recordedProjectHookEntries, + removeRecordedProjectHooks, +} from "../../shared/remove-project-hooks.js"; const HOOKS_MANIFEST_PATH = "hooks/hooks.json"; @@ -32,27 +38,53 @@ export class ProjectHooksMaterializer { toolId: AiToolId, projectRoot: string ): Promise { + return (await this.materializeWithProvenance(dist, toolId, projectRoot)).skipped; + } + + async materializeWithProvenance( + dist: PluginDistribution, + toolId: AiToolId, + projectRoot: string, + previous?: ProjectHooksProvenance + ): Promise<{ skipped: ReadonlySkipList; projectHooks?: ProjectHooksProvenance }> { const pluginsCap = resolvePluginsCapability(toolId); - if (pluginsCap === null || pluginsCap.hooksDestination !== "project") return []; + if (pluginsCap === null || pluginsCap.hooksDestination !== "project") return { skipped: [] }; const projectHooksRelativePath = pluginsCap.projectHooksRelativePath; - if (projectHooksRelativePath === null) return []; + if (projectHooksRelativePath === null) return { skipped: [] }; const manifestFile = dist.components.hooks.find((f) => f.relativePath === HOOKS_MANIFEST_PATH); - if (manifestFile === undefined) return []; - const warnings = await this.mergeProjectHooksJson( + if (manifestFile === undefined) { + if (previous !== undefined) { + await removeRecordedProjectHooks( + this.fs, + dist.manifest.name, + previous, + toolId, + projectRoot + ); + } + return { skipped: [] }; + } + await assertProjectHooksUnchanged(this.fs, dist.manifest.name, previous, toolId, projectRoot); + await this.assertScriptsNotUserOwned(dist, projectRoot, previous); + const { warnings, entries } = await this.mergeProjectHooksJson( dist, manifestFile, projectRoot, projectHooksRelativePath ); - await this.writeProjectHooksScripts(dist, projectRoot); - return warnings.map( - (reason): PluginTranslationSkip => ({ - pluginName: dist.manifest.name, - component: "hooks", - toolId, - reason, - }) - ); + const scripts = await this.writeProjectHooksScripts(dist, projectRoot); + await this.removeObsoleteScripts(previous, scripts, projectRoot); + return { + skipped: warnings.map( + (reason): PluginTranslationSkip => ({ + pluginName: dist.manifest.name, + component: "hooks", + toolId, + reason, + }) + ), + projectHooks: { entries, scripts }, + }; } private async mergeProjectHooksJson( @@ -60,7 +92,7 @@ export class ProjectHooksMaterializer { manifestFile: PluginComponentFile, projectRoot: string, projectHooksRelativePath: string - ): Promise { + ): Promise<{ warnings: readonly string[]; entries: ProjectHooksProvenance["entries"] }> { const destPath = join(projectRoot, projectHooksRelativePath); const existing = await this.readExistingJson(destPath); const { content, warnings } = mergeCursorProjectHooksJson( @@ -69,17 +101,55 @@ export class ProjectHooksMaterializer { dist.manifest.name ); await this.fs.writeFile(destPath, content); - return warnings; + return { warnings, entries: recordedProjectHookEntries(content, dist.manifest.name) }; + } + + private async assertScriptsNotUserOwned( + dist: PluginDistribution, + projectRoot: string, + previous?: ProjectHooksProvenance + ): Promise { + for (const file of dist.components.hooks) { + if (file.relativePath === HOOKS_MANIFEST_PATH) continue; + const relativePath = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath); + if (previous?.scripts.has(relativePath) === true) continue; + if (await this.fs.fileExists(join(projectRoot, relativePath))) { + throw new Error(`Cursor hook script '${relativePath}' is user-owned; install refused.`); + } + } } private async writeProjectHooksScripts( dist: PluginDistribution, projectRoot: string - ): Promise { + ): Promise> { + const scripts = new Map(); for (const file of dist.components.hooks) { if (file.relativePath === HOOKS_MANIFEST_PATH) continue; const dest = cursorProjectHooksScriptPath(dist.manifest.name, file.relativePath); await this.fs.writeFile(join(projectRoot, dest), file.content); + scripts.set(dest, (await this.fs.readFileHash(join(projectRoot, dest))).value); + } + return scripts; + } + + private async removeObsoleteScripts( + previous: ProjectHooksProvenance | undefined, + current: ReadonlyMap, + projectRoot: string + ): Promise { + if (previous === undefined) return; + for (const [relativePath, digest] of previous.scripts) { + if (current.has(relativePath)) continue; + const path = join(projectRoot, relativePath); + if (!(await this.fs.fileExists(path))) continue; + if ((await this.fs.readFileHash(path)).value !== digest) { + throw new Error( + `Cursor hook script '${relativePath}' was edited during reinstall; removal refused.` + ); + } + await this.fs.deleteFile(path); + await this.fs.deleteEmptyDirectories(dirname(path)); } } diff --git a/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts b/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts new file mode 100644 index 000000000..5c36dce71 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/native-host-registration-gate.ts @@ -0,0 +1,115 @@ +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; +import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; +import { nativeActivationOf } from "../../../tools/domain/registry.js"; +import type { + NativeMarketplaceRegistration, + NativePluginClaim, + NativeRegistrations, +} from "../../domain/manifest/native-registrations.js"; +import { + assertNoForeignNativeRefs, + inspectNativeMarketplaceSource, +} from "./native-marketplace-source-proof.js"; + +export class NativeHostRegistrationGate { + constructor( + private readonly activators: ReadonlyMap, + private readonly registries: ReadonlyMap, + private readonly sources: ReadonlyMap = new Map() + ) {} + + async requireTargetedUpdate( + toolId: AiToolId, + claim: NativePluginClaim, + projectRoot: string, + registrations?: NativeRegistrations + ): Promise { + const activation = nativeActivationOf(toolId); + if (activation?.updateVerb === undefined) { + throw new Error( + `${toolId} does not support targeted native plugin update for '${claim.ref}'; use marketplace refresh or framework update instead.` + ); + } + const activator = this.activators.get(activation.binary); + const reader = this.registries.get(toolId); + if ( + activator === undefined || + !activator.isAvailable() || + activator.updatePlugin === undefined || + reader === undefined + ) { + throw new Error( + `${toolId}: cannot update AIDD-owned ref '${claim.ref}' without its CLI and readable host registry; canonical claims retained.` + ); + } + if ((await reader.read(projectRoot)).refs?.get(claim.ref)?.enabled !== true) { + throw new Error( + `${toolId}: ref '${claim.ref}' is not provably enabled on the host; update refused.` + ); + } + const registration = registrations?.marketplaces.find((marketplace) => + claim.ref.endsWith(`@${marketplace.hostName}`) + ); + if (registration === undefined) + throw new Error( + `${toolId}: ref '${claim.ref}' has no canonical catalogue source proof; update refused.` + ); + const proof = await inspectNativeMarketplaceSource( + this.sources.get(toolId), + projectRoot, + registration + ); + if (proof.status !== "owned") + throw new Error(proof.reason ?? `${toolId}: catalogue source unproven.`); + return activator; + } + + async planMarketplaceRemoval( + toolId: AiToolId, + registrations: NativeRegistrations, + registration: NativeMarketplaceRegistration, + projectRoot: string + ): Promise<{ + activator: NativePluginActivator; + refs: readonly NativePluginClaim[]; + scopes: ReadonlyMap; + }> { + const activator = this.activators.get(registrations.binary); + const reader = this.registries.get(toolId); + if (activator === undefined || !activator.isAvailable() || reader === undefined) { + throw new Error( + `Cannot prove/remove AIDD-owned catalogue '${registration.hostName}': ${registrations.binary} CLI or host registry unavailable.` + ); + } + const proof = await inspectNativeMarketplaceSource( + this.sources.get(toolId), + projectRoot, + registration + ); + if (proof.status !== "owned") + throw new Error(proof.reason ?? `${toolId}: catalogue source unproven.`); + const owned = new Set((registrations.pluginClaims ?? []).map((claim) => claim.ref)); + const hostRefs = await assertNoForeignNativeRefs( + reader, + registration.hostName, + owned, + projectRoot + ); + const refs = (registrations.pluginClaims ?? []).filter((claim) => + claim.ref.endsWith(`@${registration.hostName}`) + ); + for (const claim of refs) { + if (hostRefs.get(claim.ref)?.enabled !== true) + throw new Error( + `Cannot prove AIDD-owned host ref '${claim.ref}' is still enabled; refusing user-scope removal.` + ); + } + return { + activator, + refs, + scopes: new Map([...hostRefs].map(([ref, state]) => [ref, state.scope])), + }; + } +} diff --git a/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts b/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts new file mode 100644 index 000000000..126784df5 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/native-marketplace-source-proof.ts @@ -0,0 +1,110 @@ +import type { + HostPluginRegistryEntry, + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, + NativeMarketplaceSourceReading, +} from "../../../tools/domain/ports/native-marketplace-source-reader.js"; +import type { NativeMarketplaceRegistration } from "../../domain/manifest/native-registrations.js"; + +export interface NativeMarketplaceSourceInspection { + readonly status: "absent" | "owned" | "unproven"; + readonly current?: NativeMarketplaceSource; + readonly reason?: string; +} + +export function sameNativeMarketplaceSource( + recorded: NativeMarketplaceSource, + current: NativeMarketplaceSource +): boolean { + if (recorded.kind !== current.kind || recorded.source !== current.source) return false; + if (recorded.kind === "registry" && current.kind === "registry") return true; + return ( + recorded.kind === "effective-list" && + current.kind === "effective-list" && + recorded.root === current.root && + recorded.sourceType === current.sourceType + ); +} + +export async function inspectNativeMarketplaceSource( + reader: NativeMarketplaceSourceReader | undefined, + projectRoot: string, + registration: NativeMarketplaceRegistration +): Promise { + const name = registration.hostName; + if (reader === undefined) + return { + status: "unproven", + reason: `Catalogue '${name}': host source reader unavailable; reconcile manually.`, + }; + let reading: NativeMarketplaceSourceReading; + try { + reading = await reader.read(projectRoot); + } catch (error) { + return { + status: "unproven", + reason: `Catalogue '${name}': host source read failed (${error instanceof Error ? error.message : String(error)}); reconcile manually.`, + }; + } + if (reading.entries === undefined) + return { + status: "unproven", + reason: `Catalogue '${name}': ${reading.unreadable ?? `host source unreadable at ${reading.location}`}; reconcile manually.`, + }; + if (!reading.entries.has(name)) return { status: "absent" }; + const current = reading.entries.get(name); + if (current === undefined || current === null) + return { + status: "unproven", + reason: `Catalogue '${name}': current host source is unproven; reconcile manually.`, + }; + if (registration.provenance === undefined) + return { + status: "unproven", + reason: `Catalogue '${name}': legacy claim has no source proof; reconcile manually.`, + }; + if (!sameNativeMarketplaceSource(registration.provenance, current)) + return { + status: "unproven", + reason: `Catalogue '${name}': current host source differs from AIDD's recorded source; reconcile manually.`, + }; + return { status: "owned", current }; +} + +/** A source may be AIDD-owned while the host still carries somebody else's plugin from it. */ +export async function assertNoForeignNativeRefs( + reader: HostPluginRegistryReader | undefined, + hostName: string, + ownedRefs: ReadonlySet, + projectRoot: string +): Promise> { + if (reader === undefined) + throw new Error( + `Catalogue '${hostName}': host plugin registry reader unavailable; no host mutation made.` + ); + let reading: HostPluginRegistryReading; + try { + reading = await reader.read(projectRoot); + } catch (error) { + throw new Error( + `Catalogue '${hostName}': host plugin registry read failed (${error instanceof Error ? error.message : String(error)}); no host mutation made.` + ); + } + if (reading.refs === undefined && reading.absent !== true) + throw new Error( + `Catalogue '${hostName}': host plugin registry unreadable (${reading.unreadable ?? reading.location}); no host mutation made.` + ); + const refs = reading.refs ?? new Map(); + const foreign = [...refs.keys()].find( + (ref) => ref.endsWith(`@${hostName}`) && !ownedRefs.has(ref) + ); + if (foreign !== undefined) + throw new Error( + `Catalogue '${hostName}' includes foreign host ref '${foreign}'; no host mutation made.` + ); + return refs; +} diff --git a/cli/src/contexts/framework/application/ownership/native-plugin-ownership.ts b/cli/src/contexts/framework/application/ownership/native-plugin-ownership.ts new file mode 100644 index 000000000..b3fc392e8 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/native-plugin-ownership.ts @@ -0,0 +1,55 @@ +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { ToolId } from "../../../../kernel/tool.js"; +import type { NativePluginClaim } from "../../domain/manifest/native-registrations.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; + +export async function machineNativePluginClaim( + repo: ManifestRepository | undefined, + toolId: ToolId, + ref: string +): Promise { + if (repo === undefined) return undefined; + const machine = await repo.load(); + return machine?.getNativeRegistrations(toolId)?.pluginClaims?.find((claim) => claim.ref === ref); +} + +export async function isMachineOwnedNativeRef( + repo: ManifestRepository | undefined, + toolId: ToolId, + ref: string +): Promise { + return (await machineNativePluginClaim(repo, toolId, ref)) !== undefined; +} + +export async function detachNativePluginRefs( + repo: ManifestRepository | undefined, + fs: FileReader, + projectRoot: string, + refsByTool: ReadonlyMap +): Promise { + if (repo === undefined || refsByTool.size === 0) return; + const action = async () => { + const machine = await repo.load(); + if (machine === null) return; + const root = await fs.realpath(projectRoot); + let changed = false; + for (const [toolId, refs] of refsByTool) { + const registration = machine.getNativeRegistrations(toolId); + if (registration?.pluginClaims === undefined) continue; + const targeted = new Set(refs); + const claims = registration.pluginClaims.map((claim) => { + if (!targeted.has(claim.ref) || !claim.dependents.includes(root)) return claim; + changed = true; + return { + ref: claim.ref, + dependents: claim.dependents.filter((dependent) => dependent !== root), + }; + }); + if (changed) + machine.setNativeRegistrations(toolId, { ...registration, pluginClaims: claims }); + } + if (changed) await repo.save(machine); + }; + if (repo.withExclusiveAccess !== undefined) await repo.withExclusiveAccess(action); + else await action(); +} diff --git a/cli/src/contexts/framework/application/ownership/project-path-boundary.ts b/cli/src/contexts/framework/application/ownership/project-path-boundary.ts new file mode 100644 index 000000000..71b04503c --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/project-path-boundary.ts @@ -0,0 +1,43 @@ +import { dirname, isAbsolute, relative, resolve, sep } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; + +/** Resolve an existing file or its nearest existing parent before mutating project-local content. */ +export async function assertProjectPathWithinRoot( + fs: FileReader, + projectRoot: string, + path: string +): Promise { + const lexicalRoot = resolve(projectRoot); + const lexicalPath = resolve(path); + if (!strictlyWithin(lexicalRoot, lexicalPath)) { + throw new Error(`'${path}' escapes project root '${projectRoot}'; local mutation refused.`); + } + const canonicalRoot = await fs.realpath(lexicalRoot); + let candidate = lexicalPath; + while (true) { + try { + const canonical = await fs.realpath(candidate); + if (!within(canonicalRoot, canonical)) { + throw new Error( + `'${path}' resolves outside project root '${projectRoot}'; local mutation refused.` + ); + } + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (candidate === lexicalRoot) { + throw new Error(`Project root '${projectRoot}' disappeared; local mutation refused.`); + } + candidate = dirname(candidate); + } + } +} + +function within(root: string, path: string): boolean { + const rel = relative(root, path); + return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel)); +} + +function strictlyWithin(root: string, path: string): boolean { + return root !== path && within(root, path); +} diff --git a/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts b/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts new file mode 100644 index 000000000..d2bb2b54c --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/project-plugin-cleanup.ts @@ -0,0 +1,98 @@ +import { join } from "node:path"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; +import type { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js"; +import { unmergeOpencodeMcp } from "../../../tools/domain/formats/opencode-mcp-merge.js"; +import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; +import { isFrameworkPrimeFlatMcp } from "../plugin/plugin-target-resolution.js"; +import { assertProjectHooksRemovable, removeProjectHooks } from "../shared/remove-project-hooks.js"; +import { detachNativePluginRefs } from "./native-plugin-ownership.js"; +import { assertProjectPathWithinRoot } from "./project-path-boundary.js"; +import { detachUserPlugin } from "./user-plugin-ownership.js"; + +/** Read-only OpenCode MCP proof, shared by Clean and local plugin removals before any detach. */ +export async function assertProjectMcpEntriesRemovable( + fs: FileReader, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +): Promise { + await planProjectMcpRemoval(fs, plugin, toolId, projectRoot); +} + +async function planProjectMcpRemoval( + fs: FileReader, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +): Promise<{ output: string; content: string } | undefined> { + if (plugin.mcpEntries.size === 0) return undefined; + const config = getToolConfig(toolId); + if (!isAiTool(config)) return undefined; + const caps = config.capabilities as Record; + if (!isFrameworkPrimeFlatMcp(caps)) return undefined; + const mcp = caps.mcp as McpCapability; + const output = join(projectRoot, await mcp.resolveOutput(projectRoot, fs)); + await assertProjectPathWithinRoot(fs, projectRoot, output); + try { + const existing = await fs.readFile(output); + return { output, content: unmergeOpencodeMcp(existing, plugin.mcpEntries) }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +} + +export class ProjectPluginCleanup { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly userManifestRepo?: ManifestRepository + ) {} + + async assertLocalIntegrationRemovable( + toolId: AiToolId, + plugin: InstalledPlugin, + projectRoot: string + ): Promise { + await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, projectRoot); + await assertProjectHooksRemovable(this.fs, plugin, toolId, projectRoot); + } + + async removeLocalIntegration( + toolId: AiToolId, + plugin: InstalledPlugin, + projectRoot: string + ): Promise { + const mcpUpdate = await planProjectMcpRemoval(this.fs, plugin, toolId, projectRoot); + await removeProjectHooks(this.fs, plugin, toolId, projectRoot); + if (mcpUpdate !== undefined) { + await assertProjectPathWithinRoot(this.fs, projectRoot, mcpUpdate.output); + await this.fs.writeFile(mcpUpdate.output, mcpUpdate.content); + } + } + + async deleteLocalFiles( + toolId: AiToolId, + plugin: InstalledPlugin, + projectRoot: string + ): Promise { + await deletePluginFilesForTool(plugin.files, plugin.scope, toolId, projectRoot, this.fs); + } + + async detachClaims( + projectRoot: string, + nativeRefs: ReadonlyMap, + plugins: readonly { toolId: AiToolId; plugin: InstalledPlugin }[] + ): Promise { + await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs); + for (const { toolId, plugin } of plugins) { + if (plugin.scope === "user") { + await detachUserPlugin(this.userManifestRepo, this.fs, toolId, plugin.name, projectRoot); + } + } + } +} diff --git a/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts b/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts new file mode 100644 index 000000000..dfe15769b --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/user-marketplace-remove-use-case.ts @@ -0,0 +1,156 @@ +import { + ActiveMachineDependentsError, + InvalidMarketplaceNameError, + MarketplaceNotFoundError, +} from "../../../../kernel/errors.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import { resolveHomeDir } from "../../../../kernel/reading/home-dir.js"; +import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; +import { + FRAMEWORK_MARKETPLACE_NAME, + type Marketplace, +} from "../../../distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import type { + MarketplaceRemoveOptions, + MarketplaceRemoveResult, +} from "../flows/marketplace-remove-use-case.js"; +import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; +import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js"; +import type { NativeHostRegistrationGate } from "./native-host-registration-gate.js"; + +export class UserMarketplaceRemoveUseCase { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly repo: ManifestRepository, + private readonly registry: MarketplaceRegistry, + private readonly nativeHost: NativeHostRegistrationGate + ) {} + + async execute(options: MarketplaceRemoveOptions): Promise { + if (options.name === FRAMEWORK_MARKETPLACE_NAME) { + throw new InvalidMarketplaceNameError( + `"${FRAMEWORK_MARKETPLACE_NAME}" is shared by every project on this machine and is removed by \`aidd clean --scope user\`, not marketplace remove.` + ); + } + const marketplace = await this.findOrThrow(options.projectRoot, options.name); + const run = () => this.removeLocked(options, marketplace); + return this.repo.withExclusiveAccess === undefined ? run() : this.repo.withExclusiveAccess(run); + } + + private async findOrThrow(projectRoot: string, name: string): Promise { + const found = (await this.registry.list(projectRoot)).find( + (entry) => entry.name === name && entry.scope === "user" + ); + if (found === undefined) throw new MarketplaceNotFoundError(name); + return found; + } + + private async removeLocked( + options: MarketplaceRemoveOptions, + marketplace: Marketplace + ): Promise { + const current = await this.findOrThrow(options.projectRoot, marketplace.name); + if (JSON.stringify(current.source) !== JSON.stringify(marketplace.source)) { + throw new Error( + `User-scope marketplace '${marketplace.name}' changed while waiting for the machine lock; retry against its current source.` + ); + } + const manifest = await this.repo.load(); + if (manifest === null) + throw new Error( + `Cannot prove AIDD ownership of user-scope marketplace '${marketplace.name}': no user manifest.` + ); + const plugins = AI_TOOL_IDS.flatMap((toolId) => + manifest + .getPlugins(toolId) + .filter((plugin) => plugin.scope === "user" && plugin.marketplace === marketplace.name) + .map((plugin) => ({ toolId, plugin })) + ); + const native = AI_TOOL_IDS.flatMap((toolId) => { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) return []; + return registrations.marketplaces + .filter((registration) => registration.alias === marketplace.name) + .map((registration) => ({ toolId, registrations, registration })); + }); + if (plugins.length === 0 && native.length === 0) { + throw new Error( + `Cannot prove AIDD ownership of user-scope marketplace '${marketplace.name}': no canonical plugin or host catalogue claim.` + ); + } + const nativeRefs = native.flatMap(({ registrations, registration }) => + (registrations.pluginClaims ?? []).filter((claim) => + claim.ref.endsWith(`@${registration.hostName}`) + ) + ); + const dependents = [ + ...new Set([ + ...plugins.flatMap(({ plugin }) => plugin.dependents), + ...nativeRefs.flatMap((claim) => claim.dependents), + ]), + ]; + if (dependents.length > 0) + throw new ActiveMachineDependentsError( + `user-scope marketplace '${marketplace.name}'`, + dependents + ); + const safeFiles = await Promise.all( + plugins.map(async ({ toolId, plugin }) => { + const files = await userScopeFilesSafeToDelete( + this.fs, + { debug() {}, info() {}, warn() {} }, + plugin, + toolId, + resolveHomeDir() + ); + if (files.size !== plugin.files.size) + throw new Error( + `Refusing partial user-scope marketplace removal: '${plugin.name}' has a tracked file outside its boundary.` + ); + return { toolId, plugin, files }; + }) + ); + const nativeCalls = await Promise.all( + native.map(async ({ toolId, registrations, registration }) => ({ + toolId, + registrations, + registration, + ...(await this.nativeHost.planMarketplaceRemoval( + toolId, + registrations, + registration, + options.projectRoot + )), + })) + ); + for (const { registration, activator, refs, scopes } of nativeCalls) { + for (const claim of refs) + activator.uninstallPlugin(claim.ref, scopes.get(claim.ref) ?? "user"); + activator.removeMarketplace(registration.hostName, "user"); + } + for (const { toolId, plugin, files } of safeFiles) { + await deletePluginFilesForTool(files, "user", toolId, options.projectRoot, this.fs, "user"); + manifest.removePlugin(toolId, plugin.name); + } + for (const { toolId, registrations, registration } of nativeCalls) { + manifest.setNativeRegistrations(toolId, { + ...registrations, + marketplaces: registrations.marketplaces.filter( + (entry) => entry.alias !== registration.alias + ), + pluginRefs: registrations.pluginRefs.filter( + (ref) => !ref.endsWith(`@${registration.hostName}`) + ), + pluginClaims: (registrations.pluginClaims ?? []).filter( + (claim) => !claim.ref.endsWith(`@${registration.hostName}`) + ), + }); + } + await this.registry.delete(options.projectRoot, marketplace.name, "user"); + await this.repo.save(manifest); + return { marketplace, removedPluginCount: plugins.length + nativeRefs.length, orphanCount: 0 }; + } +} diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts b/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts new file mode 100644 index 000000000..03fb25508 --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/user-plugin-distribution-loader.ts @@ -0,0 +1,22 @@ +import { join } from "node:path"; +import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js"; +import type { PluginFetcher } from "../../../distribution/domain/ports/plugin-fetcher.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; + +export class UserPluginDistributionLoader { + constructor( + private readonly fetcher: PluginFetcher, + private readonly reader: PluginDistributionReader + ) {} + + async loadLatest(plugin: InstalledPlugin, projectRoot: string): Promise { + const localPath = await this.fetcher.fetch( + plugin.source, + join(projectRoot, PLUGIN_CACHE_SUBDIR), + { forceRefresh: true } + ); + return this.reader.read(localPath); + } +} diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts new file mode 100644 index 000000000..3d4c99d4c --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/user-plugin-file-updater.ts @@ -0,0 +1,184 @@ +import { homedir as nodeHomedir } from "node:os"; +import { join } from "node:path"; +import type { InstallationFile } from "../../../../kernel/file.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; +import type { Hasher } from "../../../../kernel/ports/hasher.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import { compareSemver } from "../../../../kernel/semver.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import { + frameworkBuildModeFor, + getToolConfig, + resolvePluginsCapability, +} from "../../../tools/domain/registry.js"; +import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; +import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; +import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { + readBuiltUserPluginFiles, + withoutHooksPrefix, +} from "../framework/translator/built-tree-materialization-translator.js"; +import { withoutHooks } from "../framework/translator/project-hooks-materializer.js"; +import { deleteOldFiles, writePluginFiles } from "../plugin/plugin-helpers.js"; +import { resolveBaseDirFromRecord } from "../plugin/plugin-target-resolution.js"; +import type { BuiltMaterializationDeps } from "../shared/apply-plugin-files-use-case.js"; +import { + assertUserScopeWriteBoundary, + userScopeFilesSafeToDelete, +} from "../shared/user-scope-plugin-files.js"; +import type { UserPluginDistributionLoader } from "./user-plugin-distribution-loader.js"; + +export interface PlannedUserPluginFileUpdate { + readonly plugin: InstalledPlugin; + readonly toolId: AiToolId; + readonly home: string; + readonly baseDir: string; + readonly files: readonly InstallationFile[]; + readonly oldSafe: ReadonlyMap; + readonly next: InstalledPlugin; +} + +export class UserPluginFileUpdater { + constructor( + private readonly fs: FileReader & FileWriter, + private readonly loader: UserPluginDistributionLoader, + private readonly hasher: Hasher, + private readonly builtDeps?: BuiltMaterializationDeps + ) {} + + async update( + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string, + logger: Logger + ): Promise { + const plan = await this.planUpdate(plugin, toolId, projectRoot, logger); + return plan === null ? null : this.applyUpdate(plan, logger); + } + + async planUpdate( + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string, + logger: Logger + ): Promise { + const dist = await this.loader.loadLatest(plugin, projectRoot); + if (compareSemver(dist.manifest.version, plugin.version) <= 0) return null; + const { files, componentPaths } = await this.materializeFiles( + dist, + toolId, + plugin, + projectRoot + ); + const home = (this.builtDeps?.homedir ?? nodeHomedir)(); + const oldSafe = await userScopeFilesSafeToDelete(this.fs, logger, plugin, toolId, home); + if (oldSafe.size !== plugin.files.size) { + throw new Error( + `${toolId}: '${plugin.name}' has unsafe recorded files; update refused without dropping its machine claim.` + ); + } + await assertUserScopeWriteBoundary(this.fs, toolId, plugin.name, files, home); + const baseDir = resolveBaseDirFromRecord("user", toolId, projectRoot, () => home); + await this.assertNewFilesDoNotCollide(plugin, toolId, baseDir, files); + return { + plugin, + toolId, + home, + baseDir, + files, + oldSafe, + next: InstalledPlugin.fromDistribution( + dist, + plugin.source, + files, + "user", + componentPaths, + plugin.marketplace + ).withDependents(plugin.dependents), + }; + } + + async applyUpdate(plan: PlannedUserPluginFileUpdate, logger: Logger): Promise { + const oldSafe = await userScopeFilesSafeToDelete( + this.fs, + logger, + plan.plugin, + plan.toolId, + plan.home + ); + if (oldSafe.size !== plan.plugin.files.size || oldSafe.size !== plan.oldSafe.size) { + throw new Error( + `${plan.toolId}: '${plan.plugin.name}' has unsafe recorded files since preflight; update refused before writing.` + ); + } + await assertUserScopeWriteBoundary( + this.fs, + plan.toolId, + plan.plugin.name, + [...plan.files], + plan.home + ); + await this.assertNewFilesDoNotCollide(plan.plugin, plan.toolId, plan.baseDir, plan.files); + await writePluginFiles([...plan.files], plan.baseDir, this.fs); + const newPaths = new Set(plan.files.map((file) => file.relativePath)); + await deleteOldFiles( + new Map([...plan.oldSafe].filter(([path]) => !newPaths.has(path))), + plan.baseDir, + this.fs + ); + return plan.next; + } + + private async assertNewFilesDoNotCollide( + plugin: InstalledPlugin, + toolId: AiToolId, + baseDir: string, + files: readonly InstallationFile[] + ): Promise { + for (const file of files) { + if (plugin.files.has(file.relativePath)) continue; + if (await this.fs.fileExists(join(baseDir, file.relativePath))) { + throw new Error( + `${toolId}: '${plugin.name}' has untracked existing file '${file.relativePath}'; update refused without overwriting user content or changing its machine claim.` + ); + } + } + } + + private async materializeFiles( + dist: PluginDistribution, + toolId: AiToolId, + plugin: InstalledPlugin, + projectRoot: string + ): Promise<{ files: InstallationFile[]; componentPaths: ReadonlyMap }> { + const marketplace = + plugin.marketplace === undefined + ? undefined + : (await this.builtDeps?.marketplaceRegistry.list(projectRoot))?.find( + (entry) => entry.name === plugin.marketplace + ); + if (marketplace !== undefined && this.builtDeps !== undefined) { + const { builtDir } = await this.builtDeps.ensureBuilt.execute({ + projectRoot, + marketplace, + target: toolId, + mode: frameworkBuildModeFor(toolId), + }); + const built = await readBuiltUserPluginFiles(this.fs, this.hasher, builtDir, plugin.name); + const files = + resolvePluginsCapability(toolId)?.hooksDestination === "project" + ? withoutHooksPrefix(built, plugin.name) + : built; + return { files, componentPaths: new Map() }; + } + const toolConfig = getToolConfig(toolId); + const forGlobalFiles = + resolvePluginsCapability(toolId)?.hooksDestination === "project" ? withoutHooks(dist) : dist; + const translated = new PluginContentTranslator(this.hasher).translateWithComponentPaths( + forGlobalFiles, + toolConfig + ); + return { files: translated.files, componentPaths: translated.componentPaths }; + } +} diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts b/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts new file mode 100644 index 000000000..88ab22c8c --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/user-plugin-ownership.ts @@ -0,0 +1,40 @@ +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; +import type { AiToolId } from "../../../../kernel/tool.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; + +/** Release only this project's claim; machine files remain until explicit user cleanup. */ +export async function detachUserPlugin( + repo: ManifestRepository | undefined, + fs: FileReader, + toolId: AiToolId, + name: string, + projectRoot: string +): Promise { + if (repo === undefined) return; + if (repo.withExclusiveAccess !== undefined) { + return repo.withExclusiveAccess(() => + detachUserPluginUnlocked(repo, fs, toolId, name, projectRoot) + ); + } + return detachUserPluginUnlocked(repo, fs, toolId, name, projectRoot); +} + +async function detachUserPluginUnlocked( + repo: ManifestRepository, + fs: FileReader, + toolId: AiToolId, + name: string, + projectRoot: string +): Promise { + const manifest = await repo.load(); + if (manifest === null) return; + const plugin = manifest.getPlugins(toolId).find((p) => p.name === name); + if (plugin === undefined || plugin.scope !== "user") return; + const root = await fs.realpath(projectRoot); + if (!plugin.dependents.includes(root)) return; + manifest.updatePlugin( + toolId, + plugin.withDependents(plugin.dependents.filter((dependent) => dependent !== root)) + ); + await repo.save(manifest); +} diff --git a/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts new file mode 100644 index 000000000..2978e3fcf --- /dev/null +++ b/cli/src/contexts/framework/application/ownership/user-plugin-update-use-case.ts @@ -0,0 +1,120 @@ +import { PluginNotFoundError } from "../../../../kernel/errors.js"; +import type { Logger } from "../../../../kernel/ports/logger.js"; +import { Manifest } from "../../domain/manifest.js"; +import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { resolvePluginToolIds } from "../plugin/plugin-target-resolution.js"; +import type { PluginUpdateOptions } from "../plugin/plugin-update-use-case.js"; +import type { NativeHostRegistrationGate } from "./native-host-registration-gate.js"; +import type { UserPluginFileUpdater } from "./user-plugin-file-updater.js"; + +export class UserPluginUpdateUseCase { + constructor( + private readonly repo: ManifestRepository, + private readonly fileUpdater: UserPluginFileUpdater, + private readonly logger: Logger, + private readonly nativeHost: NativeHostRegistrationGate + ) {} + + async execute(options: PluginUpdateOptions): Promise { + const run = () => this.updateLocked(options); + return this.repo.withExclusiveAccess === undefined ? run() : this.repo.withExclusiveAccess(run); + } + + private async updateLocked(options: PluginUpdateOptions): Promise { + const loaded = await this.repo.load(); + if (loaded === null) + throw new Error( + "No machine manifest: cannot prove AIDD ownership for user-scope plugin update." + ); + const manifest = Manifest.fromJSON(loaded.toJSON()); + const toolIds = resolvePluginToolIds(options.toolIds, manifest); + const targets = toolIds.flatMap((toolId) => + manifest + .getPlugins(toolId) + .filter( + (plugin) => + plugin.scope === "user" && + (options.pluginNames === undefined || options.pluginNames.includes(plugin.name)) + ) + .map((plugin) => ({ toolId, plugin })) + ); + const nativeTargets = toolIds.flatMap((toolId) => + (manifest.getNativeRegistrations(toolId)?.pluginClaims ?? []) + .filter( + (claim) => + options.pluginNames === undefined || + options.pluginNames.some((name) => + name.includes("@") ? claim.ref === name : claim.ref.startsWith(`${name}@`) + ) + ) + .map((claim) => ({ toolId, claim, registrations: manifest.getNativeRegistrations(toolId) })) + ); + if (options.pluginNames !== undefined && targets.length === 0 && nativeTargets.length === 0) { + throw new PluginNotFoundError(options.pluginNames.join(", ")); + } + if ( + options.pluginNames?.some( + (name) => + !name.includes("@") && + nativeTargets.filter(({ claim }) => claim.ref.startsWith(`${name}@`)).length > 1 + ) + ) { + throw new Error( + "Multiple native catalogues match this plugin name; use an exact @ ref for targeted update." + ); + } + const filePlans = await Promise.all( + targets.map(async ({ toolId, plugin }) => ({ + toolId, + plugin, + plan: await this.fileUpdater.planUpdate(plugin, toolId, options.projectRoot, this.logger), + })) + ); + const nativeUpdates = await Promise.all( + nativeTargets.map(async ({ toolId, claim, registrations }) => ({ + toolId, + claim, + activator: await this.nativeHost.requireTargetedUpdate( + toolId, + claim, + options.projectRoot, + registrations + ), + })) + ); + const updated: string[] = []; + for (const { toolId, claim, activator } of nativeUpdates) { + if (claim.dependents.length > 0) + this.logger.warn( + `${toolId}: updating native ref '${claim.ref}' affects ${claim.dependents.join(", ")}; projects must refresh their local integrations afterward.` + ); + if (activator.updatePlugin === undefined) + throw new Error( + `${toolId}: targeted native update became unavailable; canonical claims retained.` + ); + activator.updatePlugin(claim.ref); + updated.push(claim.ref); + } + for (const { toolId, plugin, plan } of filePlans) { + if (plan === null) continue; + if (plugin.dependents.length > 0) + this.logger.warn( + `${toolId}: updating user-scope '${plugin.name}' affects ${plugin.dependents.join(", ")}; each project must refresh its own integration afterward.` + ); + let next: InstalledPlugin; + try { + next = await this.fileUpdater.applyUpdate(plan, this.logger); + } catch (error) { + throw new Error( + `${toolId}: user plugin file update failed after preflight; a native ref may already have been updated and file bytes may be partial. Canonical claim not saved; reconcile manually before retrying.`, + { cause: error } + ); + } + manifest.updatePlugin(toolId, next); + updated.push(plugin.name); + } + if (updated.length > 0) await this.repo.save(manifest); + return updated; + } +} diff --git a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts index 2da057bb5..f69c8cc8b 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-add-use-case.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { DuplicatePluginError, MissingPluginMetadataError, + ToolNotInManifestError, VersionMismatchError, } from "../../../../kernel/errors.js"; import type { InstallationFile } from "../../../../kernel/file.js"; @@ -20,13 +21,18 @@ import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; import { PluginContentTranslator } from "../../../translate/domain/content-translator.js"; import type { PluginDistribution } from "../../../translate/domain/plugin-distribution.js"; import type { ReadonlySkipList } from "../../../translate/domain/plugin-translation-skip.js"; -import type { Manifest } from "../../domain/manifest.js"; -import { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; +import { Manifest } from "../../domain/manifest.js"; +import { + InstalledPlugin, + type ProjectHooksProvenance, +} from "../../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { PluginDistributionReader } from "../../domain/ports/plugin-distribution-reader.js"; import type { PluginTranslator } from "../framework/translator/plugin-translator.js"; import { resolvePluginTranslator } from "../framework/translator/resolve-plugin-translator.js"; +import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js"; import type { EnsureBuiltMarketplace } from "../shared/ensure-built-marketplace-use-case.js"; +import { assertProjectHooksRemovable } from "../shared/remove-project-hooks.js"; import { loadPluginManifest, writePluginFiles } from "./plugin-helpers.js"; import { resolveBaseDirFromRecord, @@ -59,26 +65,38 @@ export class PluginAddUseCase implements PluginAdd { private readonly hasher: Hasher, private readonly logger: Logger, private readonly marketplaceRegistry: MarketplaceRegistry, - private readonly ensureBuilt: EnsureBuiltMarketplace + private readonly ensureBuilt: EnsureBuiltMarketplace, + private readonly userManifestRepo: ManifestRepository ) {} async execute(options: PluginAddOptions): Promise { + if (this.userManifestRepo.withExclusiveAccess !== undefined) { + return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(options)); + } + return this.executeLocked(options); + } + + private async executeLocked(options: PluginAddOptions): Promise { const { source, toolIds, projectRoot, marketplace } = options; - const manifest = await loadPluginManifest(this.manifestRepo); + const manifest = Manifest.fromJSON((await loadPluginManifest(this.manifestRepo)).toJSON()); + const existingMachine = await this.userManifestRepo.load(); + const machine = + existingMachine === null ? Manifest.create() : Manifest.fromJSON(existingMachine.toJSON()); const resolvedToolIds = resolvePluginToolIds(toolIds, manifest); const installedBefore = pluginsInstalledBefore(manifest, resolvedToolIds); if (marketplace !== undefined && (await this.isGithubMarketplace(marketplace, projectRoot))) { - await this.addGithubMarketplacePlugin(options, resolvedToolIds, manifest, installedBefore); + await this.addGithubMarketplacePlugin(options, resolvedToolIds, manifest, machine); } else { - await this.addLocalPlugin( - options, - resolvedToolIds, - manifest, - source, - projectRoot, - installedBefore - ); + await this.addLocalPlugin(options, resolvedToolIds, manifest, source, projectRoot, machine); } + const claimed = await this.recordUserScopeOwnership( + manifest, + machine, + resolvedToolIds, + projectRoot, + installedBefore + ); + if (claimed) await this.userManifestRepo.save(machine); await this.manifestRepo.save(manifest); } @@ -92,10 +110,17 @@ export class PluginAddUseCase implements PluginAdd { options: PluginAddOptions, toolIds: AiToolId[], manifest: Manifest, - installedBefore: PluginsInstalledBefore + machine: Manifest ): Promise { const { pluginMetadata } = options; if (pluginMetadata === undefined) throw new MissingPluginMetadataError(); + if (options.replace === true) + await this.assertExistingContributionsUnchanged( + pluginMetadata.name, + toolIds, + manifest, + options.projectRoot + ); if (options.replace === true) this.dropExistingPlugin(pluginMetadata.name, toolIds, manifest); else this.validateNoDuplicates(pluginMetadata.name, toolIds, manifest); const adapterMap = this.buildAdapterMap(toolIds); @@ -108,7 +133,7 @@ export class PluginAddUseCase implements PluginAdd { manifest, options.source, options.projectRoot, - installedBefore + machine ); } await this.registerNativeGithubPlugins(options, nativeToolIds, manifest); @@ -171,14 +196,26 @@ export class PluginAddUseCase implements PluginAdd { manifest: Manifest, source: PluginSource, projectRoot: string, - installedBefore: PluginsInstalledBefore + machine: Manifest ): Promise { const { marketplace, requiredVersion, replace, pluginMetadata } = options; const read = await this.readDistribution(source, projectRoot); const dist = pluginMetadata === undefined ? read : read.withStrict(pluginMetadata.strict); const pluginName = dist.manifest.name; this.assertPluginVersionMatches(pluginName, dist.manifest.version, requiredVersion); - const { prevMcpMap } = this.prepareForInstall(pluginName, resolvedToolIds, manifest, replace); + if (replace === true) + await this.assertExistingContributionsUnchanged( + pluginName, + resolvedToolIds, + manifest, + projectRoot + ); + const { prevMcpMap, prevHooksMap } = this.prepareForInstall( + pluginName, + resolvedToolIds, + manifest, + replace + ); await this.installPluginForAllTools( dist, resolvedToolIds, @@ -187,23 +224,48 @@ export class PluginAddUseCase implements PluginAdd { manifest, marketplace, prevMcpMap, - installedBefore + prevHooksMap, + machine ); } + private async assertExistingContributionsUnchanged( + pluginName: string, + toolIds: readonly AiToolId[], + manifest: Manifest, + projectRoot: string + ): Promise { + for (const toolId of toolIds) { + const previous = manifest.getPlugins(toolId).find((plugin) => plugin.name === pluginName); + if (previous === undefined) continue; + await assertProjectMcpEntriesRemovable(this.fs, previous, toolId, projectRoot); + await assertProjectHooksRemovable(this.fs, previous, toolId, projectRoot); + } + } + private prepareForInstall( pluginName: string, toolIds: AiToolId[], manifest: Manifest, replace: boolean | undefined - ): { prevMcpMap: Map> } { + ): { + prevMcpMap: Map>; + prevHooksMap: Map; + } { const prevMcpMap = this.collectPreviousMcpEntries(pluginName, toolIds, manifest); + const prevHooksMap = new Map(); + for (const toolId of toolIds) { + const projectHooks = manifest + .getPlugins(toolId) + .find((p) => p.name === pluginName)?.projectHooks; + if (projectHooks !== undefined) prevHooksMap.set(toolId, projectHooks); + } if (replace === true) { this.dropExistingPlugin(pluginName, toolIds, manifest); } else { this.validateNoDuplicates(pluginName, toolIds, manifest); } - return { prevMcpMap }; + return { prevMcpMap, prevHooksMap }; } private async installPluginForAllTools( @@ -214,7 +276,8 @@ export class PluginAddUseCase implements PluginAdd { manifest: Manifest, marketplace: string | undefined, prevMcpMap: Map>, - installedBefore: PluginsInstalledBefore + prevHooksMap: Map, + machine: Manifest ): Promise { const allSkipped: ReadonlySkipList[] = []; const allNotices: ReadonlyNoticeList[] = []; @@ -223,7 +286,7 @@ export class PluginAddUseCase implements PluginAdd { dist.manifest.name, toolId, projectRoot, - installedBefore + machine ); if (foreignDir !== undefined) { this.logger.warn( @@ -239,7 +302,9 @@ export class PluginAddUseCase implements PluginAdd { manifest, marketplace, prev, - foreignDir !== undefined + foreignDir !== undefined || + machine.getPlugins(toolId).some((p) => p.name === dist.manifest.name), + prevHooksMap.get(toolId) ); allSkipped.push(skipped); allNotices.push(notices); @@ -252,10 +317,10 @@ export class PluginAddUseCase implements PluginAdd { pluginName: string, toolId: AiToolId, projectRoot: string, - installedBefore: PluginsInstalledBefore + machine: Manifest ): Promise { if (resolveScopeForInstall(toolId) !== "user") return undefined; - if (installedBefore.has(installedKey(toolId, pluginName))) return undefined; + if (machine.getPlugins(toolId).some((p) => p.name === pluginName)) return undefined; const dir = join( resolveBaseDirFromRecord("user", toolId, projectRoot, nodeHomedir), pluginName @@ -264,6 +329,38 @@ export class PluginAddUseCase implements PluginAdd { return present.length > 0 ? dir : undefined; } + private async recordUserScopeOwnership( + project: Manifest, + machine: Manifest, + toolIds: readonly AiToolId[], + projectRoot: string, + installedBefore: PluginsInstalledBefore + ): Promise { + const root = await this.fs.realpath(projectRoot); + let claimed = false; + for (const toolId of toolIds) { + for (const plugin of project.getPlugins(toolId)) { + if (plugin.scope !== "user") continue; + const owned = machine.getPlugins(toolId).find((p) => p.name === plugin.name); + if (owned === undefined && installedBefore.has(installedKey(toolId, plugin.name))) continue; + if (owned === undefined && plugin.files.size === 0) continue; + if (!machine.hasTool(toolId)) { + const version = project.getToolVersion(toolId); + if (version === undefined) throw new ToolNotInManifestError(toolId); + machine.addTool(toolId, version, []); + } + const canonical = ( + owned ?? InstalledPlugin.withMcpEntries(plugin, new Map()) + ).withDependents([...(owned?.dependents ?? []), root]); + if (owned === undefined) machine.addPlugin(toolId, canonical); + else machine.updatePlugin(toolId, canonical); + project.updatePlugin(toolId, plugin.withFiles(new Map())); + claimed = true; + } + } + return claimed; + } + private collectPreviousMcpEntries( pluginName: string, toolIds: AiToolId[], @@ -310,7 +407,8 @@ export class PluginAddUseCase implements PluginAdd { manifest: Manifest, marketplace: string | undefined, previousMcpEntries: ReadonlyMap, - userScopeDirTaken: boolean + userScopeDirTaken: boolean, + previousProjectHooks?: ProjectHooksProvenance ): Promise<{ skipped: ReadonlySkipList; notices: ReadonlyNoticeList }> { const toolConfig = getToolConfig(toolId); if (!isAiTool(toolConfig)) return { skipped: [], notices: [] }; @@ -324,7 +422,8 @@ export class PluginAddUseCase implements PluginAdd { manifest, marketplace, previousMcpEntries, - userScopeDirTaken + userScopeDirTaken, + previousProjectHooks ); return { ...result, notices: [] }; } diff --git a/cli/src/contexts/framework/application/plugin/plugin-helpers.ts b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts index 6add4784a..49f448b25 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-helpers.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-helpers.ts @@ -50,8 +50,10 @@ export async function deletePluginFilesForTool( scope: PluginScope, toolId: AiToolId, projectRoot: string, - fs: FileWriter + fs: FileWriter, + ownerScope: "project" | "user" = "project" ): Promise { + if (scope === "user" && ownerScope !== "user") return []; const baseDir = resolveBaseDirFromRecord(scope, toolId, projectRoot, nodeHomedir); const deleted: string[] = []; for (const relativePath of files.keys()) { @@ -100,7 +102,10 @@ export async function materializeViaTranslator( plugin.source, projectRoot, manifest, - plugin.marketplace + plugin.marketplace, + plugin.mcpEntries, + false, + plugin.projectHooks ); return written ?? 0; } diff --git a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts index d47b8d41c..66d5cd22d 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-remove-use-case.ts @@ -1,16 +1,24 @@ import { homedir as nodeHomedir } from "node:os"; import { dirname, join } from "node:path"; -import { NativePluginCliError, PluginNotFoundError } from "../../../../kernel/errors.js"; +import { + ActiveMachineDependentsError, + NativePluginCliError, + PluginNotFoundError, +} from "../../../../kernel/errors.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; import { resolveHomeDir } from "../../../../kernel/reading/home-dir.js"; import type { MarketplaceScope } from "../../../../kernel/scope.js"; -import type { AiToolId } from "../../../../kernel/tool.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; import type { MarketplaceRegistry } from "../../../distribution/domain/ports/marketplace-registry.js"; import type { McpCapability } from "../../../tools/domain/capabilities/mcp-capability.js"; import { unmergeOpencodeMcp } from "../../../tools/domain/formats/opencode-mcp-merge.js"; -import type { HostPluginRegistryReader } from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../../../tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../tools/domain/ports/native-plugin-activator.js"; import { getToolConfig, @@ -24,8 +32,16 @@ import type { Manifest } from "../../domain/manifest.js"; import type { InstalledPlugin } from "../../domain/plugins/installed-plugin.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import type { UserSourceReferences } from "../../domain/ports/user-source-references.js"; +import { inspectNativeMarketplaceSource } from "../ownership/native-marketplace-source-proof.js"; +import { + detachNativePluginRefs, + isMachineOwnedNativeRef, +} from "../ownership/native-plugin-ownership.js"; +import { assertProjectPathWithinRoot } from "../ownership/project-path-boundary.js"; +import { assertProjectMcpEntriesRemovable } from "../ownership/project-plugin-cleanup.js"; +import { detachUserPlugin } from "../ownership/user-plugin-ownership.js"; import { resolveCacheCandidate } from "../shared/purge-declared-cache.js"; -import { removeProjectHooks } from "../shared/remove-project-hooks.js"; +import { assertProjectHooksRemovable, removeProjectHooks } from "../shared/remove-project-hooks.js"; import { resolveUninstallScopeOrder } from "../shared/resolve-uninstall-scope.js"; import { describeGuardedPluginRefMessage, @@ -35,7 +51,8 @@ import { resolveProjectRootForReferences, toleratingUnreadableSourceReferences, } from "../shared/shared-source-reference-support.js"; -import { loadPluginManifest } from "./plugin-helpers.js"; +import { userScopeFilesSafeToDelete } from "../shared/user-scope-plugin-files.js"; +import { deletePluginFilesForTool, loadPluginManifest } from "./plugin-helpers.js"; import { isFrameworkPrimeFlatMcp, resolveBaseDirFromRecord, @@ -46,6 +63,7 @@ export interface PluginRemoveOptions { pluginName: string; toolIds: AiToolId[] | "all"; projectRoot: string; + scope?: "project" | "user"; } export class PluginRemoveUseCase { @@ -69,16 +87,230 @@ export class PluginRemoveUseCase { /** Resolves the scope this project's own registry recorded for `plugin.marketplace` — the one * fact `frameworkSourceIsShared` needs and a plugin record does not carry. Absent treats every * marketplace as not shared. */ - private readonly marketplaceRegistry?: MarketplaceRegistry + private readonly marketplaceRegistry?: MarketplaceRegistry, + private readonly userManifestRepo?: ManifestRepository, + private readonly nativeMarketplaceSources: ReadonlyMap< + AiToolId, + NativeMarketplaceSourceReader + > = new Map() ) {} async execute(options: PluginRemoveOptions): Promise { + if (options.scope === "user") { + if (this.userManifestRepo === undefined) + throw new Error("User manifest repository is required for user-scope plugin removal."); + if (this.userManifestRepo.withExclusiveAccess !== undefined) { + return this.userManifestRepo.withExclusiveAccess(() => this.removeUserPlugin(options)); + } + return this.removeUserPlugin(options); + } const { pluginName, toolIds, projectRoot } = options; const manifest = await loadPluginManifest(this.manifestRepo); const resolvedToolIds = resolvePluginToolIds(toolIds, manifest); + for (const toolId of resolvedToolIds) { + const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName); + if (plugin === undefined) continue; + await assertProjectMcpEntriesRemovable(this.fs, plugin, toolId, projectRoot); + await assertProjectHooksRemovable(this.fs, plugin, toolId, projectRoot); + await this.assertProjectNativeSource( + plugin, + toolId, + projectRoot, + manifest.getNativeRegistrations(toolId) + ); + } + const userTools = resolvedToolIds.filter((toolId) => + manifest.getPlugins(toolId).some((p) => p.name === pluginName && p.scope === "user") + ); + const nativeRefs = new Map(); + for (const toolId of resolvedToolIds) { + const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName); + const registrations = manifest.getNativeRegistrations(toolId); + if (plugin?.marketplace === undefined || registrations === undefined) continue; + const hostName = registrations.marketplaces.find( + (m) => m.alias === plugin.marketplace + )?.hostName; + if (hostName !== undefined) nativeRefs.set(toolId, [`${pluginName}@${hostName}`]); + } const removed = await this.removeFromTools(pluginName, resolvedToolIds, projectRoot, manifest); if (!removed) throw new PluginNotFoundError(pluginName); await this.manifestRepo.save(manifest); + await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs); + for (const toolId of userTools) { + await detachUserPlugin(this.userManifestRepo, this.fs, toolId, pluginName, projectRoot); + } + } + + private async assertProjectNativeSource( + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string, + registrations: NativeRegistrations | undefined + ): Promise { + const activation = resolvePluginsCapability(toolId)?.nativeActivation; + if (activation == null || plugin.marketplace === undefined) return; + const activator = this.activators.get(activation.binary); + if (activator === undefined || !activator.isAvailable()) return; + const registration = registrations?.marketplaces.find( + (entry) => entry.alias === plugin.marketplace + ); + if (registration === undefined) + throw new Error( + `${toolId}: '${plugin.name}' has no recorded native catalogue source for '${plugin.marketplace}'; reconcile manually before removal.` + ); + const ref = `${plugin.name}@${registration.hostName}`; + if ( + pluginEnablementIsMachineGlobal(toolId) && + !(await isMachineOwnedNativeRef(this.userManifestRepo, toolId, ref)) + ) + throw new Error( + `${toolId}: '${ref}' is an unclaimed machine-global host ref; canonical machine claim required before removal.` + ); + if ( + registrations?.pluginRefs.includes(ref) !== true && + this.activators.get(activation.binary)?.enablesPlugins() !== true + ) + throw new Error( + `${toolId}: '${ref}' is not a ref this project enabled; reconcile manually before removal.` + ); + const proof = await inspectNativeMarketplaceSource( + this.nativeMarketplaceSources.get(toolId), + projectRoot, + registration + ); + if (proof.status !== "owned") { + throw new Error( + proof.reason ?? + `${toolId}: catalogue '${registration.hostName}' has unproven host source; reconcile manually.` + ); + } + if (pluginEnablementIsMachineGlobal(toolId) || registrations?.pluginRefs.includes(ref) !== true) + return; + const reader = this.hostPluginRegistries.get(toolId); + if (reader === undefined) + throw new Error( + `${toolId}: '${ref}' has no readable host plugin registry; uninstall refused before project changes.` + ); + let reading: HostPluginRegistryReading; + try { + reading = await reader.read(projectRoot); + } catch (error) { + throw new Error( + `${toolId}: '${ref}' host plugin registry read failed (${error instanceof Error ? error.message : String(error)}); uninstall refused.` + ); + } + const current = reading.refs?.get(ref); + if (current?.enabled !== true || (current.scope !== "project" && current.scope !== "user")) + throw new Error( + `${toolId}: '${ref}' is not provably enabled with an exact host scope (${reading.unreadable ?? reading.location}); uninstall refused.` + ); + } + + private async removeUserPlugin(options: PluginRemoveOptions): Promise { + const repo = this.userManifestRepo; + if (repo === undefined) + throw new Error("User manifest repository is required for user-scope plugin removal."); + const manifest = await loadPluginManifest(repo); + const toolIds = resolvePluginToolIds(options.toolIds, manifest); + const targets = toolIds.flatMap((toolId) => { + const plugin = manifest + .getPlugins(toolId) + .find((p) => p.name === options.pluginName && p.scope === "user"); + return plugin === undefined ? [] : [{ toolId, plugin }]; + }); + const nativeTargets = toolIds.flatMap((toolId) => { + const registrations = manifest.getNativeRegistrations(toolId); + return (registrations?.pluginClaims ?? []) + .filter((claim) => + options.pluginName.includes("@") + ? claim.ref === options.pluginName + : claim.ref.startsWith(`${options.pluginName}@`) + ) + .map((claim) => ({ toolId, registrations: registrations as NativeRegistrations, claim })); + }); + if (targets.length === 0 && nativeTargets.length === 0) + throw new PluginNotFoundError(options.pluginName); + if (!options.pluginName.includes("@") && nativeTargets.length > 1) { + throw new Error( + `Native plugin '${options.pluginName}' has multiple catalogues; use the exact @ ref.` + ); + } + const dependents = [ + ...new Set([ + ...targets.flatMap(({ plugin }) => plugin.dependents), + ...nativeTargets.flatMap(({ claim }) => claim.dependents), + ]), + ]; + if (dependents.length > 0) { + throw new ActiveMachineDependentsError( + `user-scope plugin '${options.pluginName}'`, + dependents + ); + } + const safeFiles = await Promise.all( + targets.map(async ({ toolId, plugin }) => { + const files = await userScopeFilesSafeToDelete( + this.fs, + this.logger, + plugin, + toolId, + resolveHomeDir() + ); + if (files.size !== plugin.files.size) + throw new Error( + `Refusing partial removal of user-scope plugin '${plugin.name}': a tracked file escaped its boundary.` + ); + return { toolId, plugin, files }; + }) + ); + const nativeRemoval = await Promise.all( + nativeTargets.map(async ({ toolId, registrations, claim }) => { + const activator = this.activators.get(registrations.binary); + const reader = this.hostPluginRegistries.get(toolId); + if (activator === undefined || !activator.isAvailable() || reader === undefined) { + throw new Error( + `Cannot prove or remove AIDD-owned native ref '${claim.ref}': ${registrations.binary} CLI or host registry is unavailable.` + ); + } + const onHost = (await reader.read(options.projectRoot)).refs?.get(claim.ref); + if (onHost?.enabled !== true) + throw new Error( + `Cannot prove native ref '${claim.ref}' is still enabled on ${registrations.binary}; no host mutation made.` + ); + const catalogues = registrations.marketplaces.filter((registration) => + claim.ref.endsWith(`@${registration.hostName}`) + ); + if (catalogues.length !== 1) { + throw new Error( + `Native ref '${claim.ref}' has no exact canonical catalogue source proof; removal refused.` + ); + } + const proof = await inspectNativeMarketplaceSource( + this.nativeMarketplaceSources.get(toolId), + options.projectRoot, + catalogues[0] + ); + if (proof.status !== "owned") { + throw new Error(proof.reason ?? `Native ref '${claim.ref}' has unproven host source.`); + } + return { toolId, registrations, claim, activator, scope: onHost.scope ?? "user" }; + }) + ); + for (const target of nativeRemoval) { + target.activator.uninstallPlugin(target.claim.ref, target.scope); + const claims = + target.registrations.pluginClaims?.filter((claim) => claim.ref !== target.claim.ref) ?? []; + manifest.setNativeRegistrations(target.toolId, { + ...target.registrations, + pluginRefs: target.registrations.pluginRefs.filter((ref) => ref !== target.claim.ref), + pluginClaims: claims, + }); + } + for (const { toolId, plugin, files } of safeFiles) { + await deletePluginFilesForTool(files, "user", toolId, options.projectRoot, this.fs, "user"); + manifest.removePlugin(toolId, plugin.name); + } + await repo.save(manifest); } private async removeFromTools( @@ -92,13 +324,36 @@ export class PluginRemoveUseCase { const plugins = manifest.getPlugins(toolId); const plugin = plugins.find((p) => p.name === pluginName); if (plugin === undefined) continue; + const registrations = manifest.getNativeRegistrations(toolId); const baseDir = resolveBaseDirFromRecord(plugin.scope, toolId, projectRoot, nodeHomedir); - const confirmed = await this.removeNativeActivation(plugin, toolId, projectRoot, manifest); + const confirmed = await this.removeNativeActivation( + plugin, + toolId, + projectRoot, + registrations + ); if (confirmed !== undefined) - await this.purgeCachedPlugin(manifest, toolId, plugin, confirmed); - await this.deletePluginFiles(plugin.files, baseDir); + await this.purgeCachedPlugin(registrations, toolId, plugin, confirmed); + if (plugin.scope !== "user") await this.deletePluginFiles(plugin.files, baseDir); await this.removeMcpEntries(plugin, toolId, projectRoot); - await removeProjectHooks(this.fs, pluginName, toolId, projectRoot); + await removeProjectHooks(this.fs, plugin, toolId, projectRoot); + const hostName = registrations?.marketplaces.find( + (m) => m.alias === plugin.marketplace + )?.hostName; + const ref = hostName === undefined ? undefined : `${plugin.name}@${hostName}`; + const canDetachRef = + confirmed === true || + (ref !== undefined && + pluginEnablementIsMachineGlobal(toolId) && + (await isMachineOwnedNativeRef(this.userManifestRepo, toolId, ref))); + if (hostName !== undefined && registrations !== undefined && canDetachRef) { + manifest.setNativeRegistrations(toolId, { + ...registrations, + pluginRefs: registrations.pluginRefs.filter( + (ref) => ref !== `${plugin.name}@${hostName}` + ), + }); + } manifest.removePlugin(toolId, pluginName); removed = true; } @@ -117,14 +372,21 @@ export class PluginRemoveUseCase { plugin: InstalledPlugin, toolId: AiToolId, projectRoot: string, - manifest: Manifest + registrations: NativeRegistrations | undefined ): Promise { const nativeActivation = resolvePluginsCapability(toolId)?.nativeActivation; if (nativeActivation == null || plugin.marketplace === undefined) return undefined; const activator = this.activators.get(nativeActivation.binary); if (activator === undefined) return undefined; + if (!activator.isAvailable()) { + const hostName = this.hostNameFor(registrations, plugin.marketplace) ?? plugin.marketplace; + this.logger.warn( + `${nativeActivation.binary} CLI not found on PATH — '${plugin.name}@${hostName}' was not uninstalled from the host; its ref may remain enabled after local removal.` + ); + return false; + } + await this.assertProjectNativeSource(plugin, toolId, projectRoot, registrations); const alias = plugin.marketplace; - const registrations = manifest.getNativeRegistrations(toolId); const registeredHostName = this.hostNameFor(registrations, alias); if (registeredHostName === undefined && registrations !== undefined) { this.logger.warn( @@ -133,6 +395,12 @@ export class PluginRemoveUseCase { } const hostName = registeredHostName ?? alias; const ref = `${plugin.name}@${hostName}`; + if (await isMachineOwnedNativeRef(this.userManifestRepo, toolId, ref)) { + this.logger.warn( + `${toolId}: '${ref}' is an AIDD-owned machine plugin ref — left enabled; this project's claim detaches after local removal.` + ); + return undefined; + } if ( registeredHostName !== undefined && activator.enablesPlugins() && @@ -272,7 +540,7 @@ export class PluginRemoveUseCase { * own `NativeRegistrations`, never the alias, which a host never learns. */ private async purgeCachedPlugin( - manifest: Manifest, + registrations: NativeRegistrations | undefined, toolId: AiToolId, plugin: InstalledPlugin, confirmed: boolean @@ -280,7 +548,7 @@ export class PluginRemoveUseCase { if (plugin.marketplace === undefined) return; const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(resolveHomeDir()); if (cacheRoot === undefined) return; - const hostName = this.hostNameFor(manifest.getNativeRegistrations(toolId), plugin.marketplace); + const hostName = this.hostNameFor(registrations, plugin.marketplace); if (hostName === undefined) return; const label = `${toolId}: cache for '${plugin.name}'`; const candidate = await resolveCacheCandidate( @@ -315,6 +583,7 @@ export class PluginRemoveUseCase { const existing = await this.readExistingJson(outputPath); if (existing === null) return; const updated = unmergeOpencodeMcp(existing, plugin.mcpEntries); + await assertProjectPathWithinRoot(this.fs, projectRoot, outputPath); await this.fs.writeFile(outputPath, updated); } diff --git a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts index 9a79e9eeb..efeaf37f5 100644 --- a/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts +++ b/cli/src/contexts/framework/application/plugin/plugin-update-use-case.ts @@ -1,5 +1,6 @@ import { homedir as nodeHomedir } from "node:os"; import { join } from "node:path"; +import { InvalidPluginScopeError } from "../../../../kernel/errors.js"; import { PLUGIN_CACHE_SUBDIR } from "../../../../kernel/paths.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; @@ -29,6 +30,7 @@ export interface PluginUpdateOptions { pluginNames?: string[]; toolIds: AiToolId[] | "all"; projectRoot: string; + scope?: "project" | "user"; } export class PluginUpdateUseCase { @@ -42,6 +44,8 @@ export class PluginUpdateUseCase { ) {} async execute(options: PluginUpdateOptions): Promise { + if (options.scope === "user") + throw new Error("User-scope plugin update requires UserPluginUpdateUseCase."); const { pluginNames, toolIds, projectRoot } = options; const manifest = await loadPluginManifest(this.manifestRepo); const resolvedToolIds = resolvePluginToolIds(toolIds, manifest); @@ -71,7 +75,7 @@ export class PluginUpdateUseCase { const plugins = manifest.getPlugins(toolId); const targets = pluginNames ? plugins.filter((p) => pluginNames.includes(p.name)) - : [...plugins]; + : plugins.filter((plugin) => plugin.scope !== "user"); const updated: string[] = []; for (const plugin of targets) { const didUpdate = await this.updateOnePlugin(plugin, toolId, projectRoot, cacheDir, manifest); @@ -87,6 +91,7 @@ export class PluginUpdateUseCase { cacheDir: string, manifest: Manifest ): Promise { + if (plugin.scope === "user") throw new InvalidPluginScopeError(toolId, "project", "user"); const localPath = await this.pluginFetcher.fetch(plugin.source, cacheDir, { forceRefresh: true, }); diff --git a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts index 0d1a2053e..9ba14ac66 100644 --- a/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts +++ b/cli/src/contexts/framework/application/setup/setup-machine-scope-use-case.ts @@ -30,6 +30,16 @@ export class SetupMachineScopeUseCase { async execute(flow: SetupFlow): Promise { const source = await this.setupMarketplaceRegistration.resolveSourceIfNeeded(flow); + if (this.userManifestRepo.withExclusiveAccess !== undefined) { + return this.userManifestRepo.withExclusiveAccess(() => this.executeLocked(flow, source)); + } + return this.executeLocked(flow, source); + } + + private async executeLocked( + flow: SetupFlow, + source: Awaited> + ): Promise { const isNew = await this.initUserManifest(); await this.setupMarketplaceRegistration.registerIfPresent(flow, source); await this.registerUserScopeTools(flow); diff --git a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts index 0dbc7c87f..daf70c45f 100644 --- a/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts +++ b/cli/src/contexts/framework/application/shared/apply-plugin-files-use-case.ts @@ -48,6 +48,7 @@ export class ApplyPluginFilesUseCase { ) {} async execute(options: ApplyPluginFilesOptions): Promise { + if (options.plugin.scope === "user") return 0; const localPath = await this.pluginFetcher.fetch(options.plugin.source, options.cacheDir); const dist = await this.pluginDistributionReader.read(localPath); const translator = this.resolveTranslator(options.toolConfig); diff --git a/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts b/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts index 0e5c2476e..46201ea06 100644 --- a/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts +++ b/cli/src/contexts/framework/application/shared/purge-native-marketplace-cache.ts @@ -45,8 +45,8 @@ export async function purgeAllNativeCaches( * running, so containment alone proves the path cannot escape the declared root — never that the * caller still owns what sits inside it. Two proofs, one per declaration: * - * - a profile declaring `marketplaceRegistry` (claude) is reread after the undo: the name gone - * from that registry is the host's own admission nothing there resolves any more; + * - a profile declaring `marketplaceRegistry` (claude) also requires a confirmed host removal, + * then rereads the registry: a name gone says nothing there resolves any more; * - a profile declaring `pluginCacheDir` alone (codex) drives a host that deletes the cached * content itself and leaves only an empty shell — measured. Emptiness proves no data would be * lost, never that this caller emptied it, so `removed` (the host's own confirmation) is @@ -82,6 +82,12 @@ export async function purgeNativeMarketplaceCache( ); return; } + if (!removed) { + logger.warn( + `${binary}: cache for '${hostName}' left in place, its own removal was not confirmed: ${candidate}` + ); + return; + } await purgeOnceRegistryClears(fs, logger, reader, candidate, binary, hostName); } diff --git a/cli/src/contexts/framework/application/shared/remove-project-hooks.ts b/cli/src/contexts/framework/application/shared/remove-project-hooks.ts index cc1e42906..c0674a0e2 100644 --- a/cli/src/contexts/framework/application/shared/remove-project-hooks.ts +++ b/cli/src/contexts/framework/application/shared/remove-project-hooks.ts @@ -1,4 +1,5 @@ -import { dirname, join } from "node:path"; +import { createHash } from "node:crypto"; +import { dirname, join, posix } from "node:path"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { AiToolId } from "../../../../kernel/tool.js"; @@ -7,43 +8,170 @@ import { unmergeCursorProjectHooksJson, } from "../../../tools/domain/formats/cursor-hooks-project-merge.js"; import { resolvePluginsCapability } from "../../../tools/domain/registry.js"; +import type { + InstalledPlugin, + ProjectHooksProvenance, +} from "../../domain/plugins/installed-plugin.js"; +import { assertProjectPathWithinRoot } from "../ownership/project-path-boundary.js"; -/** - * Undoes what `ProjectHooksMaterializer` wrote for one plugin: a tool declaring - * `hooksDestination: "project"` (Cursor) merges a plugin's hooks into the project's own hooks file - * rather than tracking them in `Plugin.files`, so removal needs an unmerge instead of a - * baseDir-relative file delete. Both destinations are recomputed from `pluginName` alone, exactly - * as install computed them, so there is no extra state to keep in sync. - * - * Returns whether anything was actually there to undo. A no-op for a tool declaring no - * project-merged hooks destination. - */ -export async function removeProjectHooks( +type HookEntry = { command: string; [key: string]: unknown }; + +/** Preflight the exact project hook entries and scripts installed by AIDD. Does not mutate. */ +export async function assertProjectHooksUnchanged( + fs: FileReader, + pluginName: string, + provenance: ProjectHooksProvenance | undefined, + toolId: AiToolId, + projectRoot: string +): Promise<{ existing: string | null }> { + const cap = resolvePluginsCapability(toolId); + if (cap?.hooksDestination !== "project" || cap.projectHooksRelativePath === null) { + return { existing: null }; + } + const hooksPath = join(projectRoot, cap.projectHooksRelativePath); + await assertProjectPathWithinRoot(fs, projectRoot, hooksPath); + const existing = await readExistingJson(fs, hooksPath); + const scriptDir = join(projectRoot, cursorProjectHooksScriptDir(pluginName)); + await assertProjectPathWithinRoot(fs, projectRoot, scriptDir); + const scriptsPresent = (await fs.fileExists(scriptDir)) ? await fs.listDirectory(scriptDir) : []; + const currentEntries = existing === null ? [] : contributedEntries(existing, pluginName); + if (provenance === undefined) { + if (currentEntries.length > 0 || scriptsPresent.length > 0) { + throw new Error( + `Cursor project hooks for '${pluginName}' have an unproven legacy install digest; detach refused.` + ); + } + return { existing }; + } + const recorded = provenance.entries.map( + ({ event, command, digest }) => `${event}\u0000${command}\u0000${digest}` + ); + const current = currentEntries.map( + ({ event, entry }) => `${event}\u0000${entry.command}\u0000${digestEntry(entry)}` + ); + if ( + provenance.entries.some(({ digest }) => !/^[0-9a-f]{32}$/.test(digest)) || + recorded.sort().join("\n") !== current.sort().join("\n") + ) { + throw new Error( + `Cursor project hooks for '${pluginName}' were edited after install; detach refused.` + ); + } + for (const [relativePath, digest] of provenance.scripts) { + assertScriptPath(pluginName, relativePath); + const path = join(projectRoot, relativePath); + await assertProjectPathWithinRoot(fs, projectRoot, path); + if (!(await fs.fileExists(path))) continue; + if (!/^[0-9a-f]{32}$/.test(digest)) { + throw new Error( + `Cursor hook script '${relativePath}' has an unproven install digest; detach refused.` + ); + } + if ((await fs.readFileHash(path)).value !== digest) { + throw new Error( + `Cursor hook script '${relativePath}' was edited after install; detach refused.` + ); + } + } + return { existing }; +} + +export async function assertProjectHooksRemovable( + fs: FileReader, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +): Promise { + await assertProjectHooksUnchanged(fs, plugin.name, plugin.projectHooks, toolId, projectRoot); +} + +/** Unmerge only verified hook entries and tracked scripts; never delete a directory by name. */ +export async function removeRecordedProjectHooks( fs: FileReader & FileWriter, pluginName: string, + provenance: ProjectHooksProvenance | undefined, toolId: AiToolId, projectRoot: string ): Promise { - const pluginsCap = resolvePluginsCapability(toolId); - if (pluginsCap?.hooksDestination !== "project") return false; - const projectHooksRelativePath = pluginsCap.projectHooksRelativePath; - if (projectHooksRelativePath === null) return false; - const hooksPath = join(projectRoot, projectHooksRelativePath); - const existing = await readExistingJson(fs, hooksPath); - if (existing !== null) { + const cap = resolvePluginsCapability(toolId); + if (cap?.hooksDestination !== "project" || cap.projectHooksRelativePath === null) return false; + const { existing } = await assertProjectHooksUnchanged( + fs, + pluginName, + provenance, + toolId, + projectRoot + ); + const hooksPath = join(projectRoot, cap.projectHooksRelativePath); + const hasEntries = existing !== null && contributedEntries(existing, pluginName).length > 0; + if (hasEntries && existing !== null) { + await assertProjectPathWithinRoot(fs, projectRoot, hooksPath); const unmerged = unmergeCursorProjectHooksJson(existing, pluginName); - // A file this route wrote in the first place — leaving it as an empty shell once - // its last plugin is gone is the same residue clean exists to stop leaving. if (isHooksFileEmpty(unmerged)) await fs.deleteFile(hooksPath); else await fs.writeFile(hooksPath, unmerged); } - const scriptDir = join(projectRoot, cursorProjectHooksScriptDir(pluginName)); - const hadScriptDir = await fs.fileExists(scriptDir); - if (hadScriptDir) { - await fs.deleteDirectory(scriptDir); - await fs.deleteEmptyDirectories(dirname(scriptDir)); + let removedScript = false; + for (const relativePath of provenance?.scripts.keys() ?? []) { + const path = join(projectRoot, relativePath); + if (!(await fs.fileExists(path))) continue; + await assertProjectPathWithinRoot(fs, projectRoot, path); + await fs.deleteFile(path); + await fs.deleteEmptyDirectories(dirname(path)); + removedScript = true; + } + return hasEntries || removedScript; +} + +export async function removeProjectHooks( + fs: FileReader & FileWriter, + plugin: InstalledPlugin, + toolId: AiToolId, + projectRoot: string +): Promise { + return removeRecordedProjectHooks(fs, plugin.name, plugin.projectHooks, toolId, projectRoot); +} + +function contributedEntries( + content: string, + pluginName: string +): readonly { event: string; entry: HookEntry }[] { + const parsed = JSON.parse(content) as { hooks?: Record }; + const marker = cursorProjectHooksScriptDir(pluginName); + return Object.entries(parsed.hooks ?? {}).flatMap(([event, entries]) => + entries + .filter((entry) => typeof entry.command === "string" && entry.command.includes(marker)) + .map((entry) => ({ event, entry })) + ); +} + +/** Hash of one merged hook entry, independent of other plugins' entries. */ +export function digestEntry(entry: HookEntry): string { + return createHash("md5").update(JSON.stringify(entry), "utf-8").digest("hex"); +} + +export function recordedProjectHookEntries( + content: string, + pluginName: string +): ProjectHooksProvenance["entries"] { + return contributedEntries(content, pluginName).map(({ event, entry }) => ({ + event, + command: entry.command, + digest: digestEntry(entry), + })); +} + +function assertScriptPath(pluginName: string, relativePath: string): void { + const prefix = cursorProjectHooksScriptDir(pluginName); + if ( + !relativePath.startsWith(prefix) || + posix.isAbsolute(relativePath) || + relativePath.includes("\\") || + posix.normalize(relativePath) !== relativePath + ) { + throw new Error( + `Cursor hook script path '${relativePath}' has unproven provenance; detach refused.` + ); } - return existing !== null || hadScriptDir; } async function readExistingJson(fs: FileReader, path: string): Promise { diff --git a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts index 3937f01f5..8a75f4d26 100644 --- a/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts +++ b/cli/src/contexts/framework/application/shared/user-scope-plugin-files.ts @@ -1,4 +1,5 @@ -import { join } from "node:path"; +import { dirname, join, posix } from "node:path"; +import type { InstallationFile } from "../../../../kernel/file.js"; import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; import type { AiToolId } from "../../../../kernel/tool.js"; @@ -9,8 +10,8 @@ import { isStrictlyWithinUserScope } from "../../domain/plugins/user-scope-conta /** * The files of a user-scope plugin that are actually safe to delete: safe only once the real, * `realpath`-resolved location still sits strictly inside the tool's own declared user-scope - * directory. A `..` segment a corrupted manifest entry carries, or a plugin directory that became a - * symlink after install, both fail this and are left in place and named. + * directory and its current content still matches the recorded install digest. A `..` segment, + * symlink escape, unreadable file, or user edit refuses a destructive operation before claim detach. */ export async function userScopeFilesSafeToDelete( fs: FileReader, @@ -30,6 +31,17 @@ export async function userScopeFilesSafeToDelete( resolvedCandidate !== null && isStrictlyWithinUserScope(resolvedCandidate, resolvedBoundary) ) { + if (!/^[0-9a-f]{32}$/.test(hash)) { + throw new Error( + `${toolId}: '${plugin.name}' file '${relativePath}' has an unproven install digest; removal refused.` + ); + } + const current = await fs.readFileHash(join(boundary, relativePath)); + if (current.value !== hash) { + throw new Error( + `${toolId}: '${plugin.name}' file '${relativePath}' was edited after install; removal refused.` + ); + } allowed.set(relativePath, hash); continue; } @@ -40,6 +52,60 @@ export async function userScopeFilesSafeToDelete( return allowed; } +/** Refuse a machine update if a new path, existing parent, or plugin directory escapes its declared base. */ +export async function assertUserScopeWriteBoundary( + fs: FileReader, + toolId: AiToolId, + pluginName: string, + files: readonly InstallationFile[], + homedir: string +): Promise { + const boundary = resolvePluginsCapability(toolId)?.userPluginsBaseDir(homedir); + if (boundary === null || boundary === undefined) + throw new Error(`${toolId}: no user plugins directory is declared.`); + const resolvedBoundary = await tryRealpath(fs, boundary); + const pluginDir = join(boundary, pluginName); + const resolvedPluginDir = await tryRealpath(fs, pluginDir); + if ( + resolvedBoundary === null || + resolvedPluginDir === null || + !isStrictlyWithinUserScope(resolvedPluginDir, resolvedBoundary) + ) { + throw new Error( + `${toolId}: '${pluginName}' directory is not safely inside ${boundary}; update refused.` + ); + } + for (const file of files) { + const rel = file.relativePath; + if ( + posix.isAbsolute(rel) || + rel.includes("\\") || + posix.normalize(rel) !== rel || + !rel.startsWith(`${pluginName}/`) + ) { + throw new Error( + `${toolId}: '${pluginName}' update path '${rel}' escapes its plugin directory.` + ); + } + let parent = dirname(join(boundary, rel)); + while (true) { + const resolved = await tryRealpath(fs, parent); + if (resolved !== null) { + if (!isStrictlyWithinUserScope(resolved, resolvedBoundary)) { + throw new Error( + `${toolId}: '${pluginName}' update parent '${parent}' escapes ${boundary}.` + ); + } + break; + } + if (parent === pluginDir) { + throw new Error(`${toolId}: '${pluginName}' directory disappeared during update.`); + } + parent = dirname(parent); + } + } +} + async function tryRealpath(fs: FileReader, path: string): Promise { try { return await fs.realpath(path); diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts index 49dbb10a3..e47d67504 100644 --- a/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts +++ b/cli/src/contexts/framework/application/uninstall/uninstall-plugin-use-case.ts @@ -1,9 +1,13 @@ import { NoManifestError, PluginNotFoundError } from "../../../../kernel/errors.js"; +import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; import { AI_TOOL_IDS } from "../../../../kernel/tool.js"; import type { Manifest } from "../../domain/manifest.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { detachNativePluginRefs } from "../ownership/native-plugin-ownership.js"; +import { ProjectPluginCleanup } from "../ownership/project-plugin-cleanup.js"; +import { detachUserPlugin } from "../ownership/user-plugin-ownership.js"; import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; export interface UninstallPluginOptions { @@ -20,8 +24,9 @@ export interface UninstallPluginResult { export class UninstallPluginUseCase { constructor( - private readonly fs: FileWriter, - private readonly manifestRepo: ManifestRepository + private readonly fs: FileWriter & FileReader, + private readonly manifestRepo: ManifestRepository, + private readonly userManifestRepo?: ManifestRepository ) {} async execute(options: UninstallPluginOptions): Promise { @@ -29,9 +34,27 @@ export class UninstallPluginUseCase { const manifest = await this.manifestRepo.load(); if (manifest === null) throw new NoManifestError(); const scope = this.resolveToolScope(toolIds, manifest); + const userTools = scope.filter((toolId) => + manifest.getPlugins(toolId).some((p) => p.name === pluginName && p.scope === "user") + ); + const nativeRefs = new Map(); + const cleanup = new ProjectPluginCleanup(this.fs, this.userManifestRepo); + for (const toolId of scope) { + const plugin = manifest.getPlugins(toolId).find((candidate) => candidate.name === pluginName); + if (plugin !== undefined) + await cleanup.assertLocalIntegrationRemovable(toolId, plugin, projectRoot); + const hostName = manifest + .getNativeRegistrations(toolId) + ?.marketplaces.find((m) => m.alias === plugin?.marketplace)?.hostName; + if (hostName !== undefined) nativeRefs.set(toolId, [`${pluginName}@${hostName}`]); + } const results = await this.removeFromTools(pluginName, scope, projectRoot, manifest); if (results.length === 0) throw new PluginNotFoundError(pluginName); await this.manifestRepo.save(manifest); + await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs); + for (const toolId of userTools) { + await detachUserPlugin(this.userManifestRepo, this.fs, toolId, pluginName, projectRoot); + } return results; } @@ -47,9 +70,22 @@ export class UninstallPluginUseCase { manifest: Manifest ): Promise { const results: UninstallPluginResult[] = []; + const cleanup = new ProjectPluginCleanup(this.fs, this.userManifestRepo); for (const toolId of toolIds) { const plugin = manifest.getPlugins(toolId).find((p) => p.name === pluginName); if (plugin === undefined) continue; + await cleanup.removeLocalIntegration(toolId, plugin, projectRoot); + const registrations = manifest.getNativeRegistrations(toolId); + const hostName = registrations?.marketplaces.find( + (m) => m.alias === plugin.marketplace + )?.hostName; + if (registrations !== undefined && hostName !== undefined) + manifest.setNativeRegistrations(toolId, { + ...registrations, + pluginRefs: registrations.pluginRefs.filter( + (ref) => ref !== `${plugin.name}@${hostName}` + ), + }); const deletedFiles = await deletePluginFilesForTool( plugin.files, plugin.scope, diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts index 006b1bdb2..9b2e7e8ac 100644 --- a/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts +++ b/cli/src/contexts/framework/application/uninstall/uninstall-tools-use-case.ts @@ -7,10 +7,13 @@ import { import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; -import type { ToolId } from "../../../../kernel/tool.js"; +import type { AiToolId, ToolId } from "../../../../kernel/tool.js"; import { isAiToolId } from "../../../../kernel/tool.js"; import { getToolConfig, isAiTool } from "../../../tools/domain/registry.js"; import type { Manifest } from "../../domain/manifest.js"; +import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; +import { detachNativePluginRefs } from "../ownership/native-plugin-ownership.js"; +import { detachUserPlugin } from "../ownership/user-plugin-ownership.js"; import { deletePluginFilesForTool } from "../plugin/plugin-helpers.js"; export interface UninstallToolsOptions { @@ -28,7 +31,8 @@ export interface UninstallToolsResult { export class UninstallToolsUseCase { constructor( private readonly fs: FileReader & FileWriter, - private readonly logger: Logger + private readonly logger: Logger, + private readonly userManifestRepo?: ManifestRepository ) {} async execute(options: UninstallToolsOptions): Promise { @@ -40,6 +44,17 @@ export class UninstallToolsUseCase { return results; } + async detachClaimsAfterSave( + projectRoot: string, + userPlugins: readonly { toolId: AiToolId; name: string }[], + nativeRefs: ReadonlyMap + ): Promise { + await detachNativePluginRefs(this.userManifestRepo, this.fs, projectRoot, nativeRefs); + for (const { toolId, name } of userPlugins) { + await detachUserPlugin(this.userManifestRepo, this.fs, toolId, name, projectRoot); + } + } + private async removeOneTool( toolId: ToolId, allToolIds: ToolId[], diff --git a/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts b/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts index d03b49074..56def6420 100644 --- a/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts +++ b/cli/src/contexts/framework/application/uninstall/uninstall-use-case.ts @@ -7,7 +7,7 @@ import type { FileReader } from "../../../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../../../kernel/ports/file-writer.js"; import type { Logger } from "../../../../kernel/ports/logger.js"; import type { ToolId } from "../../../../kernel/tool.js"; -import { VALID_TOOL_IDS } from "../../../../kernel/tool.js"; +import { isAiToolId, VALID_TOOL_IDS } from "../../../../kernel/tool.js"; import type { Manifest } from "../../domain/manifest.js"; import type { ManifestRepository } from "../../domain/ports/manifest-repository.js"; import { UninstallPluginUseCase } from "./uninstall-plugin-use-case.js"; @@ -30,12 +30,13 @@ export class UninstallUseCase { private readonly toolsUninstall: UninstallToolsUseCase; constructor( - fs: FileReader & FileWriter, + private readonly fs: FileReader & FileWriter, private readonly manifestRepo: ManifestRepository, - logger: Logger + logger: Logger, + userManifestRepo?: ManifestRepository ) { - this.pluginUninstall = new UninstallPluginUseCase(fs, manifestRepo); - this.toolsUninstall = new UninstallToolsUseCase(fs, logger); + this.pluginUninstall = new UninstallPluginUseCase(fs, manifestRepo, userManifestRepo); + this.toolsUninstall = new UninstallToolsUseCase(fs, logger, userManifestRepo); } async execute(options: UninstallOptions): Promise { @@ -52,10 +53,24 @@ export class UninstallUseCase { } const manifest = await this.loadAndValidate(toolIds); + const userPlugins = toolIds.flatMap((toolId) => + isAiToolId(toolId) + ? manifest + .getPlugins(toolId) + .filter((plugin) => plugin.scope === "user") + .map((plugin) => ({ toolId, name: plugin.name })) + : [] + ); + const nativeRefs = new Map(); + for (const toolId of toolIds) { + const refs = manifest.getNativeRegistrations(toolId)?.pluginRefs; + if (refs !== undefined && refs.length > 0) nativeRefs.set(toolId, refs); + } const results = await this.toolsUninstall.execute({ toolIds, manifest, projectRoot }); await this.manifestRepo.save(manifest); + await this.toolsUninstall.detachClaimsAfterSave(projectRoot, userPlugins, nativeRefs); return results; } diff --git a/cli/src/contexts/framework/domain/manifest/native-registrations.ts b/cli/src/contexts/framework/domain/manifest/native-registrations.ts index 0a95699fe..fa6f4e754 100644 --- a/cli/src/contexts/framework/domain/manifest/native-registrations.ts +++ b/cli/src/contexts/framework/domain/manifest/native-registrations.ts @@ -1,3 +1,5 @@ +import type { NativeMarketplaceSource } from "../../../tools/domain/ports/native-marketplace-source-reader.js"; + /** One marketplace registration a tool's own CLI was asked to make — aidd's own local name for it * (`alias`, what this project's registry is keyed by) beside what the host actually registered it * under (`hostName`, the catalog's own declared name, which every host-facing call must use @@ -6,18 +8,27 @@ export interface NativeMarketplaceRegistration { readonly alias: string; readonly hostName: string; + /** Missing on legacy records: never infer current host ownership from the name alone. */ + readonly provenance?: NativeMarketplaceSource; } export interface NativeRegistrations { readonly binary: string; readonly marketplaces: readonly NativeMarketplaceRegistration[]; readonly pluginRefs: readonly string[]; + readonly pluginClaims?: readonly NativePluginClaim[]; +} + +export interface NativePluginClaim { + readonly ref: string; + readonly dependents: readonly string[]; } export interface NativeRegistrationsData { binary: string; marketplaces: NativeMarketplaceRegistration[]; pluginRefs: string[]; + pluginClaims?: NativePluginClaim[]; } export function toNativeRegistrationsData( @@ -25,8 +36,19 @@ export function toNativeRegistrationsData( ): NativeRegistrationsData { return { binary: registrations.binary, - marketplaces: registrations.marketplaces.map((m) => ({ ...m })), + marketplaces: registrations.marketplaces.map((m) => ({ + ...m, + ...(m.provenance === undefined ? {} : { provenance: { ...m.provenance } }), + })), pluginRefs: [...registrations.pluginRefs], + ...(registrations.pluginClaims === undefined + ? {} + : { + pluginClaims: registrations.pluginClaims.map((claim) => ({ + ref: claim.ref, + dependents: [...claim.dependents], + })), + }), }; } @@ -36,7 +58,33 @@ export function parseNativeRegistrations( if (data === undefined) return undefined; return { binary: data.binary, - marketplaces: data.marketplaces.map((m) => ({ alias: m.alias, hostName: m.hostName })), + marketplaces: data.marketplaces.map((m) => ({ + alias: m.alias, + hostName: m.hostName, + ...(validProvenance(m.provenance) ? { provenance: { ...m.provenance } } : {}), + })), pluginRefs: [...data.pluginRefs], + ...(data.pluginClaims === undefined + ? {} + : { + pluginClaims: data.pluginClaims.map((claim) => ({ + ref: claim.ref, + dependents: [...claim.dependents], + })), + }), }; } + +function validProvenance(value: unknown): value is NativeMarketplaceSource { + if (value === null || typeof value !== "object") return false; + const data = value as Record; + if (typeof data.source !== "string" || data.source === "") return false; + if (data.kind === "registry") return true; + return ( + data.kind === "effective-list" && + typeof data.root === "string" && + data.root !== "" && + typeof data.sourceType === "string" && + data.sourceType !== "" + ); +} diff --git a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts index 2ee323dd1..1753f048c 100644 --- a/cli/src/contexts/framework/domain/plugins/installed-plugin.ts +++ b/cli/src/contexts/framework/domain/plugins/installed-plugin.ts @@ -41,6 +41,17 @@ export type ComponentPathMap = BrandedMap<"ComponentPathMap">; /** MCP server name → MD5 hash of the contributed server JSON (OpenCode merge tracking). */ export type McpDigestMap = BrandedMap<"McpDigestMap">; +/** Exact merged Cursor hook commands and copied project scripts recorded at install. */ +export interface ProjectHooksProvenance { + entries: readonly { event: string; command: string; digest: string }[]; + scripts: ReadonlyMap; +} + +interface ProjectHooksEntryData { + entries: { event: string; command: string; digest: string }[]; + scripts: Record; +} + function asPathHashMap(m: ReadonlyMap): PathHashMap { return m as PathHashMap; } @@ -64,7 +75,10 @@ export interface PluginEntryData { scope: PluginScope; componentPaths?: Record; mcpEntries?: Record; + projectHooks?: ProjectHooksEntryData; marketplace?: string; + /** Canonical project roots using machine-owned user-scope files. Only the user manifest owns this list. */ + dependents?: string[]; } export class InstalledPlugin { @@ -76,7 +90,9 @@ export class InstalledPlugin { readonly scope: PluginScope; readonly componentPaths: ComponentPathMap; readonly mcpEntries: McpDigestMap; + readonly projectHooks?: ProjectHooksProvenance; readonly marketplace?: string; + readonly dependents: readonly string[]; private constructor(params: { name: string; @@ -87,7 +103,9 @@ export class InstalledPlugin { scope: PluginScope; componentPaths: ComponentPathMap; mcpEntries: McpDigestMap; + projectHooks?: ProjectHooksProvenance; marketplace?: string; + dependents: readonly string[]; }) { this.name = params.name; this.source = params.source; @@ -97,7 +115,9 @@ export class InstalledPlugin { this.scope = params.scope; this.componentPaths = params.componentPaths; this.mcpEntries = params.mcpEntries; + this.projectHooks = params.projectHooks; this.marketplace = params.marketplace; + this.dependents = params.dependents; } static fromMetadata( @@ -133,7 +153,28 @@ export class InstalledPlugin { scope: plugin.scope, componentPaths: plugin.componentPaths, mcpEntries: asMcpDigestMap(mcpEntries), + projectHooks: plugin.projectHooks, + marketplace: plugin.marketplace, + dependents: plugin.dependents, + }); + } + + static withProjectHooks( + plugin: InstalledPlugin, + projectHooks: ProjectHooksProvenance | undefined + ): InstalledPlugin { + return new InstalledPlugin({ + name: plugin.name, + source: plugin.source, + version: plugin.version, + strict: plugin.strict, + files: plugin.files, + scope: plugin.scope, + componentPaths: plugin.componentPaths, + mcpEntries: plugin.mcpEntries, + projectHooks, marketplace: plugin.marketplace, + dependents: plugin.dependents, }); } @@ -209,7 +250,15 @@ export class InstalledPlugin { scope: data.scope, componentPaths: asComponentPathMap(componentPaths), mcpEntries: asMcpDigestMap(mcpEntries), + projectHooks: + data.projectHooks === undefined + ? undefined + : { + entries: data.projectHooks.entries, + scripts: new Map(Object.entries(data.projectHooks.scripts)), + }, marketplace: data.marketplace, + dependents: data.dependents ?? [], }); } @@ -224,7 +273,14 @@ export class InstalledPlugin { }; if (this.componentPaths.size > 0) data.componentPaths = mapToRecord(this.componentPaths); if (this.mcpEntries.size > 0) data.mcpEntries = mapToRecord(this.mcpEntries); + if (this.projectHooks !== undefined) { + data.projectHooks = { + entries: [...this.projectHooks.entries], + scripts: mapToRecord(this.projectHooks.scripts), + }; + } if (this.marketplace !== undefined) data.marketplace = this.marketplace; + if (this.dependents.length > 0) data.dependents = [...this.dependents]; return data; } @@ -242,7 +298,9 @@ export class InstalledPlugin { scope: this.scope, componentPaths: this.componentPaths, mcpEntries: this.mcpEntries, + projectHooks: this.projectHooks, marketplace: this.marketplace, + dependents: this.dependents, }); } @@ -256,7 +314,25 @@ export class InstalledPlugin { scope: this.scope, componentPaths: this.componentPaths, mcpEntries: this.mcpEntries, + projectHooks: this.projectHooks, + marketplace: this.marketplace, + dependents: this.dependents, + }); + } + + withDependents(dependents: readonly string[]): InstalledPlugin { + return new InstalledPlugin({ + name: this.name, + source: this.source, + version: this.version, + strict: this.strict, + files: this.files, + scope: this.scope, + componentPaths: this.componentPaths, + mcpEntries: this.mcpEntries, + projectHooks: this.projectHooks, marketplace: this.marketplace, + dependents: [...new Set(dependents)], }); } } diff --git a/cli/src/contexts/framework/domain/ports/manifest-repository.ts b/cli/src/contexts/framework/domain/ports/manifest-repository.ts index fb92cf05b..894fb850b 100644 --- a/cli/src/contexts/framework/domain/ports/manifest-repository.ts +++ b/cli/src/contexts/framework/domain/ports/manifest-repository.ts @@ -7,4 +7,6 @@ export interface ManifestRepository { load(): Promise; save(manifest: Manifest): Promise; delete(): Promise; + /** Optional for project repositories; user-scope mutations hold an inter-process lock here. */ + withExclusiveAccess?(action: () => Promise): Promise; } diff --git a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts index b1240fd9e..8aed72d64 100644 --- a/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts +++ b/cli/src/contexts/framework/infrastructure/user-manifest-repository-adapter.ts @@ -1,8 +1,10 @@ -import { rm } from "node:fs/promises"; +import { mkdir, rm, rmdir } from "node:fs/promises"; +import { dirname } from "node:path"; +import { setTimeout as pause } from "node:timers/promises"; import { userManifestPath } from "../../../kernel/paths.js"; import type { Manifest } from "../domain/manifest.js"; import type { ManifestRepository } from "../domain/ports/manifest-repository.js"; -import { readManifestFile, writeManifestFile } from "./manifest-file-io.js"; +import { readManifestFile } from "./manifest-file-io.js"; /** * The user-scope counterpart of `ManifestRepositoryAdapter` — same schema, same version and refusal @@ -14,7 +16,10 @@ import { readManifestFile, writeManifestFile } from "./manifest-file-io.js"; * a live bug here. */ export class UserManifestRepositoryAdapter implements ManifestRepository { - constructor(private readonly userConfigDir: () => string) {} + constructor( + private readonly userConfigDir: () => string, + private readonly atomicWriter: (path: string, content: string) => Promise + ) {} get path(): string { return userManifestPath(this.userConfigDir()); @@ -29,10 +34,36 @@ export class UserManifestRepositoryAdapter implements ManifestRepository { } async save(manifest: Manifest): Promise { - await writeManifestFile(this.path, manifest); + await mkdir(dirname(this.path), { recursive: true }); + await this.atomicWriter(this.path, JSON.stringify(manifest.toJSON(), null, 2)); } async delete(): Promise { await rm(this.path, { force: true }); } + + async withExclusiveAccess(action: () => Promise): Promise { + const lock = `${this.path}.lock`; + await mkdir(dirname(lock), { recursive: true }); + const deadline = Date.now() + 30_000; + while (true) { + try { + await mkdir(lock); + break; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (Date.now() >= deadline) { + throw new Error( + `User manifest is busy: ${lock}. Retry after the other AIDD operation finishes.` + ); + } + await pause(100); + } + } + try { + return await action(); + } finally { + await rmdir(lock); + } + } } diff --git a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts index 250c1d7fb..dbc9c403b 100644 --- a/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts +++ b/cli/src/contexts/tools/domain/capabilities/plugins-capability.ts @@ -33,6 +33,8 @@ export interface NativeActivation { /** Verb this CLI uses to re-index its marketplaces, after `plugin marketplace`. Omit when * plugins are not enabled through the CLI. */ upgradeVerb?: string; + /** Exact host command for updating one installed plugin ref. Copilot declares `update`; Codex does not. */ + updateVerb?: string; /** Verb this CLI uses to enable a plugin, after `plugin`. Omit when the tool loads plugins * from a project file this CLI writes. */ enableVerb?: string; diff --git a/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts b/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts index 55dba42ca..0b9cc094b 100644 --- a/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts +++ b/cli/src/contexts/tools/domain/formats/opencode-mcp-merge.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import type { Hasher } from "../../../../kernel/ports/hasher.js"; import { stripJsonComments } from "../../../../kernel/reading/jsonc.js"; @@ -24,11 +25,16 @@ export function mergeOpencodeMcp( mergedContent: string; contributedEntries: ReadonlyMap; collisions: ReadonlyArray; + editedPreviousEntries: ReadonlyArray; } { const { full, mcp } = parseExisting(existingContent); const incoming = parseIncoming(incomingTransformed); - const cleaned = stripPreviousEntries(mcp, previousEntriesForThisPlugin); - return applyIncoming(full, cleaned, incoming, previousEntriesForThisPlugin, hasher); + const { cleaned, editedPreviousEntries } = stripPreviousEntries( + mcp, + previousEntriesForThisPlugin, + hasher + ); + return { ...applyIncoming(full, cleaned, incoming, hasher), editedPreviousEntries }; } /** @@ -57,15 +63,16 @@ export function buildOpencodeFlatConfig( return JSON.stringify(result, null, 2); } -/** Removes servers previously contributed by a plugin from opencode.json's mcp section. A key - * absent from `entries` is left untouched. */ +/** Removes only unchanged servers previously contributed by a plugin. */ export function unmergeOpencodeMcp( existingContent: string, entries: ReadonlyMap ): string { const parsed = JSON.parse(stripJsonComments(existingContent)) as OpencodeMcpSection; const mcp = { ...(parsed.mcp ?? {}) }; - for (const name of entries.keys()) { + for (const [name, digest] of entries) { + if (!(name in mcp)) continue; + assertContributedServerUnchanged(name, mcp[name], digest); delete mcp[name]; } return JSON.stringify({ ...parsed, mcp }, null, 2); @@ -91,20 +98,23 @@ function parseIncoming(transformed: string): Record { function stripPreviousEntries( existing: Record, - previous: ReadonlyMap -): Record { + previous: ReadonlyMap, + hasher: Hasher +): { cleaned: Record; editedPreviousEntries: readonly string[] } { const result = { ...existing }; - for (const name of previous.keys()) { - delete result[name]; + const editedPreviousEntries: string[] = []; + for (const [name, digest] of previous) { + if (!(name in result)) continue; + if (hasher.hash(JSON.stringify(result[name])).value === digest) delete result[name]; + else editedPreviousEntries.push(name); } - return result; + return { cleaned: result, editedPreviousEntries }; } function applyIncoming( full: Record, cleanedMcp: Record, incoming: Record, - previous: ReadonlyMap, hasher: Hasher ): { mergedContent: string; @@ -115,7 +125,7 @@ function applyIncoming( const contributed = new Map(); const collisions: string[] = []; for (const [name, server] of Object.entries(incoming)) { - if (name in cleanedMcp && !previous.has(name)) { + if (name in cleanedMcp) { collisions.push(`${name}: ${MCP_COLLISION_REASON}`); continue; } @@ -125,3 +135,15 @@ function applyIncoming( const mergedContent = JSON.stringify({ ...full, mcp }, null, 2); return { mergedContent, contributedEntries: contributed, collisions }; } + +function assertContributedServerUnchanged(name: string, server: unknown, digest: string): void { + if (!/^[0-9a-f]{32}$/.test(digest)) { + throw new Error( + `OpenCode MCP server '${name}' has an unproven install digest; removal refused.` + ); + } + const current = createHash("md5").update(JSON.stringify(server), "utf-8").digest("hex"); + if (current !== digest) { + throw new Error(`OpenCode MCP server '${name}' was edited after install; removal refused.`); + } +} diff --git a/cli/src/contexts/tools/domain/host-plugin-registration.ts b/cli/src/contexts/tools/domain/host-plugin-registration.ts index 1b7667c63..fdf04ca6b 100644 --- a/cli/src/contexts/tools/domain/host-plugin-registration.ts +++ b/cli/src/contexts/tools/domain/host-plugin-registration.ts @@ -154,7 +154,7 @@ export function answeredRegistry( if (reading.refs === undefined) { return { answer: "unanswerable", - detail: `${reading.location} could not be read — ${reading.unreadable ?? "no reason given"}`, + detail: `${reading.location} could not be read — ${reading.absent ? "ENOENT" : (reading.unreadable ?? "no reason given")}`, }; } return { refs: reading.refs, location: reading.location }; diff --git a/cli/src/contexts/tools/domain/marketplace-settings.ts b/cli/src/contexts/tools/domain/marketplace-settings.ts index 6f6f83550..095ce2100 100644 --- a/cli/src/contexts/tools/domain/marketplace-settings.ts +++ b/cli/src/contexts/tools/domain/marketplace-settings.ts @@ -12,6 +12,9 @@ export interface MarketplaceSettings { settingsPath: string; settingsKey: string; enabledPluginsKey?: string; + /** A true entry installs the plugin when the host next loads this repository. Only project + * native refs proven during this run may be projected into this file. */ + declarativePluginActivationRequiresNativeProof?: boolean; /** * Where the tool keeps its registered marketplaces, for `doctor`, which checks the tool * actually wrote one. A path names a file of its own, which this CLI neither commits nor diff --git a/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts b/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts index 8f56d84b0..79f069c54 100644 --- a/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts +++ b/cli/src/contexts/tools/domain/ports/host-plugin-registry-reader.ts @@ -24,14 +24,13 @@ export interface HostPluginRegistryReading { /** The file consulted, named whatever it answered, so a person can open the same one. */ readonly location: string; /** - * Every ref the registry carries, mapped to what it says about it. **Absent, never empty, - * when the registry could not be read**: an empty map is a real answer, and keeping the two - * apart in the type is what stops a caller inventing "not registered" out of a permissions - * error. + * Every ref the registry carries. Absent when the file is missing or unreadable; only + * `absent: true` proves no registry exists. An empty map is a parsed file with no refs, + * distinct from a permissions error. */ readonly refs?: ReadonlyMap; - /** Why the registry could not be read: absent, unreadable, or holding something this reader - * will not pretend to understand. Present exactly when `refs` is absent. */ + readonly absent?: true; + /** Why an existing registry could not be read or understood. Never set with `absent`. */ readonly unreadable?: string; } diff --git a/cli/src/contexts/tools/domain/ports/native-marketplace-source-reader.ts b/cli/src/contexts/tools/domain/ports/native-marketplace-source-reader.ts new file mode 100644 index 000000000..edbc5f216 --- /dev/null +++ b/cli/src/contexts/tools/domain/ports/native-marketplace-source-reader.ts @@ -0,0 +1,29 @@ +/** Source currently selected by the host for a named native plugin marketplace. */ +export type NativeMarketplaceSource = + | { readonly kind: "registry"; readonly source: string } + | { + readonly kind: "effective-list"; + readonly root: string; + readonly sourceType: string; + readonly source: string; + }; + +export interface NativeMarketplaceSourceReading { + readonly location: string; + /** A missing name in a readable map proves absence; `null` means named but source unproven. */ + readonly entries?: ReadonlyMap; + /** Present only when no structured answer could be measured. */ + readonly unreadable?: string; +} + +export interface NativeMarketplaceSourceReader { + read(projectRoot: string): Promise; +} + +export interface NativeMarketplaceSourceListContract { + readonly binary: string; + readonly args: readonly string[]; + readonly parse?: (output: string) => ReadonlyMap; + readonly unavailableMessage: string; + readonly unverifiedMessage: string; +} diff --git a/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts b/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts index c2da415bb..eb01d94e7 100644 --- a/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts +++ b/cli/src/contexts/tools/domain/ports/native-plugin-activator.ts @@ -22,8 +22,7 @@ export interface NativePluginActivator { * the tool offers no way to tell, which callers must read as "leave it alone": a registration * that might belong to a live project elsewhere is not one to take over. */ registrationState(name: string): "live" | "dead" | "unknown"; - /** Refreshes marketplace snapshots so plugin installs pick up new versions. No-op when unsupported. */ - upgradeMarketplaces(): void; + upgradeMarketplaces(name: string): void; /** * Installs and enables a plugin referenced as `@`. Idempotent. * @@ -34,6 +33,7 @@ export interface NativePluginActivator { * copilot) ignores it. */ enablePlugin(pluginRef: string, scope?: MarketplaceScope): void; + updatePlugin?(pluginRef: string): void; /** * Uninstalls a plugin referenced as `@`, the counterpart of * {@link enablePlugin}. May throw when the plugin is already absent from the tool's own diff --git a/cli/src/contexts/tools/domain/profiles/codex/native-marketplace-source.ts b/cli/src/contexts/tools/domain/profiles/codex/native-marketplace-source.ts new file mode 100644 index 000000000..6cec9ef77 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/codex/native-marketplace-source.ts @@ -0,0 +1,57 @@ +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceListContract, +} from "../../ports/native-marketplace-source-reader.js"; + +function object(value: unknown): Record | undefined { + return value !== null && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +function parseCodexMarketplaceSources( + output: string +): ReadonlyMap { + const parsed = object(JSON.parse(output)); + if (!Array.isArray(parsed?.marketplaces)) throw new Error("unexpected Codex JSON shape"); + const entries = new Map(); + for (const item of parsed.marketplaces) { + const row = object(item); + if ( + typeof row?.name !== "string" || + row.name === "" || + typeof row.root !== "string" || + row.root === "" || + entries.has(row.name) + ) + throw new Error("ambiguous Codex marketplace JSON row"); + const source = row.marketplaceSource; + if (source === undefined) { + entries.set(row.name, null); + continue; + } + const sourceRow = object(source); + if ( + typeof sourceRow?.sourceType !== "string" || + sourceRow.sourceType === "" || + typeof sourceRow.source !== "string" || + sourceRow.source === "" + ) + throw new Error("unproven Codex marketplace source shape"); + entries.set(row.name, { + kind: "effective-list", + root: row.root, + sourceType: sourceRow.sourceType, + source: sourceRow.source, + }); + } + return entries; +} + +export const codexMarketplaceSourceListContract: NativeMarketplaceSourceListContract = { + binary: "codex", + args: ["plugin", "marketplace", "list", "--json"], + parse: parseCodexMarketplaceSources, + unavailableMessage: "Codex marketplace list --json unavailable; inspect the host CLI and retry", + unverifiedMessage: "Codex marketplace list --json output shape is not verified", +}; diff --git a/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts b/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts new file mode 100644 index 000000000..f3803b181 --- /dev/null +++ b/cli/src/contexts/tools/domain/profiles/copilot/native-marketplace-source.ts @@ -0,0 +1,11 @@ +import type { NativeMarketplaceSourceListContract } from "../../ports/native-marketplace-source-reader.js"; + +/** Installed Copilot 1.0.83 rejects --json; no structured source shape is verified yet. */ +export const copilotMarketplaceSourceListContract: NativeMarketplaceSourceListContract = { + binary: "copilot", + args: ["plugin", "marketplace", "list", "--json"], + unavailableMessage: + "Copilot CLI marketplace list --json unavailable; upgrade to a supported binary and retry", + unverifiedMessage: + "Copilot CLI marketplace list --json output shape is not verified; upgrade to a supported binary and reconcile manually", +}; diff --git a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts index ac7b06372..b94791a8a 100644 --- a/cli/src/contexts/tools/domain/profiles/copilot/profile.ts +++ b/cli/src/contexts/tools/domain/profiles/copilot/profile.ts @@ -293,9 +293,8 @@ export const copilot: AiTool< pluginRootToken: PLUGIN_ROOT_TOKEN, acceptsMcp: true, translationMode: "marketplace", - // Copilot treats enabledPlugins in settings.json as a recommendation, not an auto-install, - // and a project marketplace is not installable from project scope: `copilot plugin install` - // is what actually loads a plugin, while the settings file below surfaces recommendations. + // Copilot auto-installs repo-level enabledPlugins; a foreign catalogue at the same name + // must therefore never receive a declarative true entry before native source proof. // Its registry is global to the user and keyed by name, so a name held by a project that // no longer exists breaks every other project's installs — measured; `update` exits 1 on a // local path that is gone and 0 otherwise, which is what lets a dead name be reclaimed with @@ -303,6 +302,7 @@ export const copilot: AiTool< nativeActivation: { binary: "copilot", upgradeVerb: "update", + updateVerb: "update", enableVerb: "install", disableVerb: "uninstall", sourceCheckVerb: "update", @@ -311,18 +311,14 @@ export const copilot: AiTool< // binary. Never written by aidd; named here for a diagnostic alone. userSettingsPath: (h) => join(h, ".copilot", "settings.json"), }, - // VS Code Copilot reads this file, not the `copilot` CLI, which writes - // ~/.copilot/settings.json and leaves this one untouched. `chat.plugins.marketplaces` - // cannot stand in for it: it has application scope and VS Code rejects it in a workspace - // .vscode/settings.json. This file is a shared, committed recommendation, so - // `enabledPlugins`, which names plugins, belongs in it, while a marketplace registration - // naming an absolute path on one machine does not — hence `null`, with the registration - // driven through `copilot plugin install` instead. + // Copilot CLI and cloud agent read this repository file; enabledPlugins auto-installs. + // Keep machine-local marketplace paths out and project only proven native refs. marketplaceSettings: { settingsPath: ".github/copilot/settings.json", settingsKey: "extraKnownMarketplaces", marketplacesSettingsPath: null, enabledPluginsKey: "enabledPlugins", + declarativePluginActivationRequiresNativeProof: true, toEntryKey: claudeStyleMarketplaceKey, }, }), diff --git a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts index 480b1b62e..109588375 100644 --- a/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.ts @@ -60,7 +60,7 @@ export abstract class AbstractNativePluginCliAdapter implements NativePluginActi abstract enablesPlugins(): boolean; abstract registrationState(name: string): "live" | "dead" | "unknown"; - abstract upgradeMarketplaces(): void; + abstract upgradeMarketplaces(name: string): void; abstract enablePlugin(pluginRef: string, scope?: MarketplaceScope): void; abstract uninstallPlugin(pluginRef: string, scope?: MarketplaceScope): void; diff --git a/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts index 085023c4e..60d84bd63 100644 --- a/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.ts @@ -10,6 +10,12 @@ import type { HostPluginRegistryReading, } from "../domain/ports/host-plugin-registry-reader.js"; +function failedRead(location: string, error: unknown): HostPluginRegistryReading { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return { location, absent: true }; + return { location, unreadable: describeError(error) }; +} + /** * One reader per host whose own plugin registry was measured; a tool absent from the map is * one nothing here claims to know, and the diagnostic reports it unanswerable rather than @@ -51,7 +57,7 @@ class ClaudeInstalledPluginsReader implements HostPluginRegistryReader { try { content = await readFile(this.path, "utf8"); } catch (error) { - return { location: this.path, unreadable: describeError(error) }; + return failedRead(this.path, error); } try { const parsed = JSON.parse(content) as { plugins?: Record }; @@ -90,7 +96,7 @@ class CodexConfigPluginsReader implements HostPluginRegistryReader { try { content = await readFile(this.path, "utf8"); } catch (error) { - return { location: this.path, unreadable: describeError(error) }; + return failedRead(this.path, error); } return { location: this.path, refs: scanCodexPluginTables(content) }; } @@ -109,7 +115,7 @@ class CopilotSettingsPluginsReader implements HostPluginRegistryReader { try { content = await readFile(this.path, "utf8"); } catch (error) { - return { location: this.path, unreadable: describeError(error) }; + return failedRead(this.path, error); } try { const parsed = JSON.parse(content) as { enabledPlugins?: Record }; diff --git a/cli/src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.ts b/cli/src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.ts new file mode 100644 index 000000000..2b3dc7187 --- /dev/null +++ b/cli/src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.ts @@ -0,0 +1,74 @@ +import { spawnSync } from "node:child_process"; +import type { HostMarketplaceRegistryReader } from "../domain/ports/host-marketplace-registry-reader.js"; +import type { + NativeMarketplaceSourceListContract, + NativeMarketplaceSourceReader, + NativeMarketplaceSourceReading, +} from "../domain/ports/native-marketplace-source-reader.js"; +import { + hostExecutableLookup, + resolveExecutableOnPath, + runsThroughShell, + windowsCommandLine, +} from "./executable-on-path.js"; + +export class HostRegistryMarketplaceSourceReaderAdapter implements NativeMarketplaceSourceReader { + constructor(private readonly hostReader: HostMarketplaceRegistryReader) {} + + async read(_projectRoot: string): Promise { + const reading = await this.hostReader.read(); + if (reading.entries !== undefined) + return { + location: reading.location, + entries: new Map( + [...reading.entries].map(([name, source]) => [ + name, + { kind: "registry" as const, source }, + ]) + ), + }; + if (reading.absent === true) return { location: reading.location, entries: new Map() }; + return { + location: reading.location, + unreadable: reading.unreadable ?? "Host marketplace registry is unreadable", + }; + } +} + +/** Uses only a host's structured, effective listing with the requesting project's CWD. */ +export class NativeMarketplaceSourceReaderAdapter implements NativeMarketplaceSourceReader { + constructor(private readonly contract: NativeMarketplaceSourceListContract) {} + + async read(projectRoot: string): Promise { + const location = `${this.contract.binary} ${this.contract.args.join(" ")} (cwd ${projectRoot})`; + const options = { + cwd: projectRoot, + encoding: "utf-8" as const, + timeout: 30000, + stdio: ["ignore", "pipe", "pipe"] as ["ignore", "pipe", "pipe"], + shell: false, + }; + const executable = resolveExecutableOnPath(this.contract.binary, hostExecutableLookup()); + const result = + executable !== undefined && runsThroughShell(executable) + ? spawnSync(windowsCommandLine(executable, this.contract.args), { ...options, shell: true }) + : spawnSync(this.contract.binary, [...this.contract.args], options); + if (result.error !== undefined || result.status !== 0) { + const detail = result.error?.message ?? result.stderr?.trim() ?? "unknown failure"; + return { location, unreadable: `${this.contract.unavailableMessage}: ${detail}` }; + } + if (this.contract.parse === undefined) + return { + location, + unreadable: this.contract.unverifiedMessage, + }; + try { + return { location, entries: this.contract.parse(result.stdout ?? "") }; + } catch (error) { + return { + location, + unreadable: `${this.contract.binary} marketplace list could not prove a source: ${error instanceof Error ? error.message : String(error)}`, + }; + } + } +} diff --git a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts index 98cce54f3..dca4ee676 100644 --- a/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts +++ b/cli/src/contexts/tools/infrastructure/native-plugin-cli-adapter.ts @@ -7,6 +7,7 @@ export interface NativePluginCliShape { readonly forceRemoveArgs?: readonly string[]; readonly sourceCheckVerb?: string; readonly upgradeVerb?: string; + readonly updateVerb?: string; readonly enableVerb?: string; /** How the tool spells removing a plugin it installed: `remove` for codex, `uninstall` for * claude and copilot. Absent where this CLI enables plugins through a file it writes. */ @@ -43,10 +44,11 @@ export class NativePluginCliAdapter extends AbstractNativePluginCliAdapter { return this.succeeds(["plugin", "marketplace", verb, name]) ? "live" : "dead"; } - upgradeMarketplaces(): void { + upgradeMarketplaces(name: string): void { const verb = this.shape.upgradeVerb; if (verb === undefined) return; - this.run(["plugin", "marketplace", verb], `marketplace ${verb}`); + if (name.length === 0) throw new Error("A named marketplace is required for native refresh."); + this.run(["plugin", "marketplace", verb, name], `marketplace ${verb} ${name}`); } enablePlugin(pluginRef: string, scope: MarketplaceScope = "project"): void { @@ -58,6 +60,13 @@ export class NativePluginCliAdapter extends AbstractNativePluginCliAdapter { ); } + updatePlugin(pluginRef: string): void { + const verb = this.shape.updateVerb; + if (verb === undefined) + throw new Error(`${this.binary} does not support targeted native plugin update.`); + this.run(["plugin", verb, pluginRef], `plugin ${verb} ${pluginRef}`); + } + /** Undoes what `enablePlugin` did. `scope` must match what `enablePlugin` was called with: a * tool that installed at one scope and uninstalls at its default would silently miss the * entry it wrote. */ diff --git a/cli/src/kernel/errors.ts b/cli/src/kernel/errors.ts index d55531434..9a0adb539 100644 --- a/cli/src/kernel/errors.ts +++ b/cli/src/kernel/errors.ts @@ -26,6 +26,13 @@ export class InvalidPluginScopeError extends Error { } } +export class ActiveMachineDependentsError extends Error { + constructor(target: string, projects: readonly string[]) { + super(`Cannot remove ${target}: active projects still depend on it: ${projects.join(", ")}.`); + this.name = "ActiveMachineDependentsError"; + } +} + export class AuthenticationError extends Error { constructor(source: string) { super(`Authentication failed (${source}). Run \`aidd auth login\` to authenticate.`); diff --git a/cli/src/kernel/paths.ts b/cli/src/kernel/paths.ts index 3311a59b4..4c1e3914b 100644 --- a/cli/src/kernel/paths.ts +++ b/cli/src/kernel/paths.ts @@ -194,6 +194,18 @@ export function pathContainsOrEquals(outer: string, inner: string): boolean { return normalizedOuter === normalizedInner || normalizedInner.startsWith(`${normalizedOuter}/`); } +/** Whether two paths name the same directory: separators normalised, and case folded the way + * a case-insensitive filesystem would — on win32 only, where a backslash separates and case + * does not count. Elsewhere a backslash is an ordinary character in a name. Both sides are + * expected already resolved — this + * compares spelling, it does not resolve. A `realpath` answer and a path a host stored name + * the same directory in two spellings on Windows, and `===` calls them different. */ +export function samePath(a: string, b: string, platform: string = process.platform): boolean { + if (platform !== "win32") return a === b; + const folded = (p: string): string => p.replace(/\\/g, "/").toLowerCase(); + return folded(a) === folded(b); +} + /** Either direction: neither may sit inside the other. */ export function pathsOverlap(a: string, b: string): boolean { return pathContainsOrEquals(a, b) || pathContainsOrEquals(b, a); diff --git a/cli/src/presentation/commands/marketplace.ts b/cli/src/presentation/commands/marketplace.ts index 7efa3baae..86294c1b2 100644 --- a/cli/src/presentation/commands/marketplace.ts +++ b/cli/src/presentation/commands/marketplace.ts @@ -1,4 +1,5 @@ import type { Command } from "commander"; +import { parseInstallScope } from "../../contexts/framework/domain/install-scope.js"; import type { MarketplaceScope } from "../../kernel/scope.js"; import { parsePluginSourceShorthand } from "../../kernel/source.js"; import { createDeps, createMenuDeps } from "../../runtime/wiring/framework.js"; @@ -111,17 +112,23 @@ export function registerMarketplaceCommand(program: Command): void { .command("remove ") .description("Remove a registered plugin marketplace") .option("--yes", "Skip the orphan-cleanup prompt") - .action(async (name: string, cmdOptions: { yes?: boolean }) => { + .option("--scope ", "Remove from project or user scope", "project") + .action(async (name: string, cmdOptions: { yes?: boolean; scope: string }) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); try { + const scope = parseInstallScope(cmdOptions.scope) ?? "project"; const deps = await createDeps(projectRoot, { verbose }, output); - const result = await deps.marketplaceRemoveUseCase.execute({ + const result = await (scope === "user" + ? deps.userMarketplaceRemoveUseCase + : deps.marketplaceRemoveUseCase + ).execute({ name, projectRoot, autoConfirm: cmdOptions.yes ?? false, + scope, }); - await syncNativeActivation(deps, output, projectRoot); + if (scope !== "user") await syncNativeActivation(deps, output, projectRoot); printMarketplaceRemoved(output, result.marketplace.name, result.removedPluginCount); } catch (error) { errorHandler.handle(error); diff --git a/cli/src/presentation/commands/plugin.ts b/cli/src/presentation/commands/plugin.ts index ce5256c26..15e69dd72 100644 --- a/cli/src/presentation/commands/plugin.ts +++ b/cli/src/presentation/commands/plugin.ts @@ -37,18 +37,21 @@ export function registerPluginCommand(program: Command): void { .command("remove ") .description("Remove a plugin from one or all AI tools") .option("--tool ", "Target AI tool (default: all installed)") - .action(async (name: string, cmdOptions: { tool?: string }) => { + .option("--scope ", "Removal scope (default: project)") + .action(async (name: string, cmdOptions: { tool?: string; scope?: string }) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); try { assertValidAiToolId(cmdOptions.tool); + const scope = parseInstallScope(cmdOptions.scope) ?? "project"; const deps = await createDeps(projectRoot, { verbose }, output); await deps.pluginRemoveUseCase.execute({ pluginName: name, toolIds: parseToolOption(cmdOptions.tool), projectRoot, + scope, }); - await syncNativeActivation(deps, output, projectRoot); + if (scope === "project") await syncNativeActivation(deps, output, projectRoot); printPluginRemoved(output, name); } catch (error) { errorHandler.handle(error); @@ -147,18 +150,24 @@ export function registerPluginCommand(program: Command): void { .command("update [name]") .description("Update one or all plugins for one or all AI tools") .option("--tool ", "Target AI tool (default: all installed)") - .action(async (name: string | undefined, cmdOptions: { tool?: string }) => { + .option("--scope ", "Update scope (default: project)") + .action(async (name: string | undefined, cmdOptions: { tool?: string; scope?: string }) => { const { verbose, output, projectRoot } = parseGlobalOptions(program); const errorHandler = new ErrorHandler(output); try { assertValidAiToolId(cmdOptions.tool); + const scope = parseInstallScope(cmdOptions.scope) ?? "project"; const deps = await createDeps(projectRoot, { verbose }, output); - const updated = await deps.pluginUpdateUseCase.execute({ + const updated = await (scope === "user" + ? deps.userPluginUpdateUseCase + : deps.pluginUpdateUseCase + ).execute({ pluginNames: name !== undefined ? [name] : undefined, toolIds: parseToolOption(cmdOptions.tool), projectRoot, + scope, }); - await syncNativeActivation(deps, output, projectRoot); + if (scope === "project") await syncNativeActivation(deps, output, projectRoot); printPluginsUpdated(output, updated); } catch (error) { errorHandler.handle(error); diff --git a/cli/src/runtime/wiring/framework.ts b/cli/src/runtime/wiring/framework.ts index ab649e5fc..23819d772 100644 --- a/cli/src/runtime/wiring/framework.ts +++ b/cli/src/runtime/wiring/framework.ts @@ -35,6 +35,12 @@ import { InstallIdeToolUseCase } from "../../contexts/framework/application/inst import { InstallRuntimeConfigUseCase } from "../../contexts/framework/application/install/install-runtime-config-use-case.js"; import { PostInstallPipelineUseCase } from "../../contexts/framework/application/install/post-install-pipeline-use-case.js"; import { ListInstalledRulesUseCase } from "../../contexts/framework/application/list-installed-rules-use-case.js"; +import { NativeHostRegistrationGate } from "../../contexts/framework/application/ownership/native-host-registration-gate.js"; +import { ProjectPluginCleanup } from "../../contexts/framework/application/ownership/project-plugin-cleanup.js"; +import { UserMarketplaceRemoveUseCase } from "../../contexts/framework/application/ownership/user-marketplace-remove-use-case.js"; +import { UserPluginDistributionLoader } from "../../contexts/framework/application/ownership/user-plugin-distribution-loader.js"; +import { UserPluginFileUpdater } from "../../contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { UserPluginUpdateUseCase } from "../../contexts/framework/application/ownership/user-plugin-update-use-case.js"; import { PluginAddUseCase } from "../../contexts/framework/application/plugin/plugin-add-use-case.js"; import { PluginInstallFromMarketplaceUseCase } from "../../contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.js"; import { PluginInstallUseCase } from "../../contexts/framework/application/plugin/plugin-install-use-case.js"; @@ -66,13 +72,21 @@ import { PluginDistributionReaderAdapter } from "../../contexts/framework/infras import { UserManifestRepositoryAdapter } from "../../contexts/framework/infrastructure/user-manifest-repository-adapter.js"; import { UserSourceReferencesAdapter } from "../../contexts/framework/infrastructure/user-source-references-adapter.js"; import type { FileMerger } from "../../contexts/tools/domain/ports/file-merger.js"; +import type { NativeMarketplaceSourceReader } from "../../contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { codexMarketplaceSourceListContract } from "../../contexts/tools/domain/profiles/codex/native-marketplace-source.js"; +import { copilotMarketplaceSourceListContract } from "../../contexts/tools/domain/profiles/copilot/native-marketplace-source.js"; import { hostPluginRegistryReaders } from "../../contexts/tools/infrastructure/host-plugin-registry-reader-adapter.js"; +import { + HostRegistryMarketplaceSourceReaderAdapter, + NativeMarketplaceSourceReaderAdapter, +} from "../../contexts/tools/infrastructure/native-marketplace-source-reader-adapter.js"; import type { AssetProvider } from "../../kernel/ports/asset-provider.js"; import type { FileReader } from "../../kernel/ports/file-reader.js"; import type { FileWriter } from "../../kernel/ports/file-writer.js"; import type { Logger } from "../../kernel/ports/logger.js"; import type { Prompter } from "../../kernel/ports/prompter.js"; import type { VersionReader } from "../../kernel/ports/version-reader.js"; +import type { AiToolId } from "../../kernel/tool.js"; import { CLIOutput } from "../../presentation/output.js"; import { PluginPickUseCase } from "../../presentation/prompts/plugin-pick-use-case.js"; import { SetupPluginsPromptUseCase } from "../../presentation/prompts/setup-plugins-prompt-use-case.js"; @@ -85,6 +99,7 @@ import { AuthStorage } from "../auth/auth-storage.js"; import { GhCliAdapter } from "../auth/gh-cli-adapter.js"; import { GhTokenAdapter } from "../auth/gh-token-adapter.js"; import type { CredentialStore } from "../auth/ports/credential-store.js"; +import { atomicWriteFile } from "../filesystem/atomic-write.js"; import { FileAdapter } from "../filesystem/file-adapter.js"; import { HasherAdapter } from "../filesystem/hasher-adapter.js"; import { GitAdapter } from "../git/git-adapter.js"; @@ -126,9 +141,11 @@ interface Deps extends TelemetryDeps { pluginRemoveUseCase: PluginRemoveUseCase; pluginListUseCase: PluginListUseCase; pluginUpdateUseCase: PluginUpdateUseCase; + userPluginUpdateUseCase: UserPluginUpdateUseCase; marketplaceAddUseCase: MarketplaceAddUseCase; marketplaceListUseCase: MarketplaceListUseCase; marketplaceRemoveUseCase: MarketplaceRemoveUseCase; + userMarketplaceRemoveUseCase: UserMarketplaceRemoveUseCase; marketplaceRefreshUseCase: MarketplaceRefreshUseCase; marketplaceCheckUseCase: MarketplaceCheckUseCase; userSourceReferences: UserSourceReferences; @@ -191,7 +208,7 @@ export async function createDeps( const fs = new FileAdapter(hasher, logger); const pluginDistributionReader = new PluginDistributionReaderAdapter(fs); const manifestRepo = new ManifestRepositoryAdapter(projectRoot); - const userManifestRepo = new UserManifestRepositoryAdapter(userConfigDir); + const userManifestRepo = new UserManifestRepositoryAdapter(userConfigDir, atomicWriteFile); const http = new HttpClient(); const authStorage = new AuthStorage(); const ghCliAdapter = new GhCliAdapter(); @@ -222,6 +239,17 @@ export async function createDeps( ? new InquirerPrompterAdapter() : new SilentPrompterAdapter(); const { nativePluginActivators, hostMarketplaceRegistries } = wireTools(); + const nativeSources = new Map([ + ["codex", new NativeMarketplaceSourceReaderAdapter(codexMarketplaceSourceListContract)], + ["copilot", new NativeMarketplaceSourceReaderAdapter(copilotMarketplaceSourceListContract)], + ]); + const claudeMarketplaceRegistry = hostMarketplaceRegistries.get("claude"); + if (claudeMarketplaceRegistry !== undefined) { + nativeSources.set( + "claude", + new HostRegistryMarketplaceSourceReaderAdapter(claudeMarketplaceRegistry) + ); + } // Read once, reused wherever a use case needs the scope a plugin is actually registered // at: removal, clean, and doctor's own registration check. const hostPluginRegistries = hostPluginRegistryReaders(); @@ -244,15 +272,28 @@ export async function createDeps( nativePluginActivators, hostPluginRegistries, userSourceReferences, - marketplaceRegistry + marketplaceRegistry, + userManifestRepo, + nativeSources ); const pluginListUseCase = new PluginListUseCase(manifestRepo); + const nativeHostRegistrationGate = new NativeHostRegistrationGate( + nativePluginActivators, + hostPluginRegistries, + nativeSources + ); const marketplaceRemoveUseCase = new MarketplaceRemoveUseCase( - fs, + new ProjectPluginCleanup(fs, userManifestRepo), manifestRepo, marketplaceRegistry, prompter ); + const userMarketplaceRemoveUseCase = new UserMarketplaceRemoveUseCase( + fs, + userManifestRepo, + marketplaceRegistry, + nativeHostRegistrationGate + ); // `marketplace add --overwrite` removes before it adds, and removing deletes installed // plugin files — framework work — so the orchestration belongs here rather than pulling // framework into distribution's own wiring. @@ -261,7 +302,12 @@ export async function createDeps( marketplaceTrustStore, resolveMarketplaceUseCase, prompter, - marketplaceRemoveUseCase + { + execute: (options) => + options.scope === "user" + ? userMarketplaceRemoveUseCase.execute(options) + : marketplaceRemoveUseCase.execute(options), + } ); const marketplaceCheckUseCase = new MarketplaceCheckUseCase( manifestRepo, @@ -303,7 +349,9 @@ export async function createDeps( marketplaceRegisterFrameworkUseCase, userSourceReferences, currentVersionProvider, - hostPluginRegistries + hostPluginRegistries, + userManifestRepo, + nativeSources ); const pluginAddUseCase = new PluginAddUseCase( fs, @@ -313,7 +361,8 @@ export async function createDeps( hasher, logger, marketplaceRegistry, - ensureBuiltMarketplaceUseCase + ensureBuiltMarketplaceUseCase, + userManifestRepo ); const gitignoreUseCase = new GitignoreUseCase(fs); const git = new GitAdapter(fs); @@ -455,6 +504,17 @@ export async function createDeps( hasher, builtMaterializationDeps ); + const userPluginUpdateUseCase = new UserPluginUpdateUseCase( + userManifestRepo, + new UserPluginFileUpdater( + fs, + new UserPluginDistributionLoader(pluginFetcher, pluginDistributionReader), + hasher, + builtMaterializationDeps + ), + logger, + nativeHostRegistrationGate + ); const restoreUseCase = new RestoreUseCase( fs, manifestRepo, @@ -467,7 +527,7 @@ export async function createDeps( assetProvider, builtMaterializationDeps ); - const uninstallUseCase = new UninstallUseCase(fs, manifestRepo, logger); + const uninstallUseCase = new UninstallUseCase(fs, manifestRepo, logger, userManifestRepo); const statusAllUseCase = new StatusAllUseCase(statusUseCase); const restoreAllUseCase = new RestoreAllUseCase( manifestRepo, @@ -504,7 +564,9 @@ export async function createDeps( hostMarketplaceRegistries, undefined, userSourceReferences, - hostPluginRegistries + hostPluginRegistries, + userManifestRepo, + nativeSources ); const cleanUserScopeUseCase = new CleanUserScopeUseCase( fs, @@ -516,7 +578,9 @@ export async function createDeps( hostMarketplaceRegistries, homedir, userSourceReferences, - prompter + prompter, + nativeSources, + hostPluginRegistries ); const doctorAllUseCase = new DoctorAllUseCase(doctorUseCase); const listInstalledRulesUseCase = new ListInstalledRulesUseCase(fs); @@ -545,9 +609,11 @@ export async function createDeps( pluginRemoveUseCase, pluginListUseCase, pluginUpdateUseCase, + userPluginUpdateUseCase, marketplaceAddUseCase, marketplaceListUseCase, marketplaceRemoveUseCase, + userMarketplaceRemoveUseCase, marketplaceRefreshUseCase, marketplaceCheckUseCase, userSourceReferences, diff --git a/cli/tests/architecture/context-boundary.arch.test.ts b/cli/tests/architecture/context-boundary.arch.test.ts index 9d855012d..6432af088 100644 --- a/cli/tests/architecture/context-boundary.arch.test.ts +++ b/cli/tests/architecture/context-boundary.arch.test.ts @@ -23,6 +23,8 @@ const PUBLIC_MODULES: Readonly> = { // ports a caller wires a concrete adapter into, or whose type it must accept "src/contexts/tools/domain/ports/file-merger.ts", "src/contexts/tools/domain/ports/native-plugin-activator.ts", + // Native host mutations need a current source witness, not only a past manifest claim. + "src/contexts/tools/domain/ports/native-marketplace-source-reader.ts", "src/contexts/tools/domain/ports/schema-validator.ts", "src/contexts/tools/domain/ports/host-plugin-registry-reader.ts", // What a host's registry says about an installed plugin: telemetry's diagnostic and diff --git a/cli/tests/architecture/orchestrator-deps.arch.test.ts b/cli/tests/architecture/orchestrator-deps.arch.test.ts index c633bc9c1..48a721161 100644 --- a/cli/tests/architecture/orchestrator-deps.arch.test.ts +++ b/cli/tests/architecture/orchestrator-deps.arch.test.ts @@ -10,7 +10,8 @@ const MAX_INJECTED_USE_CASES = 4; /** * Orchestrators over the limit today, each with the count its reason was written around. - * The list may only shrink, and an entry naming only the file would let one grow in silence. + * The list normally shrinks. A separately justified host witness may raise an entry, while + * the exact counts still make any unreviewed growth fail this test. */ const BASELINE: readonly { readonly path: string; readonly injected: number }[] = [ // Six checks, one per thing that can drift, reported at once: the fan-out is the feature. @@ -26,17 +27,18 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] injected: 9, }, { path: "src/contexts/framework/application/restore/restore-use-case.ts", injected: 12 }, - // Both carry `hostPluginRegistries`, the host's own registry per `AiToolId`: clean asks it the - // scope a ref was registered at, sync whether the host enabled a ref before this project did. - { path: "src/contexts/framework/application/clean-use-case.ts", injected: 11 }, + // Host plugin registries prove ref scope/enablement; the added native source reader independently + // proves the current catalogue before these flows mutate it. Neither witness replaces the other. + { path: "src/contexts/framework/application/clean-use-case.ts", injected: 13 }, { path: "src/contexts/framework/application/flows/marketplace-sync-settings-use-case.ts", - injected: 13, + injected: 15, }, - // The machine-scope counterpart of `clean-use-case.ts`, same shape and same reason. + // Machine clean also needs both witnesses: source ownership and the full host ref list, + // because unregistering an owned catalogue could still disable a foreign installed ref. { path: "src/contexts/framework/application/clean/clean-user-scope-use-case.ts", - injected: 10, + injected: 12, }, { path: "src/contexts/telemetry/application/diagnose-telemetry-use-case.ts", injected: 10 }, { @@ -47,7 +49,7 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] path: "src/contexts/framework/application/shared/setup-marketplace-registration-use-case.ts", injected: 10, }, - { path: "src/contexts/framework/application/plugin/plugin-add-use-case.ts", injected: 8 }, + { path: "src/contexts/framework/application/plugin/plugin-add-use-case.ts", injected: 9 }, { path: "src/contexts/translate/application/strategies/flat-build-strategy.ts", injected: 8 }, { path: "src/contexts/framework/application/doctor/doctor-registration-use-case.ts", @@ -93,9 +95,9 @@ const BASELINE: readonly { readonly path: string; readonly injected: number }[] injected: 5, }, { path: "src/contexts/translate/application/translate-source.ts", injected: 5 }, - // Carries `clean`'s own shared-source guard as well, so `plugin remove` in one project - // cannot disable a plugin another project on the same machine still needs. - { path: "src/contexts/framework/application/plugin/plugin-remove-use-case.ts", injected: 7 }, + // Carries `clean`'s shared-source guard and the separate current-source witness so a + // targeted remove cannot disable another project's plugin or a repointed catalogue. + { path: "src/contexts/framework/application/plugin/plugin-remove-use-case.ts", injected: 9 }, ]; /** diff --git a/cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts b/cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts index 1a9446f4f..6f95e263c 100644 --- a/cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts +++ b/cli/tests/contexts/distribution/application/marketplace-add-use-case.unit.test.ts @@ -5,6 +5,7 @@ import { MarketplaceAddUseCase } from "../../../../src/contexts/distribution/app import { ResolveMarketplaceUseCase } from "../../../../src/contexts/distribution/application/resolve-marketplace-use-case.js"; import { PluginCatalogRepositoryAdapter } from "../../../../src/contexts/distribution/infrastructure/plugin-catalog-repository-adapter.js"; import { MarketplaceRemoveUseCase } from "../../../../src/contexts/framework/application/flows/marketplace-remove-use-case.js"; +import { ProjectPluginCleanup } from "../../../../src/contexts/framework/application/ownership/project-plugin-cleanup.js"; import { InvalidMarketplaceNameError, InvalidPluginManifestError, @@ -42,7 +43,12 @@ async function buildUseCase(prompter: Prompter = new KeepPrompter()) { fetchMarketplaceSource, new PluginCatalogRepositoryAdapter(fs) ); - const removeUseCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + const removeUseCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + prompter + ); const useCase = new MarketplaceAddUseCase( registry, trustStore, diff --git a/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts b/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts index f4df0480a..49b8cb31a 100644 --- a/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean-native-cache.integration.test.ts @@ -20,25 +20,48 @@ import type { HostMarketplaceRegistryReader, HostMarketplaceRegistryReading, } from "../../../../src/contexts/tools/domain/ports/host-marketplace-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; import { hostMarketplaceRegistryReaders } from "../../../../src/contexts/tools/infrastructure/host-marketplace-registry-reader-adapter.js"; +import { HostRegistryMarketplaceSourceReaderAdapter } from "../../../../src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.js"; import { AIDD_DIR } from "../../../../src/kernel/paths.js"; import type { AiToolId } from "../../../../src/kernel/tool.js"; import { CapturingLogger } from "../../../helpers/ports/capturing-logger.js"; import { FakeHostMarketplaceRegistryReader } from "../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../helpers/ports/fake-host-plugin-registry-reader.js"; import { FakeNativePluginActivator } from "../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-marketplace-registry.js"; const PROJECT_ROOT = "/test-project"; -// Not injectable: CleanUseCase.execute() resolves `nodeHomedir()` itself, so the same real -// function here — never a fixed literal — is what keeps every path below in sync with it. +// Resolve once for this in-memory fixture, then inject it into CleanUseCase: Stryker reuses +// runners while globalSetup changes HOME, so load-time cache paths and runtime paths must agree. const HOME = homedir(); const CLAUDE_CACHE_ROOT = join(HOME, ".claude", "plugins", "cache"); const CODEX_CACHE_ROOT = join(HOME, ".codex", "plugins", "cache"); const MARKETPLACE = "probe-mkt"; const REF = "plugin-a@probe-mkt"; +/** Codex's current catalogue is a literal host snapshot, not derived from the machine claim. */ +function codexHostSource(): NativeMarketplaceSourceReader { + return { + read: async () => ({ + location: "fixture Codex effective catalogue", + entries: new Map( + ["probe-mkt", "aidd-framework"].map((name) => [ + name, + { + kind: "effective-list" as const, + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + ]) + ), + }), + }; +} + /** Records every `deleteDirectory` call, so a test can prove containment refused one * without ever letting it run. */ class RecordingFileAdapter extends InMemoryFileAdapter { @@ -50,6 +73,50 @@ class RecordingFileAdapter extends InMemoryFileAdapter { } } +/** In-memory files have implicit directories; expose one real empty cache directory to prove + * a skipped host unregister cannot still make clean delete its cache shell. */ +class EmptyNativeCacheFileAdapter extends RecordingFileAdapter { + constructor(private readonly emptyCacheDir: string) { + super(); + } + + override async fileExists(path: string): Promise { + return path === this.emptyCacheDir || super.fileExists(path); + } + + override async listDirectory(path: string): Promise { + return path === this.emptyCacheDir ? [] : super.listDirectory(path); + } +} + +class LockTrackingManifestRepository extends InMemoryManifestRepository { + locked = false; + lockCalls = 0; + + async withExclusiveAccess(action: () => Promise): Promise { + this.lockCalls += 1; + this.locked = true; + try { + return await action(); + } finally { + this.locked = false; + } + } +} + +class LockAwareActivator extends FakeNativePluginActivator { + readonly removalsUnderLock: boolean[] = []; + + constructor(private readonly machineRepo: LockTrackingManifestRepository) { + super({ available: true }); + } + + override removeMarketplace(name: string, scope?: unknown, options?: { force?: boolean }): void { + this.removalsUnderLock.push(this.machineRepo.locked); + super.removeMarketplace(name, scope, options); + } +} + /** Fails `realpath` with a non-ENOENT error (EACCES) for one exact path, so a test can prove * `clean` treats that failure like containment — named and skipped — not as an abort. */ class RealpathDeniedFileAdapter extends RecordingFileAdapter { @@ -95,20 +162,37 @@ function seedManifest(toolId: "claude" | "codex", hostName: string, alias: strin manifest.addTool(toolId, "1.0.0", []); manifest.setNativeRegistrations(toolId, { binary: toolId, - marketplaces: [{ alias, hostName }], + marketplaces: [ + { + alias, + hostName, + provenance: + toolId === "claude" + ? { kind: "registry", source: "/resolved/source" } + : { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], pluginRefs: [REF], }); return manifest; } -function seedAiddMarketplaceRegistry(alias: string): InMemoryMarketplaceRegistry { +function seedAiddMarketplaceRegistry( + alias: string, + scope: "project" | "user" = "project" +): InMemoryMarketplaceRegistry { const registry = new InMemoryMarketplaceRegistry(); registry.save( PROJECT_ROOT, Marketplace.create({ name: alias, source: { kind: "local", path: "/some/built/path" }, - scope: "project", + scope, addedAt: "2026-01-01T00:00:00.000Z", }) ); @@ -119,13 +203,21 @@ function buildUseCase(deps: { fs: InMemoryFileAdapter; manifest: Manifest; activator: FakeNativePluginActivator; - binary: string; + binary: AiToolId; logger: CapturingLogger; aiddMarketplaceRegistry: InMemoryMarketplaceRegistry; hostMarketplaceRegistries?: ReadonlyMap; homeDir?: () => string; + userManifestRepo?: InMemoryManifestRepository; }): CleanUseCase { const manifestRepo = new InMemoryManifestRepository(deps.manifest, PROJECT_ROOT); + const claudeHostRegistry = deps.hostMarketplaceRegistries?.get("claude"); + const nativeReader: NativeMarketplaceSourceReader | undefined = + deps.binary === "codex" + ? codexHostSource() + : deps.binary === "claude" && claudeHostRegistry !== undefined + ? new HostRegistryMarketplaceSourceReaderAdapter(claudeHostRegistry) + : undefined; return new CleanUseCase( deps.fs, manifestRepo, @@ -135,11 +227,301 @@ function buildUseCase(deps: { deps.aiddMarketplaceRegistry, undefined, deps.hostMarketplaceRegistries ?? new Map(), - deps.homeDir + deps.homeDir ?? (() => HOME), + undefined, + new Map([ + [ + deps.binary, + new FakeHostPluginRegistryReader({ + location: "fixture host plugin registry", + refs: new Map(), + }), + ], + ]), + deps.userManifestRepo, + nativeReader === undefined ? new Map() : new Map([[deps.binary, nativeReader]]) ); } describe("clean purges a host's own plugin cache", () => { + it("project A clean keeps a non-framework Codex catalogue cache and B's exact native claim", async () => { + const fs = new RecordingFileAdapter(); + const cacheEntry = join(CODEX_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); + fs.setFile(cacheEntry, "B still uses these bytes"); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [PROJECT_ROOT, "/project-b"] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger, + aiddMarketplaceRegistry: registry, + userManifestRepo: userRepo, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(fs.getFile(cacheEntry)).toBe("B still uses these bytes"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/project-b"] }, + ]); + expect(logger.warnMessages.join("\n")).toContain("Still needed by /project-b"); + }); + it("removes a project-labelled Codex catalogue when machine refs belong only to another host catalogue", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: "other-alias", hostName: "other-catalog" }], + pluginRefs: ["other-plugin@other-catalog"], + pluginClaims: [{ ref: "other-plugin@other-catalog", dependents: ["/project-b"] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs: new RecordingFileAdapter(), + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: registry, + userManifestRepo: userRepo, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.removedMarketplaces).toEqual([MARKETPLACE]); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: "other-plugin@other-catalog", dependents: ["/project-b"] }, + ]); + }); + it("holds the machine lock through native catalogue removal after checking its claims", async () => { + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: [], + }); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + const userRepo = new LockTrackingManifestRepository(machine); + const activator = new LockAwareActivator(userRepo); + const useCase = buildUseCase({ + fs: new RecordingFileAdapter(), + manifest: project, + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + userManifestRepo: userRepo, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.removedMarketplaces).toEqual([MARKETPLACE]); + expect(activator.removalsUnderLock).toEqual([true]); + expect(userRepo.lockCalls).toBe(1); + expect(userRepo.locked).toBe(false); + }); + it("leaves a project-labelled machine-global catalogue when no user manifest can prove it unshared", async () => { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const emptyCache = join(CODEX_CACHE_ROOT, MARKETPLACE); + const fs = new EmptyNativeCacheFileAdapter(emptyCache); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger, + aiddMarketplaceRegistry: registry, + userManifestRepo: new InMemoryManifestRepository(), + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.deletedDirectories).not.toContain(emptyCache); + expect(logger.warnMessages.join("\n")).toContain( + "no user manifest can prove no other project still uses it" + ); + + const noRepoActivator = new FakeNativePluginActivator({ available: true }); + const noRepoLogger = new CapturingLogger(); + const noRepoFs = new EmptyNativeCacheFileAdapter(emptyCache); + const noRepoUseCase = buildUseCase({ + fs: noRepoFs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator: noRepoActivator, + binary: "codex", + logger: noRepoLogger, + aiddMarketplaceRegistry: registry, + }); + await noRepoUseCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(noRepoActivator.removedMarketplaces).toEqual([]); + expect(noRepoFs.deletedDirectories).not.toContain(emptyCache); + expect(noRepoLogger.warnMessages.join("\n")).toContain( + "no user manifest can prove no other project still uses it" + ); + }); + it("keeps a project-labelled framework catalogue when B still has an exact native machine claim", async () => { + const framework = "aidd-framework"; + const ref = `plugin-a@${framework}`; + const project = seedManifest("codex", framework, framework); + const projectRegistrations = project.getNativeRegistrations("codex"); + if (projectRegistrations === undefined) throw new Error("fixture missing project native state"); + project.setNativeRegistrations("codex", { ...projectRegistrations, pluginRefs: [ref] }); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: framework, hostName: framework }], + pluginRefs: [ref], + pluginClaims: [{ ref, dependents: [PROJECT_ROOT, "/project-b"] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs: new RecordingFileAdapter(), + manifest: project, + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(framework), + userManifestRepo: userRepo, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref, dependents: ["/project-b"] }, + ]); + }); + it("project A clean never unregisters a project-labelled Codex catalogue still carrying B's machine ref", async () => { + const fs = new RecordingFileAdapter(); + const cacheEntry = join(CODEX_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); + fs.setFile(cacheEntry, "B still uses these bytes"); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [PROJECT_ROOT, "/project-b"] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger, + aiddMarketplaceRegistry: registry, + userManifestRepo: userRepo, + }); + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(cacheEntry)).toBe("B still uses these bytes"); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/project-b"] }, + ]); + expect(logger.warnMessages.join("\n")).toContain("Still needed by /project-b"); + }); + it("does not purge B's empty Codex cache shell when a machine claim blocked host unregister", async () => { + const emptyCache = join(CODEX_CACHE_ROOT, MARKETPLACE); + const fs = new EmptyNativeCacheFileAdapter(emptyCache); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [PROJECT_ROOT, "/project-b"] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + manifest: seedManifest("codex", MARKETPLACE, MARKETPLACE), + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + userManifestRepo: userRepo, + }); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.deletedDirectories).not.toContain(emptyCache); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/project-b"] }, + ]); + }); it("purges claude's cache once undoing the registration actually frees the name", async () => { const fs = new RecordingFileAdapter(); const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); @@ -169,7 +551,8 @@ describe("clean purges a host's own plugin cache", () => { expect(await fs.fileExists(cacheEntry)).toBe(false); expect(activator.removedMarketplaces).toContain(MARKETPLACE); - expect(reader.reads).toBe(1); + // Source preflight, native undo recheck, then post-removal cache confirmation. + expect(reader.reads).toBe(3); }); it("leaves claude's cache in place, and names it, when the claude CLI is not on PATH", async () => { @@ -207,7 +590,7 @@ describe("clean purges a host's own plugin cache", () => { ).toBe(true); }); - it("leaves claude's cache in place when a fresh read still names it", async () => { + it("leaves claude's cache in place without trusting a registry read after host removal fails", async () => { // `removeMarketplace` throws (host refused, or the call failed for any other // reason `bestEffort` swallows), so the registry mirror never drops the name. const fs = new RecordingFileAdapter(); @@ -234,11 +617,12 @@ describe("clean purges a host's own plugin cache", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(await fs.fileExists(cacheEntry)).toBe(true); - expect(reader.reads).toBe(1); - expect(logger.warnMessages.some((m) => m.includes("still names it"))).toBe(true); + // Source preflight and undo recheck ran; failed removal never reached cache confirmation. + expect(reader.reads).toBe(2); + expect(logger.warnMessages.some((m) => m.includes("removal was not confirmed"))).toBe(true); }); - it("purges claude's cache when its registry does not exist at all", async () => { + it("refuses project clean when Claude's current registry does not exist", async () => { const fs = new RecordingFileAdapter(); const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); await fs.writeFile(cacheEntry, "{}"); @@ -258,12 +642,16 @@ describe("clean purges a host's own plugin cache", () => { hostMarketplaceRegistries: new Map([["claude", reader]]), }); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "absent on the host" + ); - expect(await fs.fileExists(cacheEntry)).toBe(false); + expect(fs.getFile(cacheEntry)).toBe("{}"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); }); - it("leaves claude's cache in place, and says so, when its registry could not be read", async () => { + it("refuses project clean without deleting cache when Claude's registry is unreadable", async () => { const fs = new RecordingFileAdapter(); const cacheEntry = join(CLAUDE_CACHE_ROOT, MARKETPLACE, "plugin-a", "1.0.0", "plugin.json"); await fs.writeFile(cacheEntry, "{}"); @@ -284,19 +672,17 @@ describe("clean purges a host's own plugin cache", () => { hostMarketplaceRegistries: new Map([["claude", reader]]), }); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "EACCES" + ); - expect(await fs.fileExists(cacheEntry)).toBe(true); - expect( - logger.warnMessages.some( - (m) => - m.includes("claude: plugin cache left in place, its registry could not be read") && - m.includes("known_marketplaces.json") - ) - ).toBe(true); + expect(fs.getFile(cacheEntry)).toBe("{}"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.deletedDirectories).toEqual([]); }); - it("purges the cache under the same HOME its own registry reader resolves its file from", async () => { + it("refuses project clean under the same HOME its registry reader proves absent", async () => { // A sentinel, never this machine's real home: a cache root composed from `os.homedir()` // directly ignores the injected `homeDir` and looks under the real home instead. const SENTINEL_HOME = "/sentinel-home-clean-cache-parity"; @@ -330,14 +716,17 @@ describe("clean purges a host's own plugin cache", () => { homeDir: () => SENTINEL_HOME, }); - await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "absent on the host" + ); - // The real reader finds no known_marketplaces.json under a sentinel home that does not - // exist on disk — absent, not unreadable — so purging proves both halves used it. - expect(await fs.fileExists(cacheEntry)).toBe(false); + // The real reader resolves the sentinel HOME, not the user's real registry. + expect(fs.getFile(cacheEntry)).toBe("{}"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); }); - it("refuses a '..' segment in a manifest's own hostName, never consulting the registry", async () => { + it("refuses a '..' cache path after proving the current host source", async () => { const hostName = "../../../evil"; const fs = new RecordingFileAdapter(); const witness = join(CLAUDE_CACHE_ROOT, hostName); @@ -366,7 +755,7 @@ describe("clean purges a host's own plugin cache", () => { expect(await fs.fileExists(join(witness, "keep-me.txt"))).toBe(true); expect(fs.deletedDirectories).not.toContain(witness); - expect(reader.reads).toBe(0); + expect(reader.reads).toBe(1); expect(logger.warnMessages.some((m) => m.includes("does not resolve inside"))).toBe(true); }); @@ -430,7 +819,7 @@ describe("clean purges a host's own plugin cache", () => { expect(fs.deletedDirectories).toContain(join(PROJECT_ROOT, AIDD_DIR, "cache")); }); - it("touches nothing under HOME for a tool whose profile declares no pluginCacheDir", async () => { + it("detaches a shared Copilot claim locally without a host source reader or HOME deletion", async () => { const fs = new RecordingFileAdapter(); const activator = new FakeNativePluginActivator({ available: true }); const manifest = Manifest.create(); @@ -446,12 +835,14 @@ describe("clean purges a host's own plugin cache", () => { activator, binary: "copilot", logger: new CapturingLogger(), - aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE, "user"), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(fs.deletedDirectories.some((p) => p.startsWith(HOME))).toBe(false); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); }); it("purges under the catalog's own hostName, never the project's local alias", async () => { @@ -495,6 +886,7 @@ describe("clean purges a host's own plugin cache", () => { binary: "codex", logger: new CapturingLogger(), aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + userManifestRepo: new InMemoryManifestRepository(Manifest.create()), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -516,6 +908,7 @@ describe("clean purges a host's own plugin cache", () => { binary: "codex", logger, aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + userManifestRepo: new InMemoryManifestRepository(Manifest.create()), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -540,6 +933,7 @@ describe("clean purges a host's own plugin cache", () => { binary: "codex", logger, aiddMarketplaceRegistry: seedAiddMarketplaceRegistry(MARKETPLACE), + userManifestRepo: new InMemoryManifestRepository(Manifest.create()), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); diff --git a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts index 38d69476d..0aeb7546e 100644 --- a/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/clean-use-case.unit.test.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto"; import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -7,12 +8,15 @@ import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; import "../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { Marketplace } from "../../../../src/contexts/distribution/domain/marketplace.js"; -import { CleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; +import { CleanUserScopeUseCase } from "../../../../src/contexts/framework/application/clean/clean-user-scope-use-case.js"; +import { CleanUseCase as ProductionCleanUseCase } from "../../../../src/contexts/framework/application/clean-use-case.js"; import { GitignoreUseCase } from "../../../../src/contexts/framework/application/gitignore-use-case.js"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { UserSourceReferencesAdapter } from "../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; import { cursorProjectHooksScriptDir } from "../../../../src/contexts/tools/domain/formats/cursor-hooks-project-merge.js"; +import type { HostPluginRegistryReader } from "../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; import { FileHash, InstallationFile } from "../../../../src/kernel/file.js"; import type { AiToolId, ToolId } from "../../../../src/kernel/tool.js"; @@ -26,6 +30,75 @@ import { InMemoryMarketplaceRegistry } from "../../../helpers/ports/in-memory-ma import { RecordingPrompter } from "../../../helpers/ports/recording-prompter.js"; const PROJECT_ROOT = "/test-project"; +const md5 = (content: string): string => createHash("md5").update(content, "utf-8").digest("hex"); + +/** Test-double host catalogues, deliberately seeded without consulting the manifest claim. */ +function nativeSourcesOnHost(): ReadonlyMap { + const codexLike: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "fixture native host catalogue", + entries: new Map( + ["aidd-framework", "user-mkt", "other-host"].map((name) => [ + name, + { + kind: "effective-list" as const, + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + ]) + ), + }), + }; + const claude: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "fixture Claude host registry", + entries: new Map([ + ["aidd-framework", { kind: "registry" as const, source: "/some/built/path" }], + ]), + }), + }; + return new Map([ + ["codex", codexLike], + ["copilot", codexLike], + ["cursor", codexLike], + ["claude", claude], + ]); +} + +/** Independently parsed test registry: no foreign refs unless a test seeds them explicitly. */ +function nativeRefsOnHost(): ReadonlyMap { + const empty = new FakeHostPluginRegistryReader({ + location: "fixture native plugin registry", + refs: new Map(), + }); + return new Map([ + ["codex", empty], + ["copilot", empty], + ["claude", empty], + ["cursor", empty], + ]); +} + +class CleanWithKnownHostSource extends ProductionCleanUseCase { + constructor(...args: ConstructorParameters) { + super( + args[0], + args[1], + args[2], + args[3], + args[4], + args[5], + args[6], + args[7], + args[8], + args[9], + args[10]?.size ? args[10] : nativeRefsOnHost(), + args[11], + args[12] ?? nativeSourcesOnHost() + ); + } +} /** Records every path `deleteFile` is called with, so a test can prove where a plugin's * file actually got deleted from without inspecting private use-case state. */ @@ -50,7 +123,7 @@ describe("clean", () => { const gitignorePath = join(PROJECT_ROOT, ".gitignore"); await deps.fs.writeFile(gitignorePath, "node_modules/\n.aidd/cache/\ndist/\n"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -71,7 +144,7 @@ describe("clean", () => { const gitignorePath = join(PROJECT_ROOT, ".gitignore"); await deps.fs.writeFile(gitignorePath, "node_modules/\n.aidd/cache/\naidd_docs/runs/\ndist/\n"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -93,7 +166,7 @@ describe("clean", () => { const gitignorePath = join(PROJECT_ROOT, ".gitignore"); await deps.fs.writeFile(gitignorePath, "node_modules/\n"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -112,7 +185,7 @@ describe("clean", () => { const userFile = join(PROJECT_ROOT, "my-custom-file.txt"); await deps.fs.writeFile(userFile, "user content"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -132,7 +205,7 @@ describe("clean", () => { await deps.fs.writeFile(configPath, '{"telemetry":{"enabled":true}}'); await deps.fs.writeFile(cacheFile, "{}"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -152,7 +225,7 @@ describe("clean", () => { const pluginCacheFile = join(PROJECT_ROOT, ".aidd", "plugin-cache", "some-plugin", "x.json"); await deps.fs.writeFile(pluginCacheFile, "{}"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -171,7 +244,7 @@ describe("clean", () => { const cacheFile = join(PROJECT_ROOT, ".aidd", "cache", "built", "leftover.json"); await deps.fs.writeFile(cacheFile, "{}"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -190,7 +263,7 @@ describe("clean", () => { const registry = join(PROJECT_ROOT, ".aidd", "marketplaces.json"); await deps.fs.writeFile(registry, JSON.stringify({ version: 1, marketplaces: [] })); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -213,7 +286,7 @@ describe("clean", () => { await deps.fs.writeFile(config, JSON.stringify({ telemetry: { enabled: true } })); await deps.fs.writeFile(registry, JSON.stringify({ version: 1, marketplaces: [] })); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -225,7 +298,7 @@ describe("clean", () => { expect(deps.fs.has(config)).toBe(true); }); - it("deletes a user-scope (cursor) plugin's file from its resolved home directory, not projectRoot", async () => { + it("leaves a user-scope Cursor plugin's files to explicit user cleanup", async () => { const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( @@ -242,7 +315,7 @@ describe("clean", () => { const fs = new RecordingFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -252,7 +325,7 @@ describe("clean", () => { expect( fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) - ).toBe(true); + ).toBe(false); expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, PLUGIN_KEY)); }); @@ -274,7 +347,7 @@ describe("clean", () => { const fs = new RecordingFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -298,12 +371,57 @@ describe("clean", () => { manifest.addTool("codex", "1.0.0", []); manifest.setNativeRegistrations("codex", { binary: BINARY, - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: [REF], + }); + return manifest; + } + + function seedClaudeNativeRegistrations(): Manifest { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: "/some/built/path" }, + }, + ], pluginRefs: [REF], }); return manifest; } + /** Explicit host ref evidence for Claude positives, separate from the AIDD manifest. */ + function claudeRefOnHost( + scope?: "project" | "user" + ): ReadonlyMap { + return new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "fixture Claude installed-plugin registry", + refs: new Map([ + [REF, scope === undefined ? { enabled: true } : { enabled: true, scope }], + ]), + }), + ], + ]); + } + function seedMarketplaceRegistry(): InMemoryMarketplaceRegistry { const registry = new InMemoryMarketplaceRegistry(); registry.save( @@ -318,12 +436,456 @@ describe("clean", () => { return registry; } - it("uninstalls the registered plugin ref and removes the marketplace it came from", async () => { + describe("machine claims survive project cleanup", () => { + it("names shared-source dependents only on refs belonging to that source", async () => { + const manifest = seedManifestWithNativeRegistrations(); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [ + ...(manifest.getNativeRegistrations("codex")?.marketplaces ?? []), + { + alias: "other", + hostName: "other-host", + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: [REF, "unclaimed@other-host"], + }); + const fs = new InMemoryFileAdapter(); + for (const root of [PROJECT_ROOT, "/project-b", "/project-c"]) + fs.setFile(join(root, "marker"), "project"); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + for (const root of [PROJECT_ROOT, "/project-b", "/project-c"]) + await references.addReference("1.0.0", root); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other", + source: { kind: "local", path: "/some/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + references, + undefined, + new InMemoryManifestRepository(Manifest.create(), "/fake-home") + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual(["other-host"]); + expect(await references.listAllReferencingProjects()).toEqual(["/project-b", "/project-c"]); + expect(logger.warnMessages.slice(0, 2)).toEqual([ + `codex: '${REF}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean. Still needed by /project-b, /project-c.`, + "codex: 'unclaimed@other-host' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.", + ]); + }); + + it.each([ + { others: [], suffix: "" }, + { + others: ["/project-b", "/project-c"], + suffix: " Still needed by /project-b, /project-c.", + }, + ])( + "keeps canonical refs and reports remaining dependents: $others", + async ({ others, suffix }) => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), "project"); + const cachePath = "/fake-home/.codex/plugins/cache/aidd-framework/plugin.json"; + fs.setFile(cachePath, "cache"); + const extraRef = "another@aidd-framework"; + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: manifest.getNativeRegistrations("codex")?.marketplaces ?? [], + pluginRefs: [REF, extraRef], + pluginClaims: [ + { ref: REF, dependents: [PROJECT_ROOT, ...others] }, + { ref: extraRef, dependents: [PROJECT_ROOT, ...others] }, + { ref: "unrelated@other-host", dependents: ["/unrelated"] }, + ], + }); + const machineRepo = new InMemoryManifestRepository(machine, "/fake-home"); + const projectRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const hostRefs = new FakeHostPluginRegistryReader({ + location: "Codex machine refs", + refs: new Map([ + [REF, { enabled: true }], + [extraRef, { enabled: true }], + ]), + }); + const useCase = new CleanWithKnownHostSource( + fs, + projectRepo, + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry(), + undefined, + new Map(), + () => "/fake-home", + undefined, + new Map([["codex", hostRefs]]), + machineRepo + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(cachePath)).toBe("cache"); + expect(projectRepo.getCurrent()).toBeNull(); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: others }, + { ref: extraRef, dependents: [PROJECT_ROOT, ...others] }, + { ref: "unrelated@other-host", dependents: ["/unrelated"] }, + ]); + expect(logger.warnMessages).toEqual([ + `codex: '${REF}' is an AIDD-owned machine plugin ref — left enabled; project claim detached after local clean.${suffix}`, + `codex: '${MARKETPLACE}' carries AIDD-owned machine plugin refs — left registered for explicit user-scope removal.${suffix}`, + `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join("/fake-home", ".codex", "plugins", "cache", MARKETPLACE)}`, + ]); + } + ); + + it.each([undefined, { binary: BINARY, marketplaces: [], pluginRefs: [] }])( + "removes an empty project catalogue when the machine has no matching claims: %j", + async (machineRegistrations) => { + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + if (machineRegistrations !== undefined) + machine.setNativeRegistrations("codex", machineRegistrations); + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(seedManifestWithNativeRegistrations(), PROJECT_ROOT), + logger, + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry(), + undefined, + new Map(), + () => "/fake-home", + undefined, + undefined, + new InMemoryManifestRepository(machine, "/fake-home") + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.removedMarketplaces).toEqual([MARKETPLACE]); + expect(logger.warnMessages).toEqual([ + `codex: '${REF}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.`, + ]); + } + ); + }); + + describe("unproven Claude refs in a shared source", () => { + async function sharedClaudeRegistry(): Promise { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/some/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + return registry; + } + + it.each([ + { entries: new Map(), reason: "Reconcile manually." }, + { + entries: new Map([[MARKETPLACE, { kind: "registry" as const, source: "/foreign" }]]), + reason: `Catalogue '${MARKETPLACE}': current host source differs from AIDD's recorded source; reconcile manually.`, + }, + ])( + "leaves the ref enabled when its host source is unproven: $reason", + async ({ entries, reason }) => { + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => ({ location: "Claude source fixture", entries }), + }; + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(seedClaudeNativeRegistrations(), PROJECT_ROOT), + logger, + new GitignoreUseCase(fs), + new Map([["claude", activator]]), + await sharedClaudeRegistry(), + undefined, + new Map(), + () => "/fake-home", + undefined, + claudeRefOnHost("project"), + undefined, + new Map([["claude", source]]) + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(logger.warnMessages[0]).toBe( + `claude: '${REF}' left enabled because its current host catalogue is unproven. ${reason}` + ); + } + ); + + it("warns about a ref whose host catalogue was not recorded", async () => { + const manifest = seedClaudeNativeRegistrations(); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: manifest.getNativeRegistrations("claude")?.marketplaces ?? [], + pluginRefs: ["orphan@unrecorded"], + }); + const fs = new InMemoryFileAdapter(); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + logger, + new GitignoreUseCase(fs), + new Map([["claude", activator]]), + await sharedClaudeRegistry() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(logger.warnMessages[0]).toBe( + "claude: 'orphan@unrecorded' has no recorded host catalogue — left enabled." + ); + }); + + it.each([false, true])( + "only uninstalls when all other host refs have canonical machine claims: %s", + async (claimed) => { + const extraRef = "machine-plugin@aidd-framework"; + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), "project"); + const machine = Manifest.create(); + machine.addTool("claude", "1.0.0", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [], + pluginRefs: [extraRef], + pluginClaims: claimed ? [{ ref: extraRef, dependents: ["/other-project"] }] : [], + }); + const hostRefs = new FakeHostPluginRegistryReader({ + location: "Claude refs fixture", + refs: new Map([ + [REF, { enabled: true, scope: "project" }], + [extraRef, { enabled: true, scope: "user" }], + ]), + }); + const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(seedClaudeNativeRegistrations(), PROJECT_ROOT), + logger, + new GitignoreUseCase(fs), + new Map([["claude", activator]]), + await sharedClaudeRegistry(), + undefined, + new Map(), + () => "/fake-home", + undefined, + new Map([["claude", hostRefs]]), + new InMemoryManifestRepository(machine, "/fake-home") + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual(claimed ? [REF] : []); + expect(activator.removedMarketplaces).toEqual([]); + if (claimed) + expect( + logger.warnMessages.some((message) => message.includes("foreign host ref")) + ).toBe(false); + else + expect(logger.warnMessages[0]).toBe( + `claude: '${REF}' left enabled; Error: Catalogue '${MARKETPLACE}' includes foreign host ref '${extraRef}'; no host mutation made.` + ); + } + ); + }); + + it("refuses a repointed native catalogue before shared reference or local deletion", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { + kind: "effective-list", + root: "/aidd/built", + sourceType: "local", + source: "/aidd/built", + }, + }, + ], + pluginRefs: [REF], + }); + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), "keep"); + fs.setFile("/other-project/marker", "keep"); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", "/other-project"); + const activator = new FakeNativePluginActivator({ available: true }); + const sourceReader: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "fake codex host", + entries: new Map([ + [ + MARKETPLACE, + { + kind: "effective-list" as const, + root: "/foreign/built", + sourceType: "local", + source: "/foreign/built", + }, + ], + ]), + }), + }; + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry(), + undefined, + new Map(), + () => "/fake-home", + references, + new Map(), + undefined, + new Map([["codex", sourceReader]]) + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "differs" + ); + + expect(await references.listAllReferencingProjects()).toContain(PROJECT_ROOT); + expect(await references.listAllReferencingProjects()).toContain("/other-project"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(join(PROJECT_ROOT, "marker"))).toBe("keep"); + }); + + it("refuses a foreign enabled ref inside an otherwise AIDD-owned catalogue", async () => { + const manifest = Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: BINARY, + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: [REF], + }); + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), "keep"); + fs.setFile("/other-project/marker", "keep"); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", "/other-project"); + const activator = new FakeNativePluginActivator({ available: true }); + const hostRefs = new FakeHostPluginRegistryReader({ + location: "fake codex plugins", + refs: new Map([ + [REF, { enabled: true }], + ["foreign@aidd-framework", { enabled: true }], + ]), + }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([[BINARY, activator]]), + seedMarketplaceRegistry(), + undefined, + new Map(), + () => "/fake-home", + references, + new Map([["codex", hostRefs]]) + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "foreign" + ); + + expect(await references.listAllReferencingProjects()).toContain(PROJECT_ROOT); + expect(await references.listAllReferencingProjects()).toContain("/other-project"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(join(PROJECT_ROOT, "marker"))).toBe("keep"); + }); + + it("leaves an unclaimed machine-global ref enabled while removing its empty project catalogue", async () => { const manifest = seedManifestWithNativeRegistrations(); const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: true }); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -334,11 +896,12 @@ describe("clean", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toEqual([REF]); + expect(activator.uninstalledPlugins).toEqual([]); expect(activator.removedMarketplaces).toEqual([MARKETPLACE]); + expect(manifestRepo.getCurrent()).toBeNull(); }); - it("leaves a machine-scope marketplace registered — every other project on this machine shares it — while still uninstalling this project's own plugin ref", async () => { + it("leaves both an unclaimed machine-global ref and its shared catalogue registered", async () => { const manifest = seedManifestWithNativeRegistrations(); const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); @@ -355,7 +918,7 @@ describe("clean", () => { ); const logger = new CapturingLogger(); const HOME = "/fake-home"; - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -369,8 +932,9 @@ describe("clean", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toEqual([REF]); + expect(activator.uninstalledPlugins).toEqual([]); expect(activator.removedMarketplaces).toEqual([]); + expect(logger.warnMessages.join("\n")).toContain("no canonical machine claim"); // The message must name all three survivors: the host's own registration, // `userConfigDir()/marketplaces.json`, and the tool's own plugin cache. const message = logger.warnMessages.find( @@ -402,7 +966,13 @@ describe("clean", () => { ); manifest.setNativeRegistrations("claude", { binary: CLAUDE_BINARY, - marketplaces: [{ alias: CLAUDE_MARKETPLACE, hostName: CLAUDE_MARKETPLACE }], + marketplaces: [ + { + alias: CLAUDE_MARKETPLACE, + hostName: CLAUDE_MARKETPLACE, + provenance: { kind: "registry", source: "/some/built/path" }, + }, + ], pluginRefs: [CLAUDE_REF], }); return manifest; @@ -432,7 +1002,7 @@ describe("clean", () => { available: true, installedAtScope: new Map([[CLAUDE_REF, "user"]]), }); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -464,7 +1034,7 @@ describe("clean", () => { }), ], ]); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -514,7 +1084,7 @@ describe("clean", () => { const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -540,6 +1110,62 @@ describe("clean", () => { expect((await registry.list(OTHER_PROJECT_ROOT)).map((m) => m.name)).toContain(MARKETPLACE); }); + it("keeps its shared source claim when gitignore cleanup fails", async () => { + const manifest = seedManifestWithNativeRegistrations(); + const fs = new InMemoryFileAdapter(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: MARKETPLACE, + source: { kind: "local", path: "/shared/built/path" }, + scope: "user", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + const references = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", OTHER_PROJECT_ROOT); + const gitignore = new GitignoreUseCase(fs); + gitignore.remove = async () => { + throw new Error("gitignore cleanup failed"); + }; + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + gitignore, + new Map([[BINARY, new FakeNativePluginActivator({ available: true })]]), + registry, + undefined, + new Map(), + () => "/fake-home", + references + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "gitignore cleanup failed" + ); + expect(await references.listAllReferencingProjects()).toEqual([ + PROJECT_ROOT, + OTHER_PROJECT_ROOT, + ]); + const userClean = new CleanUserScopeUseCase( + fs, + new InMemoryManifestRepository(), + new CapturingLogger(), + registry, + () => "/fake-home/.config/aidd", + new Map(), + new Map(), + () => "/fake-home", + references + ); + await expect(userClean.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "the shared framework source" + ); + }); + it("names how many other projects still reference the source", async () => { const manifest = seedManifestWithNativeRegistrations(); const fs = new InMemoryFileAdapter(); @@ -559,7 +1185,7 @@ describe("clean", () => { await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -606,7 +1232,7 @@ describe("clean", () => { await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); await userSourceReferences.addReference("1.0.0", THIRD_PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -644,7 +1270,7 @@ describe("clean", () => { const userSourceReferences = seedReferences(fs, [PROJECT_ROOT]); await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -684,7 +1310,7 @@ describe("clean", () => { const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); await userSourceReferences.addReference("1.0.0", OTHER_PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -727,7 +1353,7 @@ describe("clean", () => { const userSourceReferences = seedReferences(fs, [PROJECT_ROOT, OTHER_PROJECT_ROOT]); await userSourceReferences.addReference("1.0.0", PROJECT_ROOT); await userSourceReferences.addReference("2.0.0", OTHER_PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -769,7 +1395,7 @@ describe("clean", () => { await userSourceReferences.addReference("0.9.0", PROJECT_ROOT); await userSourceReferences.addReference("0.9.0", OTHER_PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -816,7 +1442,7 @@ describe("clean", () => { () => "/fake-home/.config/aidd" ); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -857,7 +1483,7 @@ describe("clean", () => { () => "/fake-home/.config/aidd" ); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -883,7 +1509,7 @@ describe("clean", () => { const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: false }); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, logger, @@ -909,18 +1535,23 @@ describe("clean", () => { }); it("uninstalls the plugin ref before removing the marketplace, for the same tool", async () => { - const manifest = seedManifestWithNativeRegistrations(); + const manifest = seedClaudeNativeRegistrations(); const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const calls: string[] = []; const activator = new OrderRecordingActivator(calls); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), new GitignoreUseCase(fs), - new Map([[BINARY, activator]]), - seedMarketplaceRegistry() + new Map([["claude", activator]]), + seedMarketplaceRegistry(), + undefined, + new Map(), + undefined, + undefined, + claudeRefOnHost("project") ); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -946,7 +1577,7 @@ describe("clean", () => { const activator = new AssertingActivator(async () => { builtTreeExistedAtRemoveMarketplace = fs.has(builtPath); }); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -965,7 +1596,7 @@ describe("clean", () => { const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: true }); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -997,7 +1628,18 @@ describe("clean", () => { manifest.addTool("codex", "1.0.0", []); manifest.setNativeRegistrations("codex", { binary: BINARY, - marketplaces: [{ alias: ALIAS, hostName: HOST_NAME }], + marketplaces: [ + { + alias: ALIAS, + hostName: HOST_NAME, + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], pluginRefs: [], }); const fs = new InMemoryFileAdapter(); @@ -1013,13 +1655,19 @@ describe("clean", () => { addedAt: "2026-01-01T00:00:00.000Z", }) ); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), new GitignoreUseCase(fs), new Map([[BINARY, activator]]), - registry + registry, + undefined, + undefined, + undefined, + undefined, + undefined, + new InMemoryManifestRepository(Manifest.create()) ); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -1038,8 +1686,9 @@ describe("clean", () => { logger: CapturingLogger; activators: ReadonlyMap; registry: InMemoryMarketplaceRegistry | undefined; - }): CleanUseCase { - return new CleanUseCase( + hostPluginRegistries?: ReadonlyMap; + }): CleanWithKnownHostSource { + return new CleanWithKnownHostSource( deps.fs, new InMemoryManifestRepository(deps.manifest, PROJECT_ROOT), deps.logger, @@ -1048,7 +1697,9 @@ describe("clean", () => { deps.registry, undefined, new Map(), - () => HOME + () => HOME, + undefined, + deps.hostPluginRegistries ); } @@ -1071,7 +1722,7 @@ describe("clean", () => { ]); }); - it("leaves a registration in place when its alias is no longer registered here, still uninstalling the plugin ref", async () => { + it("leaves a lost-alias registration and its unclaimed machine-global ref untouched", async () => { const fs = new InMemoryFileAdapter(); fs.setFile(join(CODEX_CACHE, MARKETPLACE, "leftover.json"), "{}"); const logger = new CapturingLogger(); @@ -1096,9 +1747,10 @@ describe("clean", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toStrictEqual([REF]); + expect(activator.uninstalledPlugins).toStrictEqual([]); expect(activator.removedMarketplaces).toStrictEqual([]); expect(logger.warnMessages).toStrictEqual([ + `codex: '${REF}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.`, `codex: '${MARKETPLACE}' is no longer a registered marketplace here, so its scope cannot be resolved — its codex registration was left in place.`, `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, ]); @@ -1118,8 +1770,10 @@ describe("clean", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.uninstalledPlugins).toStrictEqual([]); expect(activator.removedMarketplaces).toStrictEqual([]); expect(logger.warnMessages).toStrictEqual([ + `codex: '${REF}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.`, `codex: '${MARKETPLACE}' is no longer a registered marketplace here, so its scope cannot be resolved — its codex registration was left in place.`, `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, ]); @@ -1139,7 +1793,9 @@ describe("clean", () => { await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + expect(activator.uninstalledPlugins).toStrictEqual([]); expect(logger.warnMessages).toStrictEqual([ + `codex: '${REF}' has no canonical machine claim — left enabled for manual reconciliation; project claim detached after local clean.`, `codex marketplace remove '${MARKETPLACE}' failed: marketplace remove ${MARKETPLACE} failed: '${MARKETPLACE}' is not configured or installed`, `codex: cache for '${MARKETPLACE}' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, MARKETPLACE)}`, ]); @@ -1153,29 +1809,31 @@ describe("clean", () => { failOnUninstall: [REF], }); const useCase = buildUseCase({ - manifest: seedManifestWithNativeRegistrations(), + manifest: seedClaudeNativeRegistrations(), fs, logger, - activators: new Map([[BINARY, activator]]), + activators: new Map([["claude", activator]]), registry: seedMarketplaceRegistry(), + hostPluginRegistries: claudeRefOnHost(), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(activator.uninstalledPluginScopes).toStrictEqual(["project", "user"]); expect(logger.warnMessages).toStrictEqual([ - `codex plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed`, + `claude plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed`, ]); }); it("propagates an activator failure that is not the host refusing", async () => { const fs = new InMemoryFileAdapter(); const useCase = buildUseCase({ - manifest: seedManifestWithNativeRegistrations(), + manifest: seedClaudeNativeRegistrations(), fs, logger: new CapturingLogger(), - activators: new Map([[BINARY, new CrashingUninstallActivator()]]), + activators: new Map([["claude", new CrashingUninstallActivator()]]), registry: seedMarketplaceRegistry(), + hostPluginRegistries: claudeRefOnHost("project"), }); await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( @@ -1202,7 +1860,18 @@ describe("clean", () => { manifest.addTool(toolId, "1.0.0", []); manifest.setNativeRegistrations(toolId, { binary, - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], pluginRefs: [], }); return manifest; @@ -1226,6 +1895,115 @@ describe("clean", () => { ]); }); + it("detaches project A without host mutation when a shared Copilot source is unreadable", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), ""); + fs.setFile(join("/other-project", "marker"), ""); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", "/other-project"); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(seedRegistrations("copilot", "copilot"), PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["copilot", activator]]), + seedSharedRegistry(), + undefined, + new Map(), + () => "/fake-home", + references, + undefined, + undefined, + new Map() + ); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(result.manifestFound).toBe(true); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.uninstalledPlugins).toEqual([]); + expect(await references.listAllReferencingProjects()).toEqual(["/other-project"]); + }); + + it("does not uninstall an unclaimed shared ref when its current source is unreadable", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), ""); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + const manifest = seedRegistrations("copilot", "copilot"); + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: manifest.getNativeRegistrations("copilot")?.marketplaces ?? [], + pluginRefs: [`demo@${MARKETPLACE}`], + }); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["copilot", activator]]), + seedSharedRegistry(), + undefined, + new Map(), + () => "/fake-home", + references, + undefined, + undefined, + new Map() + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(await references.listAllReferencingProjects()).toEqual([]); + }); + + it("leaves a machine-global ref enabled without a canonical claim even when host source is proven", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(join(PROJECT_ROOT, "marker"), ""); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + const manifest = seedRegistrations("codex", "codex"); + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: manifest.getNativeRegistrations("codex")?.marketplaces ?? [], + pluginRefs: [`demo@${MARKETPLACE}`], + }); + const activator = new FakeNativePluginActivator({ available: true }); + const hostRefs = new Map([ + [ + "codex", + new FakeHostPluginRegistryReader({ + location: "Codex user-global registry", + refs: new Map([[`demo@${MARKETPLACE}`, { enabled: true }]]), + }), + ], + ]); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["codex", activator]]), + seedSharedRegistry(), + undefined, + new Map(), + () => "/fake-home", + references, + hostRefs + ); + + await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(await references.listAllReferencingProjects()).toEqual([]); + }); + it("names no cache for a shared marketplace at a tool that drives no native CLI (cursor)", async () => { const fs = new InMemoryFileAdapter(); const logger = new CapturingLogger(); @@ -1355,18 +2133,18 @@ describe("clean", () => { }); }); - describe("the scope the manifest recorded for a plugin ref, at cursor, the one host admitting a user-scope record", () => { - const CURSOR_REF = "aidd-context@aidd-framework"; + describe("the scope Claude asks for a project-owned plugin ref", () => { + const CLAUDE_REF = "aidd-context@aidd-framework"; - function seedCursorManifest( + function seedClaudeScopeManifest( marketplaces: ReadonlyArray<{ alias: string; hostName: string }>, plugins: ReadonlyArray<{ name: string; scope: "project" | "user"; marketplace: string }> ): Manifest { const manifest = Manifest.create(); - manifest.addTool("cursor", "1.0.0", []); + manifest.addTool("claude", "1.0.0", []); for (const plugin of plugins) { manifest.addPlugin( - "cursor", + "claude", InstalledPlugin.fromMetadata( plugin.name, "1.0.0", @@ -1377,10 +2155,14 @@ describe("clean", () => { ) ); } - manifest.setNativeRegistrations("cursor", { - binary: "cursor", - marketplaces: [...marketplaces], - pluginRefs: [CURSOR_REF], + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: marketplaces.map(({ alias, hostName }) => ({ + alias, + hostName, + provenance: { kind: "registry", source: "/some/built/path" }, + })), + pluginRefs: [CLAUDE_REF], }); return manifest; } @@ -1389,14 +2171,14 @@ describe("clean", () => { const fs = new InMemoryFileAdapter(); const activator = new FakeNativePluginActivator({ available: true, - installedAtScope: new Map([[CURSOR_REF, "user"]]), + installedAtScope: new Map([[CLAUDE_REF, "user"]]), }); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(manifest, PROJECT_ROOT), new CapturingLogger(), new GitignoreUseCase(fs), - new Map([["cursor", activator]]), + new Map([["claude", activator]]), seedMarketplaceRegistry() ); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -1405,7 +2187,7 @@ describe("clean", () => { it("tries the user scope first when that is what the manifest recorded for the plugin behind the ref, found by its own alias among several", async () => { const scopes = await uninstallScopesFor( - seedCursorManifest( + seedClaudeScopeManifest( [ { alias: "other", hostName: "other-host" }, { alias: MARKETPLACE, hostName: MARKETPLACE }, @@ -1422,7 +2204,7 @@ describe("clean", () => { it("tries the project scope first when the plugin's alias matches no recorded marketplace", async () => { const scopes = await uninstallScopesFor( - seedCursorManifest( + seedClaudeScopeManifest( [{ alias: MARKETPLACE, hostName: MARKETPLACE }], [{ name: "aidd-context", scope: "user", marketplace: "unknown-alias" }] ) @@ -1433,7 +2215,7 @@ describe("clean", () => { it("tries the project scope first when the manifest records no plugin at all for the ref", async () => { const scopes = await uninstallScopesFor( - seedCursorManifest([{ alias: MARKETPLACE, hostName: MARKETPLACE }], []) + seedClaudeScopeManifest([{ alias: MARKETPLACE, hostName: MARKETPLACE }], []) ); expect(scopes).toStrictEqual(["project", "user"]); @@ -1442,7 +2224,7 @@ describe("clean", () => { it("previews no shared-source fact when the references port is wired in but no marketplace registry is", async () => { const fs = new InMemoryFileAdapter(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(seedManifestWithNativeRegistrations(), PROJECT_ROOT), new CapturingLogger(), @@ -1486,7 +2268,7 @@ describe("clean", () => { const settingsLocalPath = join(PROJECT_ROOT, ".claude", "settings.local.json"); await deps.fs.writeFile(settingsLocalPath, "{}"); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( deps.fs, deps.manifestRepo, deps.logger, @@ -1499,6 +2281,9 @@ describe("clean", () => { it("deletes .cursor/hooks.json entirely once unmerging leaves it empty, and its script directory", async () => { const pluginName = "aidd-context"; + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + const command = `./${scriptMarker}run.js`; + const scriptContent = "// hook script"; const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( @@ -1510,23 +2295,26 @@ describe("clean", () => { strict: false, files: {}, scope: "project", + projectHooks: { + entries: [{ event: "PreToolUse", command, digest: md5(JSON.stringify({ command })) }], + scripts: { [`${scriptMarker}run.js`]: md5(scriptContent) }, + }, }) ); const fs = new InMemoryFileAdapter(); const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); - const scriptMarker = cursorProjectHooksScriptDir(pluginName); await fs.writeFile( hooksPath, JSON.stringify({ version: 1, - hooks: { PreToolUse: [{ command: `./${scriptMarker}run.js` }] }, + hooks: { PreToolUse: [{ command }] }, }) ); const scriptPath = join(PROJECT_ROOT, scriptMarker, "run.js"); - await fs.writeFile(scriptPath, "// hook script"); + await fs.writeFile(scriptPath, scriptContent); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -1541,6 +2329,8 @@ describe("clean", () => { it("keeps .cursor/hooks.json when another plugin's entries remain in it", async () => { const pluginName = "aidd-context"; const otherPluginName = "aidd-dev"; + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + const command = `./${scriptMarker}run.js`; const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( @@ -1552,21 +2342,21 @@ describe("clean", () => { strict: false, files: {}, scope: "project", + projectHooks: { + entries: [{ event: "PreToolUse", command, digest: md5(JSON.stringify({ command })) }], + scripts: {}, + }, }) ); const fs = new InMemoryFileAdapter(); const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); - const scriptMarker = cursorProjectHooksScriptDir(pluginName); const otherScriptMarker = cursorProjectHooksScriptDir(otherPluginName); await fs.writeFile( hooksPath, JSON.stringify({ version: 1, hooks: { - PreToolUse: [ - { command: `./${scriptMarker}run.js` }, - { command: `./${otherScriptMarker}run.js` }, - ], + PreToolUse: [{ command }, { command: `./${otherScriptMarker}run.js` }], }, }) ); @@ -1577,7 +2367,7 @@ describe("clean", () => { ); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, manifestRepo, new CapturingLogger(), @@ -1591,10 +2381,192 @@ describe("clean", () => { expect(remainingHooks).toContain(otherScriptMarker); expect(fs.has(otherScriptPath)).toBe(true); }); + + it("rejects an edited Cursor hook before dropping a shared source reference or touching native state", async () => { + const pluginName = "aidd-context"; + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + const command = `./${scriptMarker}run.js`; + const scriptPath = join(PROJECT_ROOT, scriptMarker, "run.js"); + const installedScript = "// installed hook"; + const editedScript = "// edited by the user"; + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + projectHooks: { + entries: [{ event: "PreToolUse", command, digest: md5(JSON.stringify({ command })) }], + scripts: { [`${scriptMarker}run.js`]: md5(installedScript) }, + }, + }) + ); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + { + alias: "project-native", + hostName: "other-host", + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: ["aidd-context@aidd-framework"], + }); + const fs = new InMemoryFileAdapter(); + const hooksPath = join(PROJECT_ROOT, ".cursor", "hooks.json"); + await fs.writeFile( + hooksPath, + JSON.stringify({ version: 1, hooks: { PreToolUse: [{ command }] } }) + ); + await fs.writeFile(scriptPath, editedScript); + const cachePath = join(PROJECT_ROOT, ".aidd", "cache", "built.json"); + const gitignorePath = join(PROJECT_ROOT, ".gitignore"); + fs.setFile(cachePath, "built source"); + fs.setFile(gitignorePath, ".aidd/cache/\n"); + fs.setFile(join(PROJECT_ROOT, "marker"), ""); + fs.setFile("/other-project/marker", ""); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", "/other-project"); + const registry = new InMemoryMarketplaceRegistry(); + for (const [name, scope] of [ + ["aidd-framework", "user"], + ["project-native", "project"], + ] as const) { + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name, + source: { kind: "local", path: "/some/built/path" }, + scope, + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + } + const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + manifestRepo, + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["codex", activator]]), + registry, + undefined, + new Map(), + () => "/fake-home", + references, + undefined, + new InMemoryManifestRepository(Manifest.create(), "/fake-home") + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "edited after install" + ); + + expect(await references.listAllReferencingProjects()).toContain(PROJECT_ROOT); + expect(await references.listAllReferencingProjects()).toContain("/other-project"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(scriptPath)).toBe(editedScript); + expect(fs.getFile(cachePath)).toBe("built source"); + expect(fs.getFile(gitignorePath)).toBe(".aidd/cache/\n"); + expect(manifestRepo.getCurrent()).toBe(manifest); + expect(fs.has(hooksPath)).toBe(true); + expect(fs.has(join(PROJECT_ROOT, "marker"))).toBe(true); + }); }); - describe("user-scope containment for a plugin's own files", () => { - it("refuses to delete a manifest entry whose relative path escapes the user-scope directory via `..`, while still deleting its legitimate sibling", async () => { + it("rejects an edited OpenCode MCP entry before dropping the shared source reference", async () => { + const originalServer = { type: "local" }; + const editedConfig = JSON.stringify({ mcp: { mine: { type: "remote" } } }); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "aidd-context", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { mine: md5(JSON.stringify(originalServer)) }, + scope: "project", + }) + ); + manifest.addTool("codex", "1.0.0", []); + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { + kind: "effective-list", + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + }, + ], + pluginRefs: ["aidd-context@aidd-framework"], + }); + const fs = new InMemoryFileAdapter(); + const configPath = join(PROJECT_ROOT, "opencode.json"); + await fs.writeFile(configPath, editedConfig); + fs.setFile(join(PROJECT_ROOT, "marker"), ""); + fs.setFile("/other-project/marker", ""); + const references = new UserSourceReferencesAdapter(fs, () => "/fake-home/.config/aidd"); + await references.addReference("1.0.0", PROJECT_ROOT); + await references.addReference("1.0.0", "/other-project"); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanWithKnownHostSource( + fs, + new InMemoryManifestRepository(manifest, PROJECT_ROOT), + new CapturingLogger(), + new GitignoreUseCase(fs), + new Map([["codex", activator]]), + undefined, + undefined, + new Map(), + () => "/fake-home", + references + ); + + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "edited" + ); + + expect(await references.listAllReferencingProjects()).toContain(PROJECT_ROOT); + expect(await references.listAllReferencingProjects()).toContain("/other-project"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(configPath)).toBe(editedConfig); + }); + + describe("user-scope files belong to machine cleanup", () => { + it("leaves both legitimate and escaping user paths untouched", async () => { const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( @@ -1614,15 +2586,20 @@ describe("clean", () => { const fs = new RecordingFileAdapter(); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase(fs, manifestRepo, logger, new GitignoreUseCase(fs)); + const useCase = new CleanWithKnownHostSource( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs) + ); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect( fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) - ).toBe(true); + ).toBe(false); expect(fs.deletedPaths.some((p) => p.includes(join(".ssh", "id_rsa")))).toBe(false); - expect(logger.warnMessages.some((m) => m.includes("id_rsa"))).toBe(true); + expect(logger.warnMessages.some((m) => m.includes("id_rsa"))).toBe(false); }); it("refuses to delete a plugin whose own directory is a symlink resolving outside the user-scope directory", async () => { @@ -1644,7 +2621,12 @@ describe("clean", () => { fs.setSymlink(join(boundary, "aidd-context"), "/tmp/evil-aidd-context"); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const logger = new CapturingLogger(); - const useCase = new CleanUseCase(fs, manifestRepo, logger, new GitignoreUseCase(fs)); + const useCase = new CleanWithKnownHostSource( + fs, + manifestRepo, + logger, + new GitignoreUseCase(fs) + ); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); @@ -1652,14 +2634,14 @@ describe("clean", () => { expect( fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) ).toBe(false); - expect(logger.warnMessages.some((m) => m.includes(PLUGIN_KEY))).toBe(true); + expect(logger.warnMessages.some((m) => m.includes(PLUGIN_KEY))).toBe(false); }); }); describe("without a manifest", () => { it("reports nothing found and nothing to preview", async () => { const fs = new InMemoryFileAdapter(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(null, PROJECT_ROOT), new CapturingLogger(), @@ -1682,7 +2664,7 @@ describe("clean", () => { fs: InMemoryFileAdapter, prompter: RecordingPrompter | undefined ) { - return new CleanUseCase( + return new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), new CapturingLogger(), @@ -1775,7 +2757,7 @@ describe("clean", () => { [{ relativePath: ".claude/settings.json", sectionKey: null, entries: { owned: hash() } }] ); manifest.addTool("codex", "1.0.0", [fileEntry("c.md")]); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(manifest, PROJECT_ROOT), new CapturingLogger(), @@ -1804,7 +2786,7 @@ describe("clean", () => { pluginRefs: ["aidd-context@aidd-framework"], }); const fs = new InMemoryFileAdapter(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(manifest, PROJECT_ROOT), new CapturingLogger(), @@ -1833,7 +2815,7 @@ describe("clean", () => { pluginRefs: [], }); const fs = new InMemoryFileAdapter(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(manifest, PROJECT_ROOT), new CapturingLogger(), @@ -1860,7 +2842,7 @@ describe("clean", () => { manifest: Manifest, logger = new CapturingLogger() ) { - return new CleanUseCase( + return new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(manifest, PROJECT_ROOT), logger, @@ -1902,25 +2884,31 @@ describe("clean", () => { it("counts the project hooks file a cursor plugin was unmerged from", async () => { const pluginName = "aidd-context"; + const scriptMarker = cursorProjectHooksScriptDir(pluginName); + const command = `./${scriptMarker}run.js`; const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( "cursor", - InstalledPlugin.fromMetadata( - pluginName, - "1.0.0", - { kind: "local", path: "/p" }, - false, - "project" - ) + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/p" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + projectHooks: { + entries: [{ event: "PreToolUse", command, digest: md5(JSON.stringify({ command })) }], + scripts: {}, + }, + }) ); const fs = new InMemoryFileAdapter(); - const scriptMarker = cursorProjectHooksScriptDir(pluginName); fs.setFile( join(PROJECT_ROOT, ".cursor", "hooks.json"), JSON.stringify({ version: 1, - hooks: { PreToolUse: [{ command: `./${scriptMarker}run.js` }] }, + hooks: { PreToolUse: [{ command }] }, }) ); const useCase = buildCountingUseCase(fs, manifest); @@ -2023,7 +3011,7 @@ describe("clean", () => { const fs = new InMemoryFileAdapter(); fs.setFile(join(PROJECT_ROOT, ".aidd", "config.json"), "{}"); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), logger, @@ -2042,7 +3030,7 @@ describe("clean", () => { const fs = new InMemoryFileAdapter(); fs.setFile(join(PROJECT_ROOT, ".aidd", "auth.json"), "{}"); const logger = new CapturingLogger(); - const useCase = new CleanUseCase( + const useCase = new CleanWithKnownHostSource( fs, new InMemoryManifestRepository(seedTrackedFiles(fs, ["a.md"]), PROJECT_ROOT), logger, diff --git a/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts index e1fd62d06..e2d52e326 100644 --- a/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean/clean-shared-ref-guard.integration.test.ts @@ -2,6 +2,7 @@ * copilot), a ref is left enabled while another project still references its shared source. */ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { describe, expect, it } from "vitest"; import { FRAMEWORK_MARKETPLACE_NAME, @@ -11,9 +12,12 @@ import { CleanUseCase } from "../../../../../src/contexts/framework/application/ import { GitignoreUseCase } from "../../../../../src/contexts/framework/application/gitignore-use-case.js"; import type { NativeMarketplaceRegistration } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -22,6 +26,54 @@ import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory const PROJECT_ROOT = "/test-project"; const OTHER_PROJECT = "/other-project"; const USER_CONFIG_DIR = "/fake-home/.config/aidd"; +const OTHER_PROJECT_BYTES = "B still uses these bytes"; + +function provenRegistration( + toolId: "codex" | "claude", + alias: string, + hostName: string +): NativeMarketplaceRegistration { + const source = hostName === "other-mkt" ? "/other/built/path" : "/some/built/path"; + return { + alias, + hostName, + provenance: + toolId === "claude" + ? { kind: "registry", source } + : { kind: "effective-list", root: source, sourceType: "local", source }, + }; +} + +/** A separate, literal host snapshot: not assembled from the machine claim. */ +function currentHostSource(toolId: "codex" | "claude"): NativeMarketplaceSourceReader { + return { + read: async () => ({ + location: "fixture native host", + entries: new Map([ + [ + "aidd-framework", + toolId === "claude" + ? { kind: "registry" as const, source: "/some/built/path" } + : { + kind: "effective-list" as const, + root: "/some/built/path", + sourceType: "local", + source: "/some/built/path", + }, + ], + [ + "other-mkt", + { + kind: "effective-list" as const, + root: "/other/built/path", + sourceType: "local", + source: "/other/built/path", + }, + ], + ]), + }), + }; +} function seedManifest( toolId: "codex" | "claude", @@ -38,7 +90,9 @@ function seedManifest( return manifest; } -function seedSharedMarketplaceRegistry(): InMemoryMarketplaceRegistry { +function seedSharedMarketplaceRegistry( + withOtherProjectCatalogue = false +): InMemoryMarketplaceRegistry { const registry = new InMemoryMarketplaceRegistry(); registry.save( PROJECT_ROOT, @@ -49,6 +103,17 @@ function seedSharedMarketplaceRegistry(): InMemoryMarketplaceRegistry { addedAt: "2026-01-01T00:00:00.000Z", }) ); + if (withOtherProjectCatalogue) { + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "other-mkt", + source: { kind: "local", path: "/other/built/path" }, + scope: "project", + addedAt: "2026-01-01T00:00:00.000Z", + }) + ); + } return registry; } @@ -60,16 +125,18 @@ function seedReferences(fs: InMemoryFileAdapter, roots: readonly string[]): void // `listAllReferencingProjects` filters by `fs.fileExists(root)`, so every root seeded here // needs a marker of its own or it reads back as no project at all. fs.setFile(`${PROJECT_ROOT}/marker`, ""); - for (const root of roots) fs.setFile(`${root}/marker`, ""); + for (const root of roots) fs.setFile(`${root}/marker`, OTHER_PROJECT_BYTES); } function buildUseCase(deps: { fs: InMemoryFileAdapter; manifest: Manifest; activator: FakeNativePluginActivator; - binary: string; + binary: "codex" | "claude"; logger: CapturingLogger; aiddMarketplaceRegistry: InMemoryMarketplaceRegistry; + userManifestRepo?: InMemoryManifestRepository; + gitignore?: GitignoreUseCase; }): CleanUseCase { const manifestRepo = new InMemoryManifestRepository(deps.manifest, PROJECT_ROOT); const userSourceReferences = new UserSourceReferencesAdapter(deps.fs, () => USER_CONFIG_DIR); @@ -77,18 +144,129 @@ function buildUseCase(deps: { deps.fs, manifestRepo, deps.logger, - new GitignoreUseCase(deps.fs), + deps.gitignore ?? new GitignoreUseCase(deps.fs), new Map([[deps.binary, deps.activator]]), deps.aiddMarketplaceRegistry, undefined, new Map(), - undefined, + () => "/fake-home", userSourceReferences, - new Map() + new Map([ + [ + deps.binary, + new FakeHostPluginRegistryReader({ + location: "fixture host installed-plugin registry", + refs: new Map([ + ["aidd-vcs@aidd-framework", { enabled: true }], + ["plugin-b@other-mkt", { enabled: true }], + ]), + }), + ], + ]), + deps.userManifestRepo, + new Map([[deps.binary, currentHostSource(deps.binary)]]) ); } describe("clean guards a ref another project on this machine still needs", () => { + it.each(["succeeds", "fails"] as const)( + "releases A's native and user-plugin claims only when local cleanup %s", + async (outcome) => { + const fs = new InMemoryFileAdapter(); + seedReferences(fs, [OTHER_PROJECT]); + const ref = "aidd-vcs@aidd-framework"; + const otherRef = "plugin-b@other-mkt"; + const registration = provenRegistration("codex", "aidd-framework", "aidd-framework"); + const project = seedManifest("codex", [registration], [ref]); + project.addTool("cursor", "1.0.0", []); + const record = (name: string, scope: "project" | "user" = "user") => + InstalledPlugin.fromJSON({ + name, + source: { kind: "local", path: "/shared/plugins" }, + version: "1.0.0", + strict: false, + scope, + files: {}, + dependents: [PROJECT_ROOT, OTHER_PROJECT], + }); + project.addPlugin("cursor", record("shared").withDependents([])); + project.addPlugin("cursor", record("local-only", "project").withDependents([])); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [registration, provenRegistration("codex", "other-mkt", "other-mkt")], + pluginRefs: [ref, otherRef], + pluginClaims: [ref, otherRef].map((ref) => ({ + ref, + dependents: [PROJECT_ROOT, OTHER_PROJECT], + })), + }); + machine.addTool("cursor", "1.0.0", []); + for (const name of ["shared", "local-only", "unrelated"]) + machine.addPlugin("cursor", record(name)); + const machineRepo = new InMemoryManifestRepository(machine); + const gitignore = new GitignoreUseCase(fs); + if (outcome === "fails") + gitignore.remove = async () => { + throw new Error("gitignore cleanup failed"); + }; + fs.setFile(`${OTHER_PROJECT}/plugin-content.md`, OTHER_PROJECT_BYTES); + const sharedPath = "/fake-home/.cursor/plugins/local/shared/skills/demo.md"; + fs.setFile(sharedPath, "shared plugin bytes"); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + manifest: project, + activator, + binary: "codex", + logger: new CapturingLogger(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + userManifestRepo: machineRepo, + gitignore, + }); + + if (outcome === "fails") + await expect(useCase.execute({ projectRoot: PROJECT_ROOT, force: true })).rejects.toThrow( + "gitignore cleanup failed" + ); + else await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); + + const dependents = outcome === "succeeds" ? [OTHER_PROJECT] : [PROJECT_ROOT, OTHER_PROJECT]; + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref, dependents }, + { ref: otherRef, dependents: [PROJECT_ROOT, OTHER_PROJECT] }, + ]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + ref, + otherRef, + ]); + expect( + machineRepo + .getCurrent() + ?.getPlugins("cursor") + .map((plugin) => ({ + name: plugin.name, + dependents: plugin.dependents, + })) + ).toEqual([ + { name: "shared", dependents }, + { name: "local-only", dependents: [PROJECT_ROOT, OTHER_PROJECT] }, + { name: "unrelated", dependents: [PROJECT_ROOT, OTHER_PROJECT] }, + ]); + expect( + await new UserSourceReferencesAdapter( + fs, + () => USER_CONFIG_DIR + ).listAllReferencingProjects() + ).toEqual(dependents); + expect(fs.getFile(`${OTHER_PROJECT}/plugin-content.md`)).toBe(OTHER_PROJECT_BYTES); + expect(fs.getFile(sharedPath)).toBe("shared plugin bytes"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + } + ); + it("keeps codex's ref enabled and names the other project still referencing the shared source", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); @@ -99,7 +277,7 @@ describe("clean guards a ref another project on this machine still needs", () => fs, manifest: seedManifest( "codex", - [{ alias: "aidd-framework", hostName: "aidd-framework" }], + [provenRegistration("codex", "aidd-framework", "aidd-framework")], ["aidd-vcs@aidd-framework"] ), activator, @@ -111,12 +289,14 @@ describe("clean guards a ref another project on this machine still needs", () => await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(activator.uninstalledPlugins).not.toContain("aidd-vcs@aidd-framework"); + expect(fs.getFile(`${USER_CONFIG_DIR}/references.json`)).toContain(OTHER_PROJECT); + expect(fs.getFile(`${OTHER_PROJECT}/marker`)).toBe(OTHER_PROJECT_BYTES); expect( logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(OTHER_PROJECT)) ).toBe(true); }); - it("disables codex's ref once this project holds the last reference to the shared source", async () => { + it("leaves an unclaimed machine-global Codex ref enabled after the last local reference", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, []); const activator = new FakeNativePluginActivator({ available: true }); @@ -125,7 +305,7 @@ describe("clean guards a ref another project on this machine still needs", () => fs, manifest: seedManifest( "codex", - [{ alias: "aidd-framework", hostName: "aidd-framework" }], + [provenRegistration("codex", "aidd-framework", "aidd-framework")], ["aidd-vcs@aidd-framework"] ), activator, @@ -136,7 +316,11 @@ describe("clean guards a ref another project on this machine still needs", () => await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toContain("aidd-vcs@aidd-framework"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect( + await new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR).listAllReferencingProjects() + ).not.toContain(PROJECT_ROOT); }); it("still disables claude's ref even with another project referencing the shared source", async () => { @@ -148,7 +332,7 @@ describe("clean guards a ref another project on this machine still needs", () => fs, manifest: seedManifest( "claude", - [{ alias: "aidd-framework", hostName: "aidd-framework" }], + [provenRegistration("claude", "aidd-framework", "aidd-framework")], ["aidd-vcs@aidd-framework"] ), activator, @@ -160,9 +344,10 @@ describe("clean guards a ref another project on this machine still needs", () => await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(activator.uninstalledPlugins).toContain("aidd-vcs@aidd-framework"); + expect(fs.getFile(`${OTHER_PROJECT}/marker`)).toBe(OTHER_PROJECT_BYTES); }); - it("still disables a ref from a marketplace that is not the shared source, in the same run", async () => { + it("leaves both unclaimed Codex refs enabled despite a distinct project catalogue", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); @@ -172,24 +357,26 @@ describe("clean guards a ref another project on this machine still needs", () => manifest: seedManifest( "codex", [ - { alias: "aidd-framework", hostName: "aidd-framework" }, - { alias: "other-mkt", hostName: "other-mkt" }, + provenRegistration("codex", "aidd-framework", "aidd-framework"), + provenRegistration("codex", "other-mkt", "other-mkt"), ], ["aidd-vcs@aidd-framework", "plugin-b@other-mkt"] ), activator, binary: "codex", logger: new CapturingLogger(), - aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(true), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).not.toContain("aidd-vcs@aidd-framework"); - expect(activator.uninstalledPlugins).toContain("plugin-b@other-mkt"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(`${USER_CONFIG_DIR}/references.json`)).toContain(OTHER_PROJECT); + expect(fs.getFile(`${OTHER_PROJECT}/marker`)).toBe(OTHER_PROJECT_BYTES); }); - it("still disables a ref from another marketplace when that marketplace is recorded before the shared source", async () => { + it("leaves both unclaimed Codex refs enabled when the project catalogue is listed first", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); @@ -199,23 +386,26 @@ describe("clean guards a ref another project on this machine still needs", () => manifest: seedManifest( "codex", [ - { alias: "other-mkt", hostName: "other-mkt" }, - { alias: "aidd-framework", hostName: "aidd-framework" }, + provenRegistration("codex", "other-mkt", "other-mkt"), + provenRegistration("codex", "aidd-framework", "aidd-framework"), ], ["plugin-b@other-mkt", "aidd-vcs@aidd-framework"] ), activator, binary: "codex", logger: new CapturingLogger(), - aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(), + aiddMarketplaceRegistry: seedSharedMarketplaceRegistry(true), }); await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toStrictEqual(["plugin-b@other-mkt"]); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(`${USER_CONFIG_DIR}/references.json`)).toContain(OTHER_PROJECT); + expect(fs.getFile(`${OTHER_PROJECT}/marker`)).toBe(OTHER_PROJECT_BYTES); }); - it("disables codex's ref when no claim was ever recorded for this project, and no other project references it either", async () => { + it("leaves an unclaimed global Codex ref enabled even without a recorded project reference", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); // No references.json at all: no claim of this project's own to drop, and nothing else // referencing the source to guard on either. @@ -225,7 +415,7 @@ describe("clean guards a ref another project on this machine still needs", () => fs, manifest: seedManifest( "codex", - [{ alias: "aidd-framework", hostName: "aidd-framework" }], + [provenRegistration("codex", "aidd-framework", "aidd-framework")], ["aidd-vcs@aidd-framework"] ), activator, @@ -236,7 +426,8 @@ describe("clean guards a ref another project on this machine still needs", () => await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); - expect(activator.uninstalledPlugins).toContain("aidd-vcs@aidd-framework"); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); }); // "This project's own claim was never recorded" and "no other project references it" are @@ -244,7 +435,7 @@ describe("clean guards a ref another project on this machine still needs", () => it("keeps codex's ref enabled when this project's own claim was never recorded but another project's still is", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); fs.setFile(`${USER_CONFIG_DIR}/references.json`, JSON.stringify({ "1.0.0": [OTHER_PROJECT] })); - fs.setFile(`${OTHER_PROJECT}/marker`, ""); + fs.setFile(`${OTHER_PROJECT}/marker`, OTHER_PROJECT_BYTES); const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); @@ -252,7 +443,7 @@ describe("clean guards a ref another project on this machine still needs", () => fs, manifest: seedManifest( "codex", - [{ alias: "aidd-framework", hostName: "aidd-framework" }], + [provenRegistration("codex", "aidd-framework", "aidd-framework")], ["aidd-vcs@aidd-framework"] ), activator, @@ -264,6 +455,8 @@ describe("clean guards a ref another project on this machine still needs", () => await useCase.execute({ projectRoot: PROJECT_ROOT, force: true }); expect(activator.uninstalledPlugins).not.toContain("aidd-vcs@aidd-framework"); + expect(fs.getFile(`${USER_CONFIG_DIR}/references.json`)).toContain(OTHER_PROJECT); + expect(fs.getFile(`${OTHER_PROJECT}/marker`)).toBe(OTHER_PROJECT_BYTES); expect( logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(OTHER_PROJECT)) ).toBe(true); diff --git a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts index 78c203f75..100c0232c 100644 --- a/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts +++ b/cli/tests/contexts/framework/application/clean/clean-user-scope-use-case.integration.test.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; @@ -12,11 +14,13 @@ import { CleanUserScopeUseCase } from "../../../../../src/contexts/framework/app import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { UserSourceReferencesAdapter } from "../../../../../src/contexts/framework/infrastructure/user-source-references-adapter.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; import type { NativePluginActivator } from "../../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; import type { ToolId } from "../../../../../src/kernel/tool.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -114,12 +118,55 @@ function manifestWithClaude(): Manifest { manifest.addTool("claude", "1.0.0", []); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], pluginRefs: ["aidd-context@aidd-framework"], + pluginClaims: [{ ref: "aidd-context@aidd-framework", dependents: [] }], }); return manifest; } +function provenClaudeSources( + source = "/built/claude" +): ReadonlyMap<"claude", NativeMarketplaceSourceReader> { + return new Map([ + [ + "claude", + { + read: async () => ({ + location: "known_marketplaces.json", + entries: new Map([["aidd-framework", { kind: "registry" as const, source }]]), + }), + }, + ], + ]); +} + +function provenHostPlugins( + manifest: Manifest +): ReadonlyMap<"claude" | "codex", FakeHostPluginRegistryReader> { + const readers = new Map<"claude" | "codex", FakeHostPluginRegistryReader>(); + for (const toolId of ["claude", "codex"] as const) { + const registrations = manifest.getNativeRegistrations(toolId); + if (registrations === undefined) continue; + readers.set( + toolId, + new FakeHostPluginRegistryReader({ + location: `${toolId} installed plugins`, + refs: new Map( + (registrations.pluginClaims ?? []).map(({ ref }) => [ref, { enabled: true }]) + ), + }) + ); + } + return readers; +} + /** Seeds the cache path claude's own profile declares with a marker file: an empty or absent * directory is silently skipped rather than purged. */ function seedClaudeCache(fs: InMemoryFileAdapter, hostName = "aidd-framework"): string { @@ -129,6 +176,344 @@ function seedClaudeCache(fs: InMemoryFileAdapter, hostName = "aidd-framework"): } describe("clean --scope user", () => { + it("reads canonical dependents after acquiring the machine lock", async () => { + const machine = manifestWithClaude(); + class LockedRepository extends InMemoryManifestRepository { + async withExclusiveAccess(action: () => Promise): Promise { + // A concurrent install completed while clean was waiting for the lock. + const registration = machine.getNativeRegistrations("claude"); + if (registration === undefined) throw new Error("fixture missing native registration"); + machine.setNativeRegistrations("claude", { + ...registration, + pluginClaims: [{ ref: "aidd-context@aidd-framework", dependents: ["/project-b"] }], + }); + return action(); + } + } + const repo = new LockedRepository(machine); + const fs = new RecordingFileAdapter(); + const cacheMarker = join(seedClaudeCache(fs), "marker.json"); + const sourceMarker = join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "catalogue.json"); + fs.setFile(sourceMarker, "source"); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["claude", activator]]), + new Map(), + () => HOME, + undefined, + undefined, + provenClaudeSources(), + provenHostPlugins(machine) + ); + + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /user-scope plugin files.*active projects.*\/project-b/ + ); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(sourceMarker)).toBe("source"); + expect(fs.getFile(cacheMarker)).toBe("{}"); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ + { ref: "aidd-context@aidd-framework", dependents: ["/project-b"] }, + ]); + }); + + it("refuses an owned catalogue with a foreign enabled plugin ref before any global clean effect", async () => { + const machine = manifestWithClaude(); + const repo = new InMemoryManifestRepository(machine); + const fs = new RecordingFileAdapter(); + const foreignCache = join(CLAUDE_CACHE, "aidd-framework", "foreign-payload", "bytes"); + fs.setFile(foreignCache, "foreign bytes"); + const activator = new RecordingActivator([]); + const hostPlugins = new FakeHostPluginRegistryReader({ + location: "installed_plugins.json", + refs: new Map([ + ["aidd-context@aidd-framework", { enabled: true }], + ["foreign@aidd-framework", { enabled: true }], + ]), + }); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["claude", activator]]), + new Map(), + () => HOME, + undefined, + undefined, + provenClaudeSources(), + new Map([["claude", hostPlugins]]) + ); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow( + /foreign.*ref.*foreign@aidd-framework/ + ); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(await fs.readFile(foreignCache)).toBe("foreign bytes"); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toHaveLength(1); + }); + it("preflights every native source before uninstalling any other catalogue", async () => { + const machine = manifestWithClaude(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: "foreign-alias", + hostName: "foreign-name", + provenance: { kind: "effective-list", root: "/old", sourceType: "local", source: "/old" }, + }, + ], + pluginRefs: ["plugin@foreign-name"], + pluginClaims: [{ ref: "plugin@foreign-name", dependents: [] }], + }); + const claude = new RecordingActivator([]); + const codex = new RecordingActivator([]); + const repo = new InMemoryManifestRepository(machine); + const fs = new RecordingFileAdapter(); + const claudeSource = provenClaudeSources().get("claude"); + if (claudeSource === undefined) throw new Error("fixture missing Claude source reader"); + const sources = new Map<"claude" | "codex", NativeMarketplaceSourceReader>([ + ["claude", claudeSource], + [ + "codex", + { + read: async () => ({ + location: "host", + entries: new Map([ + [ + "foreign-name", + { + kind: "effective-list", + root: "/foreign", + sourceType: "local", + source: "/foreign", + }, + ], + ]), + }), + }, + ], + ]); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([ + ["claude", claude], + ["codex", codex], + ]), + new Map(), + () => HOME, + undefined, + undefined, + sources, + provenHostPlugins(machine) + ); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow( + /current host source differs.*reconcile manually/ + ); + expect(claude.uninstalledPlugins).toEqual([]); + expect(claude.removedMarketplaces).toEqual([]); + expect(codex.uninstalledPlugins).toEqual([]); + expect(codex.removedMarketplaces).toEqual([]); + expect(repo.getCurrent()?.getNativeRegistrations("claude")).toBeDefined(); + }); + it("refuses all native host mutations when a stale canonical source points at a foreign Codex catalogue", async () => { + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: "alias", + hostName: "foreign-name", + provenance: { kind: "effective-list", root: "/old", sourceType: "local", source: "/old" }, + }, + ], + pluginRefs: ["plugin@foreign-name"], + pluginClaims: [{ ref: "plugin@foreign-name", dependents: [] }], + }); + const repo = new InMemoryManifestRepository(machine); + const fs = new RecordingFileAdapter(); + const activator = new RecordingActivator([]); + const source: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "host", + entries: new Map([ + [ + "foreign-name", + { kind: "effective-list", root: "/foreign", sourceType: "local", source: "/foreign" }, + ], + ]), + }), + }; + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["codex", activator]]), + new Map(), + () => HOME, + undefined, + undefined, + new Map([["codex", source]]) + ); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow( + /current host source differs.*reconcile manually/ + ); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(repo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toHaveLength(1); + }); + it("refuses an active B native ref even with force, then succeeds only after B detaches", async () => { + const machine = manifestWithClaude(); + const registrations = machine.getNativeRegistrations("claude"); + if (registrations === undefined) throw new Error("fixture missing native registration"); + machine.setNativeRegistrations("claude", { + ...registrations, + pluginClaims: [{ ref: "aidd-context@aidd-framework", dependents: ["/B"] }], + }); + const repo = new InMemoryManifestRepository(machine); + const fs = new RecordingFileAdapter(); + const activator = new RecordingActivator([]); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "known_marketplaces.json", + entries: new Map(), + }), + ], + ]), + () => HOME, + undefined, + undefined, + provenClaudeSources(), + provenHostPlugins(machine) + ); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow( + /active projects.*\/B/ + ); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ + { ref: "aidd-context@aidd-framework", dependents: ["/B"] }, + ]); + machine.setNativeRegistrations("claude", { + ...registrations, + pluginClaims: [{ ref: "aidd-context@aidd-framework", dependents: [] }], + }); + await useCase.execute({ projectRoot: "/A", force: true }); + expect(activator.uninstalledPlugins).toEqual(["aidd-context@aidd-framework"]); + expect(activator.removedMarketplaces).toEqual(["aidd-framework"]); + expect(repo.getCurrent()).toBeNull(); + }); + + it("refuses an active B Cursor user plugin but never treats project files as machine-owned", async () => { + const userFile = join(homedir(), ".cursor", "plugins", "local", "shared", "skills", "x.md"); + const projectFile = "/A/skills/local.md"; + const fs = new RecordingFileAdapter(); + fs.setFile(userFile, "shared bytes"); + fs.setFile(projectFile, "project bytes"); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + const shared = InstalledPlugin.fromMetadata( + "shared", + "1.0.0", + { kind: "local", path: "/source" }, + false, + "user" + ) + .withFiles( + new Map([["shared/skills/x.md", createHash("md5").update("shared bytes").digest("hex")]]) + ) + .withDependents(["/B"]); + machine.addPlugin("cursor", shared); + machine.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "local", + "1.0.0", + { kind: "local", path: "/source" }, + false, + "project" + ) + .withFiles(new Map([["skills/local.md", "hash"]])) + .withDependents(["/project-foreign"]) + ); + const repo = new InMemoryManifestRepository(machine); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR, + new Map(), + new Map(), + homedir + ); + + expect( + (await useCase.execute({ projectRoot: "/A", force: false })).preview.activePluginDependents + ).toEqual(["/B"]); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow(/\/B/); + expect(fs.has(userFile)).toBe(true); + expect(fs.has(projectFile)).toBe(true); + expect( + repo + .getCurrent() + ?.getPlugins("cursor") + .find((plugin) => plugin.name === "shared")?.dependents + ).toEqual(["/B"]); + + machine.updatePlugin("cursor", shared.withDependents([])); + await useCase.execute({ projectRoot: "/A", force: true }); + expect(repo.getCurrent()).toBeNull(); + expect(fs.has(userFile)).toBe(false); + expect(fs.order).toContain(`deleteFile:${userFile}`); + expect(fs.has(projectFile)).toBe(true); + }); + + it("retains canonical claims if the user catalogue registry refuses the final delete", async () => { + class RefusingRegistry extends InMemoryMarketplaceRegistry { + override async delete(_root: string, _name: string, _scope: MarketplaceScope): Promise { + throw new Error("registry delete failed"); + } + } + const repo = new InMemoryManifestRepository(Manifest.create()); + const useCase = new CleanUserScopeUseCase( + new RecordingFileAdapter(), + repo, + new CapturingLogger(), + new RefusingRegistry(), + () => USER_CONFIG_DIR + ); + await expect(useCase.execute({ projectRoot: "/A", force: true })).rejects.toThrow( + /registry delete failed/ + ); + expect(repo.getCurrent()).not.toBeNull(); + }); + describe("no user manifest, machine state from a project-scope setup", () => { it("still purges the whitelist and touches no host, naming the referencing projects", async () => { const order: string[] = []; @@ -164,11 +549,10 @@ describe("clean --scope user", () => { userSourceReferences ); - const result = await useCase.execute({ projectRoot: "/wherever", force: true }); - - expect(result.manifestFound).toBe(false); - // The whitelist purge runs regardless of the manifest — it reads nothing from it. - expect(fs.order).toContain(`deleteDirectory:${join(USER_CONFIG_DIR, "cache", "built")}`); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /the shared framework source.*active projects.*\/project-a/ + ); + expect(fs.order).not.toContain(`deleteDirectory:${join(USER_CONFIG_DIR, "cache", "built")}`); // No manifest means no `nativeRegistrations` to drive a host's own CLI through — // the activator that would have recorded a real call never sees one. expect(activator.removedMarketplaces).toEqual([]); @@ -209,7 +593,11 @@ describe("clean --scope user", () => { }), ], ]), - () => HOME + () => HOME, + undefined, + undefined, + provenClaudeSources(), + provenHostPlugins(manifestWithClaude()) ); await useCase.execute({ projectRoot: "/wherever", force: true }); @@ -251,7 +639,7 @@ describe("clean --scope user", () => { ); const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); await userSourceReferences.addReference("1.0.0", "/project-a"); - fs.setFile("/project-a/marker", ""); + await userSourceReferences.removeReference("/project-a"); const useCase = new CleanUserScopeUseCase( fs, manifestRepo, @@ -365,7 +753,9 @@ describe("clean --scope user", () => { () => HOME ); - await useCase.execute({ projectRoot: "/wherever", force: true }); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /tracked file.*escaped/ + ); expect(await fs.fileExists(join(escapeTarget, "evil.md"))).toBe(true); expect(logger.warnMessages.some((m) => m.includes("does not resolve inside"))).toBe(true); @@ -388,15 +778,9 @@ describe("clean --scope user", () => { () => HOME ); - await useCase.execute({ projectRoot: "/wherever", force: true }); - - const warning = logger.warnMessages.find( - (m) => m.includes("claude") && m.includes("not on the PATH") + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /claude.*not on the PATH.*1 marketplace.*1 plugin ref/s ); - expect(warning).toBeDefined(); - expect(warning).toContain("1 marketplace(s)"); - expect(warning).toContain("1 plugin ref(s)"); - expect(warning).toContain(claudeCachePath); // The whitelist step still purges userConfigDir()'s own cache/built/, unrelated // to this host's own cache — only claude's own directory must survive untouched. expect(fs.order).not.toContain(`deleteDirectory:${claudeCachePath}`); @@ -454,7 +838,7 @@ describe("clean --scope user", () => { expect(result.preview.referencingProjects).toEqual([]); }); - it("--force skips confirmation and proceeds even when projects still reference it", async () => { + it("--force cannot remove a shared source still referenced by a project", async () => { const fs = new InMemoryFileAdapter(); fs.setFile("/project-a/marker", ""); const userSourceReferences = new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR); @@ -472,10 +856,10 @@ describe("clean --scope user", () => { userSourceReferences ); - const result = await useCase.execute({ projectRoot: "/wherever", force: true }); - - expect(result.dryRun).toBe(false); - expect(manifestRepo.getCurrent()).toBeNull(); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /active projects.*\/project-a/ + ); + expect(manifestRepo.getCurrent()).not.toBeNull(); }); }); @@ -612,7 +996,11 @@ describe("clean --scope user", () => { }), ], ]), - () => HOME + () => HOME, + undefined, + undefined, + provenClaudeSources(), + provenHostPlugins(manifestWithClaude()) ); } @@ -626,7 +1014,12 @@ describe("clean --scope user", () => { expect(result).toStrictEqual({ dryRun: false, manifestFound: true, - preview: { toolIds: ["claude"], builtVersions: [], referencingProjects: [] }, + preview: { + toolIds: ["claude"], + activePluginDependents: [], + builtVersions: [], + referencingProjects: [], + }, }); expect(await fs.fileExists(join(claudeCachePath, "marker.json"))).toBe(false); }); @@ -664,7 +1057,12 @@ describe("clean --scope user", () => { expect(result).toStrictEqual({ dryRun: false, manifestFound: false, - preview: { toolIds: [], builtVersions: [], referencingProjects: [] }, + preview: { + toolIds: [], + activePluginDependents: [], + builtVersions: [], + referencingProjects: [], + }, }); expect(logger.infoMessages).toStrictEqual([ "No host registration was undone: nothing was registered at user scope.", @@ -701,7 +1099,7 @@ describe("clean --scope user", () => { ]); }); - it("reports no referencing project, and warns, when references.json cannot be read", async () => { + it("refuses to interpret an unreadable references.json as no dependent project", async () => { const fs = new InMemoryFileAdapter(); fs.setFile(join(USER_CONFIG_DIR, "references.json"), "not json"); const logger = new CapturingLogger(); @@ -717,10 +1115,10 @@ describe("clean --scope user", () => { new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR) ); - const result = await useCase.execute({ projectRoot: "/wherever", force: false }); - - expect(result.preview.referencingProjects).toStrictEqual([]); - expect(logger.warnMessages.some((m) => m.includes("references.json"))).toBe(true); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /Cannot read.*references.json/ + ); + expect(await fs.fileExists(join(USER_CONFIG_DIR, "references.json"))).toBe(true); }); }); @@ -773,18 +1171,25 @@ describe("clean --scope user", () => { fs: InMemoryFileAdapter; logger: CapturingLogger; manifest?: Manifest; + repo?: InMemoryManifestRepository; binary?: string; + nativeSources?: ReadonlyMap<"claude" | "codex", NativeMarketplaceSourceReader>; + hostPlugins?: ReadonlyMap<"claude" | "codex", FakeHostPluginRegistryReader>; activator: NativePluginActivator; }) { return new CleanUserScopeUseCase( deps.fs, - new InMemoryManifestRepository(deps.manifest ?? manifestWithClaude()), + deps.repo ?? new InMemoryManifestRepository(deps.manifest ?? manifestWithClaude()), deps.logger, new InMemoryMarketplaceRegistry(), () => USER_CONFIG_DIR, new Map([[deps.binary ?? "claude", deps.activator]]), new Map(), - () => HOME + () => HOME, + undefined, + undefined, + deps.nativeSources ?? provenClaudeSources(), + deps.hostPlugins ?? provenHostPlugins(deps.manifest ?? manifestWithClaude()) ); } @@ -802,7 +1207,294 @@ describe("clean --scope user", () => { expect(activator.uninstalledPluginScopes).toStrictEqual(["user"]); }); - it("names a refused plugin uninstall by the host's own words and still unregisters the marketplace", async () => { + it("cleans an empty native registration without inventing any plugin uninstall", async () => { + const manifest = Manifest.create(); + manifest.addTool("claude", "1.0.0", []); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [], + pluginRefs: [], + }); + const repo = new InMemoryManifestRepository(manifest); + const fs = new InMemoryFileAdapter(); + const sourceMarker = join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "catalogue.json"); + fs.setFile(sourceMarker, "source"); + const activator = new FakeNativePluginActivator({ available: true }); + + const result = await buildUseCase({ + fs, + manifest, + repo, + activator, + logger: new CapturingLogger(), + nativeSources: new Map(), + hostPlugins: new Map(), + }).execute({ projectRoot: "/wherever", force: true }); + + expect(result).toEqual({ + dryRun: false, + manifestFound: true, + preview: { + toolIds: ["claude"], + activePluginDependents: [], + builtVersions: ["1.0.0"], + referencingProjects: [], + }, + }); + expect(repo.getCurrent()).toBeNull(); + expect(fs.has(sourceMarker)).toBe(false); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + }); + + it("retains canonical state when the recorded host catalogue is absent", async () => { + const manifest = manifestWithClaude(); + const repo = new InMemoryManifestRepository(manifest); + const fs = new RecordingFileAdapter(); + const marker = join(seedClaudeCache(fs), "marker.json"); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => ({ location: "Claude source registry", entries: new Map() }), + }; + + await expect( + buildUseCase({ + fs, + manifest, + repo, + activator, + logger: new CapturingLogger(), + nativeSources: new Map([["claude", source]]), + }).execute({ projectRoot: "/wherever", force: true }) + ).rejects.toThrow("claude: catalogue source unproven."); + + expect(fs.order).toEqual([]); + expect(fs.getFile(marker)).toBe("{}"); + expect(repo.getCurrent()?.toJSON()).toEqual(manifest.toJSON()); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + }); + + it("retains canonical state if the binary disappears after source preflight", async () => { + let available = true; + class DisappearingActivator extends FakeNativePluginActivator { + override isAvailable(): boolean { + return available; + } + } + const manifest = manifestWithClaude(); + const repo = new InMemoryManifestRepository(manifest); + const fs = new RecordingFileAdapter(); + const marker = join(seedClaudeCache(fs), "marker.json"); + const activator = new DisappearingActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => { + available = false; + return { + location: "Claude source registry", + entries: new Map([ + ["aidd-framework", { kind: "registry" as const, source: "/built/claude" }], + ]), + }; + }, + }; + + await expect( + buildUseCase({ + fs, + manifest, + repo, + activator, + logger: new CapturingLogger(), + nativeSources: new Map([["claude", source]]), + }).execute({ projectRoot: "/wherever", force: true }) + ).rejects.toThrow( + `claude: registration left in place, the claude CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 1 plugin ref(s). Its cache survives at: ${join(CLAUDE_CACHE, "aidd-framework")}. User clean refused; canonical claims retained.` + ); + + expect(fs.order).toEqual([]); + expect(fs.getFile(marker)).toBe("{}"); + expect(repo.getCurrent()?.toJSON()).toEqual(manifest.toJSON()); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + }); + + it.each(["missing catalogue", "duplicate claim", "disabled ref", "missing ref"] as const)( + "refuses %s before native or cache cleanup and retains the canonical manifest", + async (fault) => { + const manifest = manifestWithClaude(); + const registration = manifest.getNativeRegistrations("claude"); + if (registration === undefined) throw new Error("fixture missing registration"); + if (fault === "missing catalogue") + manifest.setNativeRegistrations("claude", { ...registration, marketplaces: [] }); + if (fault === "duplicate claim") + manifest.setNativeRegistrations("claude", { + ...registration, + pluginClaims: [ + ...(registration.pluginClaims ?? []), + { + ref: "aidd-context@aidd-framework", + dependents: [], + }, + ], + }); + const before = manifest.toJSON(); + const repo = new InMemoryManifestRepository(manifest); + const fs = new InMemoryFileAdapter(); + const cacheMarker = join(seedClaudeCache(fs), "marker.json"); + const cacheBytes = fs.getFile(cacheMarker); + const activator = new FakeNativePluginActivator({ available: true }); + const hostPlugins = new Map([ + [ + "claude" as const, + new FakeHostPluginRegistryReader({ + location: "literal host plugin registry", + refs: new Map( + fault === "missing ref" + ? [] + : [["aidd-context@aidd-framework", { enabled: fault !== "disabled ref" }]] + ), + }), + ], + ]); + + await expect( + buildUseCase({ + fs, + manifest, + repo, + activator, + hostPlugins, + logger: new CapturingLogger(), + }).execute({ projectRoot: "/wherever", force: true }) + ).rejects.toThrow(/canonical catalogue source|duplicate canonical native ref|not enabled/); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(fs.getFile(cacheMarker)).toBe(cacheBytes); + expect(repo.getCurrent()?.toJSON()).toEqual(before); + expect(repo.saveCount).toBe(0); + } + ); + + it("refuses a second missing native binary before uninstalling the first tool", async () => { + const manifest = manifestWithClaude(); + manifest.addTool("copilot", "1.0.0", []); + manifest.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [ + { + alias: "copilot-catalog", + hostName: "copilot-catalog", + provenance: { + kind: "effective-list", + root: "/built/copilot", + sourceType: "local", + source: "/built/copilot", + }, + }, + ], + pluginRefs: [], + }); + const repo = new InMemoryManifestRepository(manifest); + const fs = new InMemoryFileAdapter(); + const cacheMarker = join(seedClaudeCache(fs), "marker.json"); + const before = await fs.readFile(cacheMarker); + const claude = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + logger: new CapturingLogger(), + manifest, + repo, + activator: claude, + }); + + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /copilot CLI is not on the PATH.*claims retained/ + ); + expect(claude.uninstalledPlugins).toEqual([]); + expect(claude.removedMarketplaces).toEqual([]); + expect(await fs.readFile(cacheMarker)).toBe(before); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual( + manifest.getNativeRegistrations("claude")?.pluginClaims + ); + }); + + it.each(["claim", "projection"] as const)( + "refuses an orphan machine %s before uninstalling a proven catalogue's own ref", + async (placement) => { + const manifest = manifestWithClaude(); + const registrations = manifest.getNativeRegistrations("claude"); + if (registrations === undefined) throw new Error("fixture missing native registration"); + manifest.setNativeRegistrations("claude", { + ...registrations, + pluginRefs: [...registrations.pluginRefs, "x@B"], + pluginClaims: + placement === "claim" + ? [...(registrations.pluginClaims ?? []), { ref: "x@B", dependents: [] }] + : registrations.pluginClaims, + }); + const repo = new InMemoryManifestRepository(manifest); + const fs = new InMemoryFileAdapter(); + const cacheMarker = join(seedClaudeCache(fs), "marker.json"); + const before = await fs.readFile(cacheMarker); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + logger: new CapturingLogger(), + manifest, + repo, + activator, + }); + + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /x@B.*canonical catalogue/ + ); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(await fs.readFile(cacheMarker)).toBe(before); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual( + manifest.getNativeRegistrations("claude")?.pluginClaims + ); + } + ); + + it("refuses duplicate host catalogue registrations before uninstall or cache purge", async () => { + const manifest = manifestWithClaude(); + const registrations = manifest.getNativeRegistrations("claude"); + if (registrations === undefined) throw new Error("fixture missing native registration"); + manifest.setNativeRegistrations("claude", { + ...registrations, + marketplaces: [ + ...registrations.marketplaces, + { ...registrations.marketplaces[0], alias: "second-alias" }, + ], + }); + const repo = new InMemoryManifestRepository(manifest); + const fs = new InMemoryFileAdapter(); + const cacheMarker = join(seedClaudeCache(fs), "marker.json"); + const before = await fs.readFile(cacheMarker); + const activator = new FakeNativePluginActivator({ available: true }); + const useCase = buildUseCase({ + fs, + logger: new CapturingLogger(), + manifest, + repo, + activator, + }); + + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /ambiguous.*aidd-framework/ + ); + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(await fs.readFile(cacheMarker)).toBe(before); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual( + manifest.getNativeRegistrations("claude")?.marketplaces + ); + }); + + it("aborts before marketplace removal when the host refuses plugin uninstall", async () => { const activator = new FakeNativePluginActivator({ available: true, failOnUninstall: ["aidd-context@aidd-framework"], @@ -810,12 +1502,14 @@ describe("clean --scope user", () => { const logger = new CapturingLogger(); const useCase = buildUseCase({ fs: new InMemoryFileAdapter(), logger, activator }); - await useCase.execute({ projectRoot: "/wherever", force: true }); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /plugin uninstall.*failed.*claims retained/ + ); expect(logger.warnMessages).toStrictEqual([ "claude plugin uninstall 'aidd-context@aidd-framework' failed: plugin `aidd-context@aidd-framework` is not installed", ]); - expect(activator.removedMarketplaces).toStrictEqual(["aidd-framework"]); + expect(activator.removedMarketplaces).toStrictEqual([]); }); it("unregisters every marketplace at the user scope", async () => { @@ -840,11 +1534,12 @@ describe("clean --scope user", () => { activator: new FakeNativePluginActivator({ available: true, throwOnRemove: true }), }); - await useCase.execute({ projectRoot: "/wherever", force: true }); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /marketplace remove.*failed.*claims retained/ + ); expect(logger.warnMessages).toStrictEqual([ "claude marketplace remove 'aidd-framework' failed: marketplace remove aidd-framework failed: 'aidd-framework' is not configured or installed", - `claude: cache for 'aidd-framework' left in place, its own removal was not confirmed: ${join(CLAUDE_CACHE, "aidd-framework")}`, ]); }); @@ -853,7 +1548,18 @@ describe("clean --scope user", () => { manifest.addTool("codex", "1.0.0", []); manifest.setNativeRegistrations("codex", { binary: "codex", - marketplaces: [{ alias: "aidd-framework", hostName: "aidd-framework" }], + marketplaces: [ + { + alias: "aidd-framework", + hostName: "aidd-framework", + provenance: { + kind: "effective-list", + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + }, + ], pluginRefs: [], }); const fs = new InMemoryFileAdapter(); @@ -865,15 +1571,37 @@ describe("clean --scope user", () => { logger, manifest, binary: "codex", + nativeSources: new Map([ + [ + "codex", + { + read: async () => ({ + location: "host", + entries: new Map([ + [ + "aidd-framework", + { + kind: "effective-list" as const, + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + ], + ]), + }), + }, + ], + ]), activator: new FakeNativePluginActivator({ available: true, throwOnRemove: true }), }); - await useCase.execute({ projectRoot: "/wherever", force: true }); + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + /marketplace remove.*failed.*claims retained/ + ); expect(fs.has(leftover)).toBe(true); expect(logger.warnMessages).toStrictEqual([ "codex marketplace remove 'aidd-framework' failed: marketplace remove aidd-framework failed: 'aidd-framework' is not configured or installed", - `codex: cache for 'aidd-framework' left in place, its own removal was not confirmed: ${join(CODEX_CACHE, "aidd-framework")}`, ]); }); }); @@ -890,11 +1618,12 @@ describe("clean --scope user", () => { binary: toolId, marketplaces: [...marketplaces], pluginRefs: [...pluginRefs], + pluginClaims: pluginRefs.map((ref) => ({ ref, dependents: [] })), }); return manifest; } - async function warningsFor(manifest: Manifest): Promise { + async function errorsFor(manifest: Manifest): Promise { const logger = new CapturingLogger(); const useCase = new CleanUserScopeUseCase( new InMemoryFileAdapter(), @@ -906,12 +1635,16 @@ describe("clean --scope user", () => { new Map(), () => HOME ); - await useCase.execute({ projectRoot: "/wherever", force: true }); - return logger.warnMessages; + try { + await useCase.execute({ projectRoot: "/wherever", force: true }); + throw new Error("expected a fail-closed binary refusal"); + } catch (error) { + return [(error as Error).message]; + } } it("names no cache for a host whose profile declares no cache directory (copilot)", async () => { - const warnings = await warningsFor( + const warnings = await errorsFor( seedRegistrations( "copilot", [{ alias: "aidd-framework", hostName: "aidd-framework" }], @@ -920,32 +1653,32 @@ describe("clean --scope user", () => { ); expect(warnings).toStrictEqual([ - "copilot: registration left in place, the copilot CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 1 plugin ref(s).", + "copilot: registration left in place, the copilot CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 1 plugin ref(s). User clean refused; canonical claims retained.", ]); }); it("names no cache for a tool that drives no native CLI (cursor)", async () => { - const warnings = await warningsFor( + const warnings = await errorsFor( seedRegistrations("cursor", [{ alias: "aidd-framework", hostName: "aidd-framework" }], []) ); expect(warnings).toStrictEqual([ - "cursor: registration left in place, the cursor CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 0 plugin ref(s).", + "cursor: registration left in place, the cursor CLI is not on the PATH. It would have unregistered 1 marketplace(s) and 0 plugin ref(s). User clean refused; canonical claims retained.", ]); }); it("names no cache when the binary registered no marketplace", async () => { - const warnings = await warningsFor( + const warnings = await errorsFor( seedRegistrations("codex", [], ["aidd-context@aidd-framework"]) ); expect(warnings).toStrictEqual([ - "codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 0 marketplace(s) and 1 plugin ref(s).", + "codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 0 marketplace(s) and 1 plugin ref(s). User clean refused; canonical claims retained.", ]); }); it("names every surviving cache, one path per marketplace", async () => { - const warnings = await warningsFor( + const warnings = await errorsFor( seedRegistrations( "codex", [ @@ -957,7 +1690,7 @@ describe("clean --scope user", () => { ); expect(warnings).toStrictEqual([ - `codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 2 marketplace(s) and 0 plugin ref(s). Its cache survives at: ${join(CODEX_CACHE, "mkt-a")}, ${join(CODEX_CACHE, "mkt-b")}.`, + `codex: registration left in place, the codex CLI is not on the PATH. It would have unregistered 2 marketplace(s) and 0 plugin ref(s). Its cache survives at: ${join(CODEX_CACHE, "mkt-a")}, ${join(CODEX_CACHE, "mkt-b")}. User clean refused; canonical claims retained.`, ]); }); }); @@ -998,6 +1731,55 @@ describe("clean --scope user", () => { }); describe("the cache/ shell around the whitelist", () => { + it("tolerates a cache shell already absent while still removing later whitelist files", async () => { + const fs = new StrictListingFileAdapter(); + const marker = join(USER_CONFIG_DIR, "update-check.json"); + fs.setFile(marker, "legacy cache"); + const logger = new CapturingLogger(); + const repo = new InMemoryManifestRepository(Manifest.create()); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + logger, + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + const result = await useCase.execute({ projectRoot: "/wherever", force: true }); + + expect(result.dryRun).toBe(false); + expect(fs.has(marker)).toBe(false); + expect(repo.getCurrent()).toBeNull(); + expect(logger.infoMessages).not.toContain( + `user scope: cache purged: ${join(USER_CONFIG_DIR, "cache")}` + ); + }); + + it("propagates an unreadable cache shell and retains canonical state and later whitelist files", async () => { + const fs = new StrictListingFileAdapter(join(USER_CONFIG_DIR, "cache")); + const source = join(USER_CONFIG_DIR, "cache", "built", "1.0.0", "catalogue.json"); + const legacy = join(USER_CONFIG_DIR, "update-check.json"); + fs.setFile(source, "built source"); + fs.setFile(legacy, "legacy cache"); + const machine = Manifest.create(); + const repo = new InMemoryManifestRepository(machine); + const useCase = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + new InMemoryMarketplaceRegistry(), + () => USER_CONFIG_DIR + ); + + await expect(useCase.execute({ projectRoot: "/wherever", force: true })).rejects.toThrow( + "permission denied" + ); + + expect(fs.has(source)).toBe(false); + expect(fs.getFile(legacy)).toBe("legacy cache"); + expect(repo.getCurrent()).toBe(machine); + }); + it("leaves cache/ and everything under it in place, naming each, once cache/ resolves outside userConfigDir()", async () => { const fs = new RecordingFileAdapter(); const cacheDir = join(USER_CONFIG_DIR, "cache"); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-remove-native-claims.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-remove-native-claims.integration.test.ts new file mode 100644 index 000000000..f509b0d8a --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-remove-native-claims.integration.test.ts @@ -0,0 +1,389 @@ +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceRemoveUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-remove-use-case.js"; +import { ProjectPluginCleanup } from "../../../../../src/contexts/framework/application/ownership/project-plugin-cleanup.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; +import { KeepPrompter, ScriptedPrompter } from "../../../../helpers/ports/scripted-prompter.js"; + +const REF = "sample-plugin@real-catalog"; +const OTHER = "other-plugin@other-catalog"; +const NAME = "project-alias"; + +async function fixture(options: { nativeMapping?: "absent" | "unrelated" } = {}) { + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "sample-plugin", + "1.0.0", + { kind: "local", path: "/fixture" }, + false, + "project", + NAME + ) + ); + if (options.nativeMapping !== "absent") + project.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + ...(options.nativeMapping === "unrelated" + ? [] + : [{ alias: NAME, hostName: "real-catalog" }]), + { alias: "other-alias", hostName: "other-catalog" }, + ], + pluginRefs: [REF, OTHER], + }); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: NAME, hostName: "real-catalog" }], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: ["/A", "/B"] }], + }); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: NAME, + source: { kind: "github", repo: "owner/project-alias" }, + scope: "project", + addedAt: "2026-09-01T00:00:00.000Z", + }) + ); + const fs = new InMemoryFileAdapter(); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs, machineRepo), + projectRepo, + registry, + new KeepPrompter() + ); + return { projectRepo, machineRepo, registry, fs, useCase }; +} + +describe("project marketplace removal against machine native claims", () => { + it.each(["absent", "unrelated"] as const)( + "removes a local orphan with %s native mapping without guessing or detaching machine refs", + async (nativeMapping) => { + const f = await fixture({ nativeMapping }); + const before = f.projectRepo.getCurrent()?.getNativeRegistrations("codex"); + expect( + await f.useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).toMatchObject({ removedPluginCount: 1, orphanCount: 1 }); + expect(f.projectRepo.getCurrent()?.getPlugins("codex")).toEqual([]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")).toEqual(before); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + expect(f.machineRepo.saveCount).toBe(0); + expect(await f.registry.list("/A")).toEqual([]); + } + ); + + it("does not detach machine claims when removing a project catalogue without orphans", async () => { + const f = await fixture(); + f.projectRepo.getCurrent()?.removePlugin("codex", "sample-plugin"); + const before = f.projectRepo.getCurrent()?.getNativeRegistrations("codex"); + expect( + await f.useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).toMatchObject({ removedPluginCount: 0, orphanCount: 0 }); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")).toEqual(before); + expect(f.projectRepo.saveCount).toBe(0); + expect(f.machineRepo.saveCount).toBe(0); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + expect(await f.registry.list("/A")).toEqual([]); + }); + + it("retains A's projection and machine claim when its Cursor hooks cannot be read", async () => { + const hooksPath = join("/A", ".cursor/hooks.json"); + const hooks = JSON.stringify({ + version: 1, + hooks: { preToolUse: [{ command: "node ./.cursor/hooks/sample-plugin/pre.js" }] }, + }); + const fs = new FaultingFileAdapter(); + fs.setFile(hooksPath, hooks); + fs.setFile(join("/A", ".cursor/hooks/sample-plugin/pre.js"), "A's hook"); + fs.setFile(join("/B", ".cursor/hooks.json"), hooks); + const globalMcp = join(homedir(), ".cursor/plugins/local/sample-plugin/mcp.json"); + fs.setFile(globalMcp, "B's MCP"); + fs.failOn("readFile", hooksPath, errnoError("EACCES")); + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + marketplace: NAME, + projectHooks: { + entries: [ + { + event: "preToolUse", + command: "node ./.cursor/hooks/sample-plugin/pre.js", + digest: createHash("md5") + .update(JSON.stringify({ command: "node ./.cursor/hooks/sample-plugin/pre.js" })) + .digest("hex"), + }, + ], + scripts: { + ".cursor/hooks/sample-plugin/pre.js": createHash("md5") + .update("A's hook") + .digest("hex"), + }, + }, + }) + ); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + dependents: ["/A", "/B"], + }) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: NAME, + source: { kind: "local", path: "/fixture" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs, machineRepo), + projectRepo, + registry, + new KeepPrompter() + ); + + await expect( + useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).rejects.toThrow(/EACCES/); + + expect(projectRepo.saveCount).toBe(0); + expect(projectRepo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/A", "/B"]); + expect(fs.getFile(hooksPath)).toBe(hooks); + expect(fs.getFile(globalMcp)).toBe("B's MCP"); + expect((await registry.list("/A")).some((entry) => entry.name === NAME)).toBe(true); + }); + + it("refuses user scope at the project use-case edge without touching a catalogue", async () => { + const f = await fixture(); + await expect( + f.useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true, scope: "user" }) + ).rejects.toThrow(/User-scope marketplace removal requires/); + expect((await f.registry.list("/A")).find((entry) => entry.name === NAME)).toBeDefined(); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + }); + + it("removes only the project catalogue when its project manifest is absent", async () => { + const f = await fixture(); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(f.fs, f.machineRepo), + new InMemoryManifestRepository(), + f.registry, + new KeepPrompter() + ); + expect( + await useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).toMatchObject({ removedPluginCount: 0, orphanCount: 0 }); + expect((await f.registry.list("/A")).find((entry) => entry.name === NAME)).toBeUndefined(); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + }); + + it("deletes A's exact orphan projection but preserves B and a different host catalogue", async () => { + const f = await fixture(); + expect( + await f.useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).toMatchObject({ removedPluginCount: 1, orphanCount: 1 }); + expect(f.projectRepo.getCurrent()?.getPlugins("codex")).toEqual([]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + OTHER, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/B"] }, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([REF]); + expect((await f.registry.list("/A")).find((entry) => entry.name === NAME)).toBeUndefined(); + }); + + it("does not detach B or claim a ref after a local file deletion failure", async () => { + class RefusingFileAdapter extends InMemoryFileAdapter { + override async deleteFile(): Promise { + throw new Error("local file delete failed"); + } + } + const f = await fixture(); + const project = f.projectRepo.getCurrent(); + const plugin = project?.getPlugins("codex")[0]; + if (project === null || plugin === undefined) throw new Error("fixture missing plugin"); + project.updatePlugin("codex", plugin.withFiles(new Map([[".codex/skills/demo.md", "abc"]]))); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(new RefusingFileAdapter(), f.machineRepo), + f.projectRepo, + f.registry, + new KeepPrompter() + ); + await expect( + useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).rejects.toThrow(/local file delete failed/); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + expect((await f.registry.list("/A")).find((entry) => entry.name === NAME)).toBeDefined(); + }); + + it("leaves a project plugin and B's machine claim when the operator declines orphan cleanup", async () => { + const f = await fixture(); + const prompter = new ScriptedPrompter([{ type: "confirm", value: false }]); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(f.fs, f.machineRepo), + f.projectRepo, + f.registry, + prompter + ); + expect( + await useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: false }) + ).toMatchObject({ removedPluginCount: 0, orphanCount: 1 }); + expect(f.projectRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + }); + + it("releases only A's Cursor file claim, leaving B's user bytes intact", async () => { + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + marketplace: NAME, + projectHooks: { + entries: [ + { + event: "preToolUse", + command: "node ./.cursor/hooks/sample-plugin/pre.js", + digest: createHash("md5") + .update(JSON.stringify({ command: "node ./.cursor/hooks/sample-plugin/pre.js" })) + .digest("hex"), + }, + ], + scripts: { + ".cursor/hooks/sample-plugin/pre.js": createHash("md5") + .update("A's hook") + .digest("hex"), + }, + }, + }) + ); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: { "sample-plugin/skills/demo.md": "abc" }, + scope: "user", + marketplace: NAME, + dependents: ["/A", "/B"], + }) + ); + const machineRepo = new InMemoryManifestRepository(machine); + const projectRepo = new InMemoryManifestRepository(project); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: NAME, + source: { kind: "github", repo: "owner/project-alias" }, + scope: "project", + addedAt: "2026-09-01T00:00:00.000Z", + }) + ); + const hooks = JSON.stringify({ + version: 1, + hooks: { + preToolUse: [{ command: "node ./.cursor/hooks/sample-plugin/pre.js" }], + }, + }); + const aHooks = join("/A", ".cursor/hooks.json"); + const bHooks = join("/B", ".cursor/hooks.json"); + const aScript = join("/A", ".cursor/hooks/sample-plugin/pre.js"); + const bScript = join("/B", ".cursor/hooks/sample-plugin/pre.js"); + const fs = new InMemoryFileAdapter({ + [aHooks]: hooks, + [bHooks]: hooks, + [aScript]: "A's hook", + [bScript]: "B's hook", + }); + const ownedPath = join(homedir(), ".cursor/plugins/local/sample-plugin/skills/demo.md"); + const ownedMcp = join(homedir(), ".cursor/plugins/local/sample-plugin/mcp.json"); + fs.setFile(ownedPath, "B's bytes"); + fs.setFile(ownedMcp, "B's MCP"); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs, machineRepo), + projectRepo, + registry, + new KeepPrompter() + ); + expect( + await useCase.execute({ name: NAME, projectRoot: "/A", autoConfirm: true }) + ).toMatchObject({ + removedPluginCount: 1, + }); + expect(fs.getFile(ownedPath)).toBe("B's bytes"); + expect(fs.getFile(ownedMcp)).toBe("B's MCP"); + expect(fs.getFile(aHooks)).toBeUndefined(); + expect(fs.getFile(aScript)).toBeUndefined(); + expect(fs.getFile(bHooks)).toBe(hooks); + expect(fs.getFile(bScript)).toBe("B's hook"); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/B"]); + expect(projectRepo.getCurrent()?.getPlugins("cursor")).toEqual([]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts index 44c8a7d5e..1fb1cc57b 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-remove-use-case.unit.test.ts @@ -1,12 +1,15 @@ +import { createHash } from "node:crypto"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { FRAMEWORK_MARKETPLACE_NAME, Marketplace, } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { MarketplaceRemoveUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-remove-use-case.js"; +import { ProjectPluginCleanup } from "../../../../../src/contexts/framework/application/ownership/project-plugin-cleanup.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { @@ -82,7 +85,12 @@ function buildUseCase() { const fs = new RecordingFileAdapter({}, hasher); const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, new KeepPrompter()); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + new KeepPrompter() + ); return { useCase, registry, manifestRepo, fs }; } @@ -157,9 +165,14 @@ describe("MarketplaceRemoveUseCase", () => { expect(reloaded?.getPlugins("claude")).toHaveLength(0); }); - it("removes a user-scope (cursor) orphan's file from its resolved home directory, not projectRoot", async () => { + it("removes a project marketplace's orphan projection without deleting shared Cursor files", async () => { const { registry, manifestRepo, fs } = buildUseCase(); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, new KeepPrompter()); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + new KeepPrompter() + ); const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); const pluginKey = "aidd-context/commands/hello.md"; @@ -196,13 +209,18 @@ describe("MarketplaceRemoveUseCase", () => { expect(result.removedPluginCount).toBe(1); expect( fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", pluginKey))) - ).toBe(true); + ).toBe(false); expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, pluginKey)); }); it("removes a cursor orphan's file under projectRoot, not ~/.cursor/plugins/local, when the manifest says scope: project", async () => { const { registry, manifestRepo, fs } = buildUseCase(); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, new KeepPrompter()); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + new KeepPrompter() + ); const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); const pluginKey = "aidd-context/commands/hello.md"; @@ -321,6 +339,254 @@ describe("which marketplace a removal takes out", () => { }); describe("which plugins a removal orphans", () => { + it("keeps A and B Cursor claims when project hooks.json resolves outside the project", async () => { + const hooksPath = join(PROJECT_ROOT, ".cursor/hooks.json"); + const foreignPath = "/foreign/hooks.json"; + const command = "node ./.cursor/hooks/sample/pre.js"; + const entry = { command }; + const content = JSON.stringify({ version: 1, hooks: { preToolUse: [entry] } }); + const fs = new InMemoryFileAdapter({ [hooksPath]: content, [foreignPath]: content }); + fs.setSymlink(hooksPath, foreignPath); + const plugin = InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + marketplace: "awesome", + projectHooks: { + entries: [ + { + event: "preToolUse", + command, + digest: createHash("md5").update(JSON.stringify(entry)).digest("hex"), + }, + ], + scripts: {}, + }, + }); + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin("cursor", plugin); + const projectRepo = new InMemoryManifestRepository(project); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin("cursor", plugin.withDependents([PROJECT_ROOT, "/B"])); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs, machineRepo), + projectRepo, + registry, + new KeepPrompter() + ); + + await expect( + useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(/outside.*project/); + + expect(projectRepo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual([ + PROJECT_ROOT, + "/B", + ]); + expect(fs.getFile(foreignPath)).toBe(content); + expect((await registry.list(PROJECT_ROOT)).some((entry) => entry.name === "awesome")).toBe( + true + ); + }); + + it("retains the OpenCode orphan and catalogue when MCP output is a symlink outside the project", async () => { + const mcpPath = join(PROJECT_ROOT, "opencode.json"); + const foreignPath = "/foreign/opencode.json"; + const content = JSON.stringify({ + mcp: { mine: { type: "local" }, theirs: { type: "remote" } }, + }); + const fs = new InMemoryFileAdapter({ [mcpPath]: content, [foreignPath]: content }); + fs.setSymlink(mcpPath, foreignPath); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: new DeterministicHasher().hash(JSON.stringify({ type: "local" })).value, + }, + scope: "project", + marketplace: "awesome", + }) + ); + const manifestRepo = new InMemoryManifestRepository(manifest); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + new KeepPrompter() + ); + + await expect( + useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(/outside.*project|escapes.*project/); + + expect(manifestRepo.saveCount).toBe(0); + expect(manifestRepo.getCurrent()?.getPlugins("opencode")).toHaveLength(1); + expect(fs.getFile(foreignPath)).toBe(content); + expect(fs.getFile(mcpPath)).toBe(content); + expect((await registry.list(PROJECT_ROOT)).some((entry) => entry.name === "awesome")).toBe( + true + ); + }); + + it("retains the OpenCode orphan and catalogue when its project MCP config cannot be rewritten", async () => { + const mcpPath = join(PROJECT_ROOT, "opencode.json"); + const content = JSON.stringify({ + mcp: { mine: { type: "local" }, theirs: { type: "remote" } }, + }); + class RefusingMcpWriteAdapter extends InMemoryFileAdapter { + override async writeFile(path: string, value: string): Promise { + if (path === mcpPath) throw new Error("MCP write refused"); + return super.writeFile(path, value); + } + } + const fs = new RefusingMcpWriteAdapter({ [mcpPath]: content }); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: new DeterministicHasher().hash(JSON.stringify({ type: "local" })).value, + }, + scope: "project", + marketplace: "awesome", + }) + ); + const manifestRepo = new InMemoryManifestRepository(manifest); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + new KeepPrompter() + ); + + await expect( + useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(/MCP write refused/); + + expect(manifestRepo.saveCount).toBe(0); + expect(manifestRepo.getCurrent()?.getPlugins("opencode")).toHaveLength(1); + expect(fs.getFile(mcpPath)).toBe(content); + expect((await registry.list(PROJECT_ROOT)).some((entry) => entry.name === "awesome")).toBe( + true + ); + }); + + it("unmerges only the orphan's contributed OpenCode MCP server from the project", async () => { + const { useCase, registry, manifestRepo, fs } = buildUseCase(); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: new DeterministicHasher().hash(JSON.stringify({ type: "local" })).value, + }, + scope: "project", + marketplace: "awesome", + }) + ); + await manifestRepo.save(manifest); + const mcpPath = join(PROJECT_ROOT, "opencode.json"); + fs.setFile( + mcpPath, + JSON.stringify({ mcp: { mine: { type: "local" }, theirs: { type: "remote" } } }) + ); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + + fs.setFile( + mcpPath, + JSON.stringify({ mcp: { mine: { type: "user-edited" }, theirs: { type: "remote" } } }) + ); + await expect( + useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(/edited/); + expect((await manifestRepo.load())?.getPlugins("opencode")).toHaveLength(1); + expect((await registry.list(PROJECT_ROOT)).some((entry) => entry.name === "awesome")).toBe( + true + ); + fs.setFile( + mcpPath, + JSON.stringify({ mcp: { mine: { type: "local" }, theirs: { type: "remote" } } }) + ); + + await useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }); + + expect(JSON.parse(fs.getFile(mcpPath) ?? "null")).toEqual({ + mcp: { theirs: { type: "remote" } }, + }); + expect((await manifestRepo.load())?.getPlugins("opencode")).toEqual([]); + }); + + it("preflights every orphan before touching A when B's MCP contribution was edited", async () => { + const { useCase, registry, manifestRepo, fs } = buildUseCase(); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + for (const name of ["alpha", "beta"]) { + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name, + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + [name]: new DeterministicHasher().hash(JSON.stringify({ type: "local" })).value, + }, + scope: "project", + marketplace: "awesome", + }) + ); + } + await manifestRepo.save(manifest); + await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); + const path = join(PROJECT_ROOT, "opencode.json"); + const edited = JSON.stringify({ + mcp: { alpha: { type: "local" }, beta: { type: "user-edited" } }, + }); + fs.setFile(path, edited); + await expect( + useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: true }) + ).rejects.toThrow(/edited.*beta|beta.*edited/); + expect(fs.getFile(path)).toBe(edited); + expect((await manifestRepo.load())?.getPlugins("opencode").map((p) => p.name)).toEqual([ + "alpha", + "beta", + ]); + expect((await registry.list(PROJECT_ROOT)).some((m) => m.name === "awesome")).toBe(true); + }); + it("removes the marketplace's own plugins and leaves another marketplace's in place", async () => { const { useCase, registry, manifestRepo } = buildUseCase(); await manifestRepo.save( @@ -344,7 +610,12 @@ describe("which plugins a removal orphans", () => { it("keeps every orphan when the person declines the cleanup, and still drops the marketplace", async () => { const { registry, manifestRepo, fs } = buildUseCase(); const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(false)]); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + prompter + ); await manifestRepo.save(manifestWithPlugins(pluginFrom("awesome", "sample"))); const awesome = marketplaceNamed("awesome"); await registry.save(PROJECT_ROOT, awesome); @@ -365,7 +636,12 @@ describe("which plugins a removal orphans", () => { it("asks once, naming how many plugins the cleanup would remove", async () => { const { registry, manifestRepo, fs } = buildUseCase(); const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(true)]); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + prompter + ); await manifestRepo.save( manifestWithPlugins(pluginFrom("awesome", "sample"), pluginFrom("awesome", "second")) ); @@ -389,7 +665,12 @@ describe("which plugins a removal orphans", () => { manifestWithPlugins(pluginFrom("elsewhere", "other")) ); const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(true)]); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + prompter + ); await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); await useCase.execute({ name: "awesome", projectRoot: PROJECT_ROOT, autoConfirm: false }); @@ -401,7 +682,12 @@ describe("which plugins a removal orphans", () => { it("cleans up without asking when the caller auto-confirms", async () => { const { registry, manifestRepo, fs } = buildUseCase(); const prompter = new ConfirmRecordingPrompter([ScriptedPrompter.answer.confirm(false)]); - const useCase = new MarketplaceRemoveUseCase(fs, manifestRepo, registry, prompter); + const useCase = new MarketplaceRemoveUseCase( + new ProjectPluginCleanup(fs), + manifestRepo, + registry, + prompter + ); await manifestRepo.save(manifestWithPlugins(pluginFrom("awesome", "sample"))); await registry.save(PROJECT_ROOT, marketplaceNamed("awesome")); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts index fa8302a70..9a809e1ff 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-source-conflict.integration.test.ts @@ -10,6 +10,8 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -43,6 +45,8 @@ interface Setup { * reported success but left nothing readable where its own tool profile probes. */ readonly omitRequestedCatalog?: boolean; readonly fs?: InMemoryFileAdapter; + /** A prior user manifest claim, not this project's plugin projection, proves catalogue ownership. */ + readonly ownedCatalog?: true; } class RealpathRefusingFileAdapter extends InMemoryFileAdapter { @@ -108,6 +112,35 @@ async function sync(setup: Setup = {}) { ? [] : ([[toolId, setup.hostReader]] as [AiToolId, FakeHostMarketplaceRegistryReader][]) ); + const hostReading = toolId === "claude" ? await setup.hostReader?.read() : undefined; + const registeredSource = hostReading?.entries?.get(catalogName); + const currentSource = + registeredSource === undefined + ? undefined + : toolId === "claude" + ? ({ kind: "registry", source: registeredSource } as const) + : ({ + kind: "effective-list", + root: registeredSource, + sourceType: "local", + source: registeredSource, + } as const); + const machine = setup.ownedCatalog ? Manifest.create() : undefined; + if (machine !== undefined) { + machine.addTool(toolId, "test", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [ + { + alias: aiddName, + hostName: catalogName, + ...(currentSource === undefined ? {} : { provenance: currentSource }), + }, + ], + pluginRefs: [], + pluginClaims: [], + }); + } const useCase = new MarketplaceSyncSettingsUseCase( fs, manifestRepo, @@ -116,7 +149,39 @@ async function sync(setup: Setup = {}) { logger, new Map([[toolId === "claude" ? "claude" : "codex", activator]]), fakeEnsureBuiltMarketplace((target) => `/built/${target}`), - hostRegistries + hostRegistries, + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + toolId, + new FakeHostPluginRegistryReader({ + location: `/home/${toolId}/plugins/installed_plugins.json`, + refs: new Map(), + }), + ], + ]), + machine === undefined ? undefined : new InMemoryManifestRepository(machine), + new Map([ + [ + toolId, + hostReading?.unreadable !== undefined + ? { + read: async () => ({ + location: hostReading.location, + unreadable: hostReading.unreadable, + }), + } + : new FakeNativeMarketplaceSourceReader( + activator, + toolId === "claude" ? "registry" : "effective-list", + (path) => (path === builtDir ? catalogName : undefined), + currentSource === undefined ? new Map() : new Map([[catalogName, currentSource]]) + ), + ], + ]) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); return { result, activator, manifestRepo, builtDir, catalogRelative }; @@ -153,6 +218,7 @@ describe("the sync guard against a marketplace name a host already holds", () => const { result, activator } = await sync({ hostReader, requestedVersion: "2.0.0", + ownedCatalog: true, requestedPluginNames: ["sample-plugin"], registeredCatalog: { path: "/other/src", version: "1.0.0", pluginNames: ["sample-plugin"] }, }); @@ -169,9 +235,9 @@ describe("the sync guard against a marketplace name a host already holds", () => entries: new Map([["probe-mkt", "/built/claude"]]), }); - const { result, activator } = await sync({ hostReader }); + const { result, activator } = await sync({ hostReader, ownedCatalog: true }); - expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(activator.addedMarketplaces).toEqual([]); expect(result.errors).toEqual([]); }); @@ -186,6 +252,7 @@ describe("the sync guard against a marketplace name a host already holds", () => const { result, activator } = await sync({ hostReader, requestedVersion: "1.0.0", + ownedCatalog: true, requestedPluginNames: ["sample-plugin"], registeredCatalog: { path: "/other-project/built/claude", @@ -198,7 +265,7 @@ describe("the sync guard against a marketplace name a host already holds", () => expect(result.errors).toEqual([]); }); - it("does not refuse when the registered source no longer resolves to a readable catalog — a dead entry a re-add repairs", async () => { + it("refuses an unproven dead entry instead of re-adding over its host name", async () => { const hostReader = new FakeHostMarketplaceRegistryReader({ location: REGISTRY_LOCATION, entries: new Map([["probe-mkt", "/gone"]]), @@ -208,7 +275,8 @@ describe("the sync guard against a marketplace name a host already holds", () => // catalog fails exactly as it would for a directory that no longer exists. const { result, activator } = await sync({ hostReader }); - expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.join("\n")).toContain("legacy claim has no source proof"); expect(result.errors).toEqual([]); }); @@ -259,7 +327,7 @@ describe("the sync guard against a marketplace name a host already holds", () => { scope: "claude", message: - "Marketplace 'probe-mkt' is already registered from a different catalog: /other/src differs from the one requested, /built/claude — plugins differ (+sample-plugin, -different-plugin), per /home/.claude/plugins/known_marketplaces.json. Run `claude plugin marketplace remove probe-mkt`, then `aidd sync` again to re-register it for this project.", + "Marketplace 'probe-mkt' is already registered from a different catalog: /other/src differs from the one requested, /built/claude — plugins differ (+sample-plugin, -different-plugin), per /home/.claude/plugins/known_marketplaces.json. Do not remove a catalogue another user or project may depend on. Reconcile the host source manually before retrying `aidd sync`.", }, ]); }); @@ -267,12 +335,14 @@ describe("the sync guard against a marketplace name a host already holds", () => it("registers from the built path as given when that path cannot be resolved", async () => { const hostReader = new FakeHostMarketplaceRegistryReader({ location: REGISTRY_LOCATION, - entries: new Map([["probe-mkt", "/built/claude"]]), + entries: new Map([["probe-mkt", "/old/claude"]]), }); const { result, activator } = await sync({ hostReader, fs: new RealpathRefusingFileAdapter("/built/claude"), + ownedCatalog: true, + registeredCatalog: { path: "/old/claude" }, }); expect(result.errors).toStrictEqual([]); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-machine-projection.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-machine-projection.integration.test.ts new file mode 100644 index 000000000..4379b64af --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-machine-projection.integration.test.ts @@ -0,0 +1,812 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSource } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +describe("native plugin ownership follows the host's activation scope", () => { + const freshNativeSources = ( + toolId: "claude" | "codex", + activator: FakeNativePluginActivator, + identities: ReadonlyMap, + initial: ReadonlyMap = new Map() + ) => + new Map([ + [ + toolId, + new FakeNativeMarketplaceSourceReader( + activator, + toolId === "claude" ? "registry" : "effective-list", + (path) => identities.get(path), + initial + ), + ], + ]); + const readablePlugins = (toolId: "claude" | "codex") => + new Map([ + [ + toolId, + new FakeHostPluginRegistryReader({ + location: `/home/${toolId}/plugins/installed_plugins.json`, + refs: new Map(), + }), + ], + ]); + const freshHostCatalogs = () => + new Map([ + [ + "claude" as const, + new FakeHostMarketplaceRegistryReader({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map(), + }), + ], + ]); + const readableHostPlugins = () => + new Map([ + [ + "claude" as const, + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + refs: new Map(), + }), + ], + ]); + it.each(["fresh", "existing project proof", "existing machine proof"] as const)( + "records a user-scope Claude catalogue without claiming its project-local plugin: %s", + async (mode) => { + const projectRoot = "/project"; + const marketplaceName = "local-catalog"; + const hostName = "real-catalog"; + const ref = `test-plugin@${hostName}`; + const otherRef = "owned-other@other-catalog"; + const otherRegistration = { + alias: "other", + hostName: "other-catalog", + provenance: { kind: "registry" as const, source: "/other/claude" }, + }; + const registration = { + alias: marketplaceName, + hostName, + provenance: { kind: "registry" as const, source: "/built/claude" }, + }; + const project = Manifest.create(); + project.addTool("claude", "1.0.0", []); + project.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + marketplaceName + ) + ); + const machine = Manifest.create(); + if (mode !== "fresh") { + project.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: mode === "existing project proof" ? [otherRegistration, registration] : [], + pluginRefs: [ref], + }); + machine.addTool("claude", "1.0.0", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: + mode === "existing machine proof" + ? [otherRegistration, registration] + : [otherRegistration], + pluginRefs: [otherRef], + pluginClaims: [{ ref: otherRef, dependents: ["/B"] }], + }); + } + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: marketplaceName, + source: { kind: "local", path: "/source" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: hostName, + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + mode === "fresh" + ? freshHostCatalogs() + : new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([ + [hostName, "/built/claude"], + ["other-catalog", "/other/claude"], + ]), + }), + ], + ]), + () => "", + undefined, + undefined, + undefined, + mode === "fresh" + ? readableHostPlugins() + : new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + refs: new Map([ + [ref, { enabled: true, scope: "project" }], + [otherRef, { enabled: true, scope: "user" }], + ["test-plugin@external-catalog", { enabled: true, scope: "user" }], + ]), + }), + ], + ]), + machineRepo, + freshNativeSources( + "claude", + activator, + new Map([["/built/claude", hostName]]), + mode === "fresh" + ? new Map() + : new Map([ + [hostName, registration.provenance], + ["other-catalog", otherRegistration.provenance], + ]) + ) + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.activated).toEqual(["claude"]); + expect(result.errors).toStrictEqual([]); + expect(result.warnings).toStrictEqual([]); + expect(activator.enabledPlugins).toEqual(mode === "fresh" ? [ref] : []); + expect(activator.enabledPluginScopes).toEqual(mode === "fresh" ? ["project"] : []); + expect(activator.addedMarketplaces).toStrictEqual(mode === "fresh" ? ["/built/claude"] : []); + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs).toEqual([ref]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual( + mode === "fresh" ? [registration] : [otherRegistration, registration] + ); + expect( + machineRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims ?? [] + ).toEqual(mode === "fresh" ? [] : [{ ref: otherRef, dependents: ["/B"] }]); + if (mode !== "fresh") + expect( + machineRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs + ).toStrictEqual([otherRef]); + } + ); + + it("claims a Claude plugin enabled at user scope without inventing a project dependent", async () => { + const projectRoot = "/project"; + const alias = "user-catalog"; + const ref = "test-plugin@real-catalog"; + const user = Manifest.create(); + user.addTool("claude", "1.0.0", []); + user.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "user", + alias + ) + ); + const userRepo = new InMemoryManifestRepository(user); + const projectRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/source" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: "real-catalog", + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + freshHostCatalogs(), + () => "", + undefined, + undefined, + undefined, + readablePlugins("claude"), + userRepo, + freshNativeSources("claude", activator, new Map([["/built/claude", "real-catalog"]])) + ); + + const result = await sync.execute({ projectRoot, scope: "user", manifestRepo: userRepo }); + + expect(result.errors).toEqual([]); + expect(activator.enabledPlugins).toEqual([ref]); + expect(activator.enabledPluginScopes).toEqual(["user"]); + expect(userRepo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual([ + { + alias, + hostName: "real-catalog", + provenance: { kind: "registry", source: "/built/claude" }, + }, + ]); + expect(userRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ + { ref, dependents: [] }, + ]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("claude")).toBeUndefined(); + }); + + it("records only the user-scope marketplace in the machine manifest when a project syncs both scopes", async () => { + const projectRoot = "/project"; + const project = Manifest.create(); + project.addTool("claude", "1.0.0", []); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + for (const [name, scope] of [ + ["user-alias", "user"], + ["project-alias", "project"], + ] as const) { + await registry.save( + projectRoot, + Marketplace.create({ + name, + source: { kind: "local", path: `/source/${name}` }, + scope, + addedAt: "2026-09-15T00:00:00Z", + }) + ); + } + const fs = new InMemoryFileAdapter({ + "/built/user-alias/.claude-plugin/marketplace.json": JSON.stringify({ + name: "real-user", + version: "1.0.0", + plugins: [], + }), + "/built/project-alias/.claude-plugin/marketplace.json": JSON.stringify({ + name: "real-project", + version: "1.0.0", + plugins: [], + }), + }); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + { + execute: async ({ marketplace }) => ({ + builtDir: `/built/${marketplace.name}`, + version: "1.0.0", + rebuilt: true, + }), + }, + freshHostCatalogs(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + machineRepo, + freshNativeSources( + "claude", + activator, + new Map([ + ["/built/user-alias", "real-user"], + ["/built/project-alias", "real-project"], + ]) + ) + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.errors).toEqual([]); + const projectMarketplaces = projectRepo + .getCurrent() + ?.getNativeRegistrations("claude")?.marketplaces; + expect(projectMarketplaces).toHaveLength(2); + expect(projectMarketplaces).toEqual( + expect.arrayContaining([ + { + alias: "user-alias", + hostName: "real-user", + provenance: { kind: "registry", source: "/built/user-alias" }, + }, + { + alias: "project-alias", + hostName: "real-project", + provenance: { kind: "registry", source: "/built/project-alias" }, + }, + ]) + ); + expect(machineRepo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual([ + { + alias: "user-alias", + hostName: "real-user", + provenance: { kind: "registry", source: "/built/user-alias" }, + }, + ]); + }); + + it("records a Codex plugin as machine-owned even when its marketplace source is project-local", async () => { + const projectRoot = "/codex-project"; + const marketplaceName = "local-catalog"; + const ref = "test-plugin@real-catalog"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + marketplaceName + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + const older = { + alias: marketplaceName, + hostName: "older-catalog", + provenance: { + kind: "effective-list" as const, + root: "/older", + sourceType: "local", + source: "/older", + }, + }; + const otherRef = "unrelated-plugin@older-catalog"; + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [older], + pluginRefs: [otherRef], + pluginClaims: [ + { ref: otherRef, dependents: ["/B"] }, + { ref, dependents: ["/B"] }, + ], + }); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: marketplaceName, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-catalog", + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + fs.setSymlink(projectRoot, "/resolved-codex-project"); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readablePlugins("codex"), + machineRepo, + freshNativeSources("codex", activator, new Map([["/built/codex", "real-catalog"]])) + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.activated).toEqual(["codex"]); + expect(activator.enabledPlugins).toEqual([ref]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ref]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: otherRef, dependents: ["/B"] }, + { ref, dependents: ["/B", "/resolved-codex-project"] }, + ]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + otherRef, + ]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual([ + older, + { + alias: marketplaceName, + hostName: "real-catalog", + provenance: { + kind: "effective-list", + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + }, + ]); + + await sync.execute({ projectRoot }); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: otherRef, dependents: ["/B"] }, + { ref, dependents: ["/B", "/resolved-codex-project"] }, + ]); + }); + + it("preserves another machine-owned marketplace claim during a narrowed sync", async () => { + const projectRoot = "/codex-project"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + for (const alias of ["market-a", "market-b"]) { + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + `plugin-${alias.at(-1)}`, + "1.0.0", + { kind: "local", path: `/source/${alias}` }, + true, + "project", + alias + ) + ); + } + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + for (const alias of ["market-a", "market-b"]) { + await registry.save( + projectRoot, + Marketplace.create({ + name: alias, + source: { kind: "local", path: `/source/${alias}` }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + } + const fs = new InMemoryFileAdapter({ + "/built/market-a/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-a", + version: "1.0.0", + plugins: [{ name: "plugin-a" }], + }), + "/built/market-b/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-b", + version: "1.0.0", + plugins: [{ name: "plugin-b" }], + }), + }); + const activator = new FakeNativePluginActivator({ available: true }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + { + execute: async ({ marketplace }) => ({ + builtDir: `/built/${marketplace.name}`, + version: "1.0.0", + rebuilt: true, + }), + }, + new Map(), + () => "", + undefined, + undefined, + undefined, + readablePlugins("codex"), + machineRepo, + freshNativeSources( + "codex", + activator, + new Map([ + ["/built/market-a", "real-a"], + ["/built/market-b", "real-b"], + ]) + ) + ); + + await sync.execute({ projectRoot, marketplaceNames: ["market-b"] }); + await sync.execute({ projectRoot, marketplaceNames: ["market-a"] }); + + expect(activator.enabledPlugins).toEqual(["plugin-b@real-b", "plugin-a@real-a"]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + "plugin-b@real-b", + "plugin-a@real-a", + ]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: "plugin-b@real-b", dependents: [projectRoot] }, + { ref: "plugin-a@real-a", dependents: [projectRoot] }, + ]); + }); + + it.each([ + { name: "binary unavailable", available: false, failOnPlugins: [] }, + { name: "host enable refused", available: true, failOnPlugins: ["test-plugin@real-catalog"] }, + ])("does not add a project dependency after $name", async (failure) => { + const projectRoot = "/B"; + const alias = "local-catalog"; + const ref = "test-plugin@real-catalog"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + alias + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias, hostName: "real-catalog" }], + pluginRefs: [], + pluginClaims: [{ ref, dependents: ["/A"] }], + }); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator(failure); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-catalog", + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readablePlugins("codex"), + machineRepo, + freshNativeSources("codex", activator, new Map([["/built/codex", "real-catalog"]])) + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.errors).toEqual([]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs ?? []).toEqual([]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref, dependents: ["/A"] }, + ]); + if (failure.available) { + expect(result.warnings).toEqual([expect.stringContaining("enable plugin")]); + expect(activator.enabledPlugins).toEqual([]); + } else { + expect(result.binaryMissing).toEqual([{ toolId: "codex", binary: "codex" }]); + expect(activator.addedMarketplaces).toEqual([]); + } + }); + + it("does not enable or claim a global ref when the host plugin registry cannot be read", async () => { + const projectRoot = "/B"; + const alias = "local-catalog"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + alias + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-catalog", + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + const hostReader: HostPluginRegistryReader = { + read: async () => { + throw new Error("host plugin registry unreadable"); + }, + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([["codex", hostReader]]), + machineRepo + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.errors).toEqual([{ scope: "codex", message: "host plugin registry unreadable" }]); + expect(activator.enabledPlugins).toEqual([]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs ?? []).toEqual([]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims ?? []).toEqual( + [] + ); + }); + + it("does not attach to a foreign enabled host ref just because another catalog has the same plugin name", async () => { + const projectRoot = "/B"; + const alias = "local-catalog"; + const requestedRef = "test-plugin@real-catalog"; + const otherRef = "test-plugin@other-catalog"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + alias + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: "other", hostName: "other-catalog" }], + pluginRefs: [], + pluginClaims: [{ ref: otherRef, dependents: ["/A"] }], + }); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new FakeNativePluginActivator({ available: true }); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "real-catalog", + version: "1.0.0", + plugins: [{ name: "test-plugin" }], + }), + }); + const hostReader: HostPluginRegistryReader = { + read: async () => ({ + location: "/host/registry", + refs: new Map([[requestedRef, { enabled: true }]]), + }), + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([["codex", hostReader]]), + machineRepo + ); + + const result = await sync.execute({ projectRoot }); + + expect(result.errors).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: otherRef, dependents: ["/A"] }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts index db26b9401..c9fdcf35f 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-narrowing.integration.test.ts @@ -11,6 +11,8 @@ import { Manifest } from "../../../../../src/contexts/framework/domain/manifest. import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -100,7 +102,38 @@ function build(activator: FakeNativePluginActivator) { hasher, new CapturingLogger(), new Map([["claude", activator]]), - ensureBuiltPerMarketplace() + ensureBuiltPerMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/host/installed_plugins.json", + refs: new Map(), + }), + ], + ]), + undefined, + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => + path === "/built/market-a" + ? "market-a" + : path === "/built/market-b" + ? "market-b" + : undefined, + new Map() + ), + ], + ]) ), }; } @@ -131,7 +164,13 @@ describe("marketplaceNames narrows a sync run to the marketplaces named", () => const reloaded = await manifestRepo.load(); const registrations = reloaded?.getNativeRegistrations("claude"); expect(registrations?.marketplaces).toEqual( - expect.arrayContaining([{ alias: "market-a", hostName: "market-a" }]) + expect.arrayContaining([ + { + alias: "market-a", + hostName: "market-a", + provenance: { kind: "registry", source: "/built/market-a" }, + }, + ]) ); expect(registrations?.pluginRefs).toEqual(expect.arrayContaining(["plugin-a@market-a"])); }); @@ -195,7 +234,13 @@ describe("marketplaceNames narrows a sync run to the marketplaces named", () => expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ binary: "claude", - marketplaces: [{ alias: "market-b", hostName: "market-b" }], + marketplaces: [ + { + alias: "market-b", + hostName: "market-b", + provenance: { kind: "registry", source: "/built/market-b" }, + }, + ], pluginRefs: ["plugin-b@market-b"], }); }); @@ -206,8 +251,16 @@ describe("marketplaceNames narrows a sync run to the marketplaces named", () => manifest.setNativeRegistrations("claude", { binary: "claude", marketplaces: [ - { alias: "market-a", hostName: "market-a" }, - { alias: "market-b", hostName: "market-b" }, + { + alias: "market-a", + hostName: "market-a", + provenance: { kind: "registry", source: "/built/market-a" }, + }, + { + alias: "market-b", + hostName: "market-b", + provenance: { kind: "registry", source: "/built/market-b" }, + }, ], pluginRefs: ["plugin-a@market-a", "plugin-b-old@market-b"], }); @@ -223,8 +276,16 @@ describe("marketplaceNames narrows a sync run to the marketplaces named", () => expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ binary: "claude", marketplaces: [ - { alias: "market-a", hostName: "market-a" }, - { alias: "market-b", hostName: "market-b" }, + { + alias: "market-a", + hostName: "market-a", + provenance: { kind: "registry", source: "/built/market-a" }, + }, + { + alias: "market-b", + hostName: "market-b", + provenance: { kind: "registry", source: "/built/market-b" }, + }, ], pluginRefs: ["plugin-a@market-a", "plugin-b@market-b"], }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts index 44e3ca375..9bedabcc6 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-activation.integration.test.ts @@ -12,9 +12,12 @@ import type { HostPluginRegistryReader, HostPluginRegistryReading, } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import { builtMarketplaceDir, userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -63,6 +66,48 @@ function seededBuiltCatalog(): InMemoryFileAdapter { }); } +function readableHostPlugins(refs: readonly string[] = []) { + return new Map([ + [ + "claude" as const, + new FakeHostPluginRegistryReader({ + location: "/home/dev/.claude/plugins/installed_plugins.json", + refs: new Map(refs.map((ref) => [ref, { enabled: true }])), + }), + ], + ]); +} + +function nativeSource( + activator: FakeNativePluginActivator, + name = MARKETPLACE, + initial: ReadonlyMap = new Map() +) { + return new Map([ + [ + "claude" as const, + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === "/built/claude" ? name : undefined), + initial + ), + ], + ]); +} + +function ownedMachineCatalog(hostName: string, source: string): InMemoryManifestRepository { + const machine = Manifest.create(); + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: MARKETPLACE, hostName, provenance: { kind: "registry", source } }], + pluginRefs: [], + pluginClaims: [], + }); + return new InMemoryManifestRepository(machine); +} + function buildSync(activator: FakeNativePluginActivator, pluginMarketplace?: string) { const registry = new InMemoryMarketplaceRegistry(); const fs = seededBuiltCatalog(); @@ -80,7 +125,15 @@ function buildSync(activator: FakeNativePluginActivator, pluginMarketplace?: str hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + nativeSource(activator) ), }; } @@ -190,7 +243,13 @@ describe("nativeRegistrations reflects what the host's own CLI was asked to regi const reloaded = await manifestRepo.load(); expect(reloaded?.getNativeRegistrations("claude")).toEqual({ binary: "claude", - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], pluginRefs: [REF], }); }); @@ -212,7 +271,13 @@ describe("nativeRegistrations reflects what the host's own CLI was asked to regi const staleManifest = await manifestRepo.load(); staleManifest?.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], pluginRefs: [REF], }); if (staleManifest) await manifestRepo.save(staleManifest); @@ -224,7 +289,13 @@ describe("nativeRegistrations reflects what the host's own CLI was asked to regi const reloaded = await manifestRepo.load(); expect(reloaded?.getNativeRegistrations("claude")).toEqual({ binary: "claude", - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], pluginRefs: [REF], }); }); @@ -251,7 +322,15 @@ describe("nativeRegistrations reflects what the host's own CLI was asked to regi hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + nativeSource(activator, CATALOG_NAME) ); await registry.save(PROJECT_ROOT, marketplace()); @@ -262,7 +341,13 @@ describe("nativeRegistrations reflects what the host's own CLI was asked to regi const reloaded = await manifestRepo.load(); expect(reloaded?.getNativeRegistrations("claude")).toEqual({ binary: "claude", - marketplaces: [{ alias: MARKETPLACE, hostName: CATALOG_NAME }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: CATALOG_NAME, + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], pluginRefs: [`${PLUGIN}@${CATALOG_NAME}`], }); }); @@ -286,7 +371,15 @@ describe("registering a marketplace does not wait for a plugin to point at it", hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + nativeSource(activator) ); await registry.save(PROJECT_ROOT, marketplace()); @@ -313,7 +406,15 @@ describe("what native activation leaves behind is not reported as the user's dri hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + nativeSource(activator) ); await registry.save(PROJECT_ROOT, marketplace()); @@ -394,7 +495,36 @@ describe("reclaiming a dead registration asks and acts on the host's own name", hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([ + [CATALOG_NAME, builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude")], + ]), + }), + }, + ], + ]), + () => "", + undefined, + undefined, + undefined, + new Map([["claude", { read: async () => hostReadingOf([]) }]]), + ownedMachineCatalog(CATALOG_NAME, builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude")), + nativeSource( + activator, + CATALOG_NAME, + new Map([ + [ + CATALOG_NAME, + { kind: "registry", source: builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude") }, + ], + ]) + ) ); await registry.save(PROJECT_ROOT, marketplace()); @@ -509,7 +639,15 @@ describe("an unnarrowed run replaces the whole recorded entry (lot 9 review C-B1 hasher, new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + nativeSource(activator, LIVE_MARKETPLACE) ); return { useCase, registry, manifestRepo }; } @@ -547,10 +685,10 @@ describe("a narrowed run preserves another alias's refs at a shared hostName (lo const PLUGIN_X = "plugin-x"; const PLUGIN_Y = "plugin-y"; - function ensureBuiltKeyedByMarketplace(): EnsureBuiltMarketplace { + function ensureBuiltSharedCatalog(): EnsureBuiltMarketplace { return { - execute: async (options) => ({ - builtDir: `/built/by-alias/${options.marketplace.name}`, + execute: async () => ({ + builtDir: "/built/by-alias/shared", version: "test", rebuilt: true, }), @@ -570,15 +708,10 @@ describe("a narrowed run preserves another alias's refs at a shared hostName (lo const activator = new FakeNativePluginActivator({ available: true }); const registry = new InMemoryMarketplaceRegistry(); const fs = new InMemoryFileAdapter({ - [`/built/by-alias/${ALIAS_X}/.claude-plugin/marketplace.json`]: JSON.stringify({ + "/built/by-alias/shared/.claude-plugin/marketplace.json": JSON.stringify({ name: SHARED_HOST_NAME, version: "1.0.0", - plugins: [{ name: PLUGIN_X }], - }), - [`/built/by-alias/${ALIAS_Y}/.claude-plugin/marketplace.json`]: JSON.stringify({ - name: SHARED_HOST_NAME, - version: "1.0.0", - plugins: [{ name: PLUGIN_Y }], + plugins: [{ name: PLUGIN_X }, { name: PLUGIN_Y }], }), }); const manifest = Manifest.create(); @@ -614,7 +747,25 @@ describe("a narrowed run preserves another alias's refs at a shared hostName (lo hasher, new CapturingLogger(), new Map([["claude", activator]]), - ensureBuiltKeyedByMarketplace() + ensureBuiltSharedCatalog(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === "/built/by-alias/shared" ? SHARED_HOST_NAME : undefined), + new Map() + ), + ], + ]) ); await registry.save(PROJECT_ROOT, aliasMarketplace(ALIAS_X)); await registry.save(PROJECT_ROOT, aliasMarketplace(ALIAS_Y)); @@ -629,7 +780,11 @@ describe("a narrowed run preserves another alias's refs at a shared hostName (lo const recorded = (await manifestRepo.load())?.getNativeRegistrations("claude"); expect(recorded?.pluginRefs).toContain(`${PLUGIN_Y}@${SHARED_HOST_NAME}`); - expect(recorded?.marketplaces).toContainEqual({ alias: ALIAS_Y, hostName: SHARED_HOST_NAME }); + expect(recorded?.marketplaces).toContainEqual({ + alias: ALIAS_Y, + hostName: SHARED_HOST_NAME, + provenance: { kind: "registry", source: "/built/by-alias/shared" }, + }); }); }); @@ -638,14 +793,25 @@ describe("what reclaiming a dead registration says", () => { const RECLAIM = "Marketplace 'aidd-framework-catalog' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back."; - function reclaimSync(activator: FakeNativePluginActivator) { + function reclaimSync( + activator: FakeNativePluginActivator, + hostSource = builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude"), + userRoot = "" + ) { const registry = new InMemoryMarketplaceRegistry(); - const fs = new InMemoryFileAdapter({ + class UserRootThatCannotResolve extends InMemoryFileAdapter { + override async realpath(path: string): Promise { + if (userRoot !== "" && path === userRoot) throw new Error("ENOENT: user cache is absent"); + return super.realpath(path); + } + } + const fs = new UserRootThatCannotResolve({ "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ name: CATALOG_NAME, version: "1.0.0", plugins: [], }), + "/foreign/cache/marker": "foreign bytes", }); const logger = new CapturingLogger(); const useCase = new MarketplaceSyncSettingsUseCase( @@ -655,11 +821,92 @@ describe("what reclaiming a dead registration says", () => { new DeterministicHasher(), logger, new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([[CATALOG_NAME, hostSource]]), + }), + }, + ], + ]), + () => userRoot, + undefined, + undefined, + undefined, + new Map([["claude", { read: async () => hostReadingOf([]) }]]), + ownedMachineCatalog( + CATALOG_NAME, + hostSource === "/foreign/cache" + ? builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude") + : hostSource + ), + nativeSource( + activator, + CATALOG_NAME, + new Map([[CATALOG_NAME, { kind: "registry", source: hostSource }]]) + ) ); - return { useCase, registry, logger }; + return { useCase, registry, logger, fs }; } + it("leaves a foreign host source and its cache untouched even with a past AIDD claim", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + }); + const { useCase, registry, fs } = reclaimSync(activator, "/foreign/cache"); + await registry.save(PROJECT_ROOT, marketplace()); + + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.warnings.join("\n")).toContain( + "current host source differs from AIDD's recorded source" + ); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(await fs.readFile("/foreign/cache/marker")).toBe("foreign bytes"); + }); + + it("does not force-remove a live registration even when its source is proven", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "live", + }); + const { useCase, registry } = reclaimSync(activator); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + }); + + it("reclaims a dead registration from an exact AIDD shared user-cache source", async () => { + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + }); + const userRoot = "/home/aidd"; + const source = userBuiltMarketplaceDir(userRoot, "1.0.0", MARKETPLACE, "claude"); + const { useCase, registry } = reclaimSync(activator, source, userRoot); + await registry.save(PROJECT_ROOT, marketplace()); + + await useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([CATALOG_NAME]); + expect(activator.forcedRemovals).toEqual([true]); + expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + }); + it("names the vanished directory's own registration in the one warning it gives", async () => { const activator = new FakeNativePluginActivator({ available: true, @@ -690,8 +937,8 @@ describe("what reclaiming a dead registration says", () => { expect(result.warnings).toStrictEqual([ RECLAIM, "Native plugin activation — unregister stale marketplace 'aidd-framework-catalog' skipped: marketplace remove aidd-framework-catalog failed: 'aidd-framework-catalog' is not configured or installed", - "Native plugin activation — register marketplace 'aidd-framework-catalog' skipped: marketplace is already added from a different source; remove it before adding this source", ]); + expect(activator.addedMarketplaces).toEqual([]); expect(result.errors).toStrictEqual([]); }); }); @@ -705,11 +952,28 @@ function hostReadingOf(refs: readonly string[], enabled = true): HostPluginRegis function buildSyncReadingHost( activator: FakeNativePluginActivator, - hostRegistry: HostPluginRegistryReader + hostRegistry: HostPluginRegistryReader, + catalogOwned = false ) { const registry = new InMemoryMarketplaceRegistry(); const manifestRepo = manifestWithPlugin(); const logger = new CapturingLogger(); + const machine = catalogOwned ? Manifest.create() : undefined; + if (machine !== undefined) { + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], + pluginRefs: [], + pluginClaims: [], + }); + } const useCase = new MarketplaceSyncSettingsUseCase( seededBuiltCatalog(), manifestRepo, @@ -723,7 +987,15 @@ function buildSyncReadingHost( undefined, undefined, undefined, - new Map([["claude", hostRegistry]]) + new Map([["claude", hostRegistry]]), + machine === undefined ? undefined : new InMemoryManifestRepository(machine), + nativeSource( + activator, + MARKETPLACE, + catalogOwned + ? new Map([[MARKETPLACE, { kind: "registry", source: "/built/claude" }]]) + : new Map() + ) ); return { useCase, registry, manifestRepo, logger }; } @@ -736,19 +1008,25 @@ describe("a ref the host had enabled before this project asked belongs to the pe }); await registry.save(PROJECT_ROOT, marketplace()); - await useCase.execute({ projectRoot: PROJECT_ROOT }); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); expect(activator.enabledPlugins).toEqual([]); - expect(logger.infoMessages.join("\n")).toContain(REF); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.join("\n")).toContain(`foreign host ref '${REF}'`); + expect(logger.warnMessages).toEqual(result.warnings); const recorded = (await manifestRepo.load())?.getNativeRegistrations("claude"); expect(recorded?.pluginRefs).toEqual([]); }); it("stays this project's own on the next sync, once the host reports the enable it made", async () => { const activator = new FakeNativePluginActivator({ available: true }); - const { useCase, registry, manifestRepo } = buildSyncReadingHost(activator, { - read: async () => hostReadingOf(activator.enabledPlugins), - }); + const { useCase, registry, manifestRepo } = buildSyncReadingHost( + activator, + { + read: async () => hostReadingOf(activator.enabledPlugins), + }, + true + ); await registry.save(PROJECT_ROOT, marketplace()); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -764,17 +1042,21 @@ describe("a ref the host had enabled before this project asked belongs to the pe { location: "/home/dev/.claude/plugins/installed_plugins.json", unreadable: "ENOENT" }, ], ["lists it disabled", hostReadingOf([REF], false)], - ])("is this project's to enable when the host registry %s", async (_case, reading) => { + ])("refuses to claim a ref when the host registry %s", async (_case, reading) => { const activator = new FakeNativePluginActivator({ available: true }); const { useCase, registry, manifestRepo } = buildSyncReadingHost(activator, { read: async () => reading, }); await registry.save(PROJECT_ROOT, marketplace()); - await useCase.execute({ projectRoot: PROJECT_ROOT }); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(activator.enabledPlugins).toEqual([REF]); + expect(activator.enabledPlugins).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.some((warning) => warning.includes("registration left untouched"))).toBe( + true + ); const recorded = (await manifestRepo.load())?.getNativeRegistrations("claude"); - expect(recorded?.pluginRefs).toEqual([REF]); + expect(recorded?.pluginRefs).toEqual([]); }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-native-provenance.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-provenance.integration.test.ts new file mode 100644 index 000000000..705691de2 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-native-provenance.integration.test.ts @@ -0,0 +1,549 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import type { AiToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const PROJECT_ROOT = "/project-a"; +const CATALOG = "aidd-framework"; +const PLUGIN_REF = `aidd-dev@${CATALOG}`; +const FOREIGN_REF = `someone-elses-plugin@${CATALOG}`; +const CACHE_WITNESS = "/fake-home/host-cache/aidd-framework/foreign-plugin/bytes"; +const HOST_CATALOG_WITNESS = "/fake-home/host-catalog/aidd-framework/marketplace.json"; +const FOREIGN_CATALOG_BYTES = '{"name":"aidd-framework","source":"foreign-repo","marker":7}'; + +class NamedActivator extends FakeNativePluginActivator { + readonly refreshedCatalogs: string[] = []; + + override upgradeMarketplaces(name?: string): void { + this.refreshedCatalogs.push(name ?? ""); + super.upgradeMarketplaces(); + } +} + +function makeSync( + toolId: "codex" | "copilot", + options: { + machine?: Manifest; + hostRefs?: ReadonlyMap; + conflictOnAdd?: boolean; + projectNativePluginRefs?: readonly string[]; + hostReader?: HostPluginRegistryReader; + sourceReader?: NativeMarketplaceSourceReader; + } = {} +) { + const builtDir = `/built/${toolId}`; + const catalogPath = + toolId === "codex" + ? `${builtDir}/.agents/plugins/marketplace.json` + : `${builtDir}/.plugin/marketplace.json`; + const hostCatalogBytes = options.machine + ? '{"name":"aidd-framework","source":"aidd-owned","marker":7}' + : FOREIGN_CATALOG_BYTES; + const fs = new InMemoryFileAdapter({ + [catalogPath]: JSON.stringify({ + name: CATALOG, + version: "1.0.0", + plugins: [{ name: "aidd-dev" }], + }), + [CACHE_WITNESS]: "foreign bytes stay byte-for-byte", + [HOST_CATALOG_WITNESS]: hostCatalogBytes, + }); + const project = Manifest.create(); + project.addTool(toolId, "1.0.0", []); + project.addPlugin( + toolId, + InstalledPlugin.fromMetadata( + "aidd-dev", + "1.0.0", + { kind: "local", path: "/framework" }, + true, + "project", + CATALOG + ) + ); + if (options.projectNativePluginRefs !== undefined) { + project.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [], + pluginRefs: options.projectNativePluginRefs, + }); + } + const projectRepo = new InMemoryManifestRepository(project, PROJECT_ROOT); + const machineRepo = new InMemoryManifestRepository(options.machine ?? null); + const registry = new InMemoryMarketplaceRegistry(); + registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: CATALOG, + source: { kind: "local", path: "/framework" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const activator = new NamedActivator({ + available: true, + conflictOnAdd: options.conflictOnAdd ?? false, + }); + const logger = new CapturingLogger(); + const hostRefs = options.hostRefs ?? new Map(); + const currentSource = options.machine + ? builtDir + : options.conflictOnAdd === true || hostRefs.has(FOREIGN_REF) + ? "/fake-home/host-catalog/aidd-framework" + : undefined; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + logger, + new Map([[toolId, activator]]), + fakeEnsureBuiltMarketplace(() => builtDir), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + toolId as AiToolId, + options.hostReader ?? + new FakeHostPluginRegistryReader({ + location: `/fake-home/${toolId}/registry`, + refs: hostRefs, + }), + ], + ]), + machineRepo, + new Map([ + [ + toolId as AiToolId, + options.sourceReader ?? + new FakeNativeMarketplaceSourceReader( + activator, + "effective-list", + (path) => (path === builtDir ? CATALOG : undefined), + currentSource === undefined + ? new Map() + : new Map([ + [ + CATALOG, + { + kind: "effective-list", + root: currentSource, + sourceType: "local", + source: currentSource, + }, + ], + ]) + ), + ], + ]) + ); + return { useCase, fs, projectRepo, machineRepo, registry, activator, logger, hostCatalogBytes }; +} + +function canonicallyOwned(toolId: "codex" | "copilot"): Manifest { + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [ + { + alias: CATALOG, + hostName: CATALOG, + provenance: { + kind: "effective-list", + root: `/built/${toolId}`, + sourceType: "local", + source: `/built/${toolId}`, + }, + }, + ], + pluginRefs: [], + pluginClaims: [{ ref: PLUGIN_REF, dependents: [PROJECT_ROOT] }], + }); + return machine; +} + +describe.each(["codex", "copilot"] as const)("%s native marketplace provenance", (toolId) => { + it.each([ + "recovered proof", + "recovered proof with a shared host alias", + "foreign source", + "missing source", + "foreign ref", + "unreadable refs", + ] as const)( + "preserves existing projections after an initial source-read refusal followed by %s", + async (change) => { + const recovered = change.startsWith("recovered proof"); + const sharedAlias = change === "recovered proof with a shared host alias"; + const machine = canonicallyOwned(toolId); + const target = machine.getNativeRegistrations(toolId)?.marketplaces[0]; + if (target === undefined) throw new Error("fixture lacks canonical catalogue"); + const otherRef = sharedAlias ? `sibling-plugin@${CATALOG}` : "aidd-dev@other-catalog"; + const staleRef = `obsolete-plugin@${CATALOG}`; + const otherRoot = sharedAlias ? `/built/${toolId}` : "/other"; + const other = { + alias: "other-alias", + hostName: sharedAlias ? CATALOG : "other-catalog", + provenance: { + kind: "effective-list" as const, + root: otherRoot, + sourceType: "local", + source: otherRoot, + }, + }; + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [other, target], + pluginRefs: [otherRef], + pluginClaims: [ + { ref: otherRef, dependents: ["/B"] }, + { ref: PLUGIN_REF, dependents: ["/B"] }, + ], + }); + let sourceReads = 0; + const sourceReader: NativeMarketplaceSourceReader = { + read: async () => { + sourceReads += 1; + if (sourceReads === 1) + return { location: "host catalogue list", unreadable: "transient permissions error" }; + if (change === "missing source") + return { location: "host catalogue list", entries: new Map() }; + const root = change === "foreign source" ? "/foreign/catalogue" : `/built/${toolId}`; + return { + location: "host catalogue list", + entries: new Map([ + [CATALOG, { kind: "effective-list", root, sourceType: "local", source: root }], + ]), + }; + }, + }; + let registryReads = 0; + const hostReader: HostPluginRegistryReader = { + read: async () => { + registryReads += 1; + if (registryReads > 1 && change === "unreadable refs") + return { location: "host plugin registry", unreadable: "permission denied" }; + const refs = new Map([ + [PLUGIN_REF, { enabled: true }], + [otherRef, { enabled: true }], + ]); + if (registryReads > 1 && change === "foreign ref") + refs.set(FOREIGN_REF, { enabled: true }); + return { location: "host plugin registry", refs }; + }, + }; + const f = makeSync(toolId, { machine, sourceReader, hostReader }); + if (sharedAlias) { + await f.registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: other.alias, + source: { kind: "local", path: "/framework" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + f.projectRepo + .getCurrent() + ?.addPlugin( + toolId, + InstalledPlugin.fromMetadata( + "sibling-plugin", + "1.0.0", + { kind: "local", path: "/framework" }, + true, + "project", + other.alias + ) + ); + } + f.projectRepo.getCurrent()?.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [other, target], + pluginRefs: [otherRef, PLUGIN_REF, staleRef], + }); + + const result = await f.useCase.execute({ + projectRoot: PROJECT_ROOT, + marketplaceNames: [CATALOG], + }); + + expect(result.errors).toEqual([]); + expect(result.warnings.join(" ")).toContain("transient permissions error"); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.activator.refreshedCatalogs).toEqual(recovered ? [CATALOG] : []); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations(toolId)).toEqual({ + binary: toolId, + marketplaces: [other, target], + pluginRefs: + recovered && !sharedAlias ? [otherRef, PLUGIN_REF] : [otherRef, PLUGIN_REF, staleRef], + }); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations(toolId)).toEqual({ + binary: toolId, + marketplaces: [other, target], + pluginRefs: [otherRef], + pluginClaims: [ + { ref: otherRef, dependents: ["/B"] }, + { + ref: PLUGIN_REF, + dependents: recovered ? ["/B", PROJECT_ROOT] : ["/B"], + }, + ], + }); + expect(f.machineRepo.saveCount).toBe(recovered ? 1 : 0); + expect(f.fs.getFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(f.fs.getFile(HOST_CATALOG_WITNESS)).toBe(f.hostCatalogBytes); + } + ); + + it("leaves a foreign same-name catalogue, ref, and cache untouched", async () => { + const fixture = makeSync(toolId, { + conflictOnAdd: true, + hostRefs: new Map([[FOREIGN_REF, { enabled: true }]]), + }); + + const result = await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(fixture.activator.removedMarketplaces).toEqual([]); + expect(fixture.activator.addedMarketplaces).toEqual([]); + expect(fixture.activator.enabledPlugins).toEqual([]); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(await fixture.fs.readFile(HOST_CATALOG_WITNESS)).toBe(FOREIGN_CATALOG_BYTES); + expect(fixture.machineRepo.getCurrent()).toBeNull(); + expect(result.warnings.join("\n")).toContain(CATALOG); + }); + + it("does not even re-add a same-name catalogue carrying a foreign ref", async () => { + const fixture = makeSync(toolId, { + hostRefs: new Map([[FOREIGN_REF, { enabled: true }]]), + }); + + const result = await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.addedMarketplaces).toEqual([]); + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(fixture.activator.enabledPlugins).toEqual([]); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(await fixture.fs.readFile(HOST_CATALOG_WITNESS)).toBe(FOREIGN_CATALOG_BYTES); + expect(result.warnings.join("\n")).toContain("legacy claim has no source proof"); + }); + + it("refreshes only an exact canonically owned catalogue without foreign refs", async () => { + const fixture = makeSync(toolId, { machine: canonicallyOwned(toolId) }); + + await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.refreshedCatalogs).toEqual([CATALOG]); + expect(fixture.activator.enabledPlugins).toEqual([PLUGIN_REF]); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(await fixture.fs.readFile(HOST_CATALOG_WITNESS)).toBe(fixture.hostCatalogBytes); + }); + + it("attaches A to the exact already-enabled machine claim without re-enabling or changing B's other claim", async () => { + const machine = canonicallyOwned(toolId); + const otherRef = "aidd-dev@other-catalog"; + const targetRegistration = { + alias: CATALOG, + hostName: CATALOG, + provenance: { + kind: "effective-list" as const, + root: `/built/${toolId}`, + sourceType: "local", + source: `/built/${toolId}`, + }, + }; + const otherRegistration = { + alias: "other-alias", + hostName: "other-catalog", + provenance: { + kind: "effective-list" as const, + root: "/other", + sourceType: "local", + source: "/other", + }, + }; + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [otherRegistration, targetRegistration], + pluginRefs: [otherRef], + pluginClaims: [ + { ref: otherRef, dependents: ["/B"] }, + { ref: PLUGIN_REF, dependents: ["/B"] }, + ], + }); + const fixture = makeSync(toolId, { + machine, + hostRefs: new Map([ + [PLUGIN_REF, { enabled: true }], + [otherRef, { enabled: true }], + ["user-plugin@external-catalog", { enabled: true }], + ]), + }); + + const result = await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(result.errors).toStrictEqual([]); + expect(result.warnings).toStrictEqual([]); + expect(fixture.activator.enabledPlugins).toStrictEqual([]); + expect(fixture.activator.addedMarketplaces).toStrictEqual([]); + expect(fixture.activator.removedMarketplaces).toStrictEqual([]); + expect(fixture.activator.refreshedCatalogs).toStrictEqual([CATALOG]); + expect( + fixture.projectRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs + ).toStrictEqual([PLUGIN_REF]); + expect(fixture.machineRepo.getCurrent()?.getNativeRegistrations(toolId)).toStrictEqual({ + binary: toolId, + marketplaces: [otherRegistration, targetRegistration], + pluginRefs: [otherRef], + pluginClaims: [ + { ref: otherRef, dependents: ["/B"] }, + { ref: PLUGIN_REF, dependents: ["/B", PROJECT_ROOT] }, + ], + }); + expect(fixture.fs.getFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(fixture.fs.getFile(HOST_CATALOG_WITNESS)).toBe(fixture.hostCatalogBytes); + }); + + it.each(["foreign source", "unreadable source", "foreign ref", "unreadable refs"] as const)( + "does not refresh or enable after the host changes to %s during sync", + async (change) => { + let sourceReads = 0; + let registryReads = 0; + const sourceReader: NativeMarketplaceSourceReader = { + read: async () => { + sourceReads += 1; + if (sourceReads > 1 && change === "unreadable source") + return { location: "host catalogue list", unreadable: "permission denied" }; + const root = + sourceReads > 1 && change === "foreign source" + ? "/someone-elses/catalogue" + : `/built/${toolId}`; + return { + location: "host catalogue list", + entries: new Map([ + [ + CATALOG, + { + kind: "effective-list" as const, + root, + sourceType: "local", + source: root, + }, + ], + ]), + }; + }, + }; + const hostReader: HostPluginRegistryReader = { + read: async () => { + registryReads += 1; + if (registryReads > 1 && change === "unreadable refs") + return { location: "host plugin registry", unreadable: "permission denied" }; + return { + location: "host plugin registry", + refs: new Map( + registryReads > 1 && change === "foreign ref" + ? [[FOREIGN_REF, { enabled: true }]] + : [] + ), + }; + }, + }; + const fixture = makeSync(toolId, { + machine: canonicallyOwned(toolId), + sourceReader, + hostReader, + }); + + await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.addedMarketplaces).toEqual([]); + expect(fixture.activator.removedMarketplaces).toEqual([]); + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(fixture.activator.enabledPlugins).toEqual([]); + expect(fixture.machineRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual( + [] + ); + expect( + fixture.machineRepo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims ?? [] + ).toEqual([{ ref: PLUGIN_REF, dependents: [PROJECT_ROOT] }]); + expect(await fixture.fs.readFile(HOST_CATALOG_WITNESS)).toBe(fixture.hostCatalogBytes); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + } + ); + + it("refuses refresh when a foreign ref shares an AIDD-owned catalogue name", async () => { + const fixture = makeSync(toolId, { + machine: canonicallyOwned(toolId), + hostRefs: new Map([[FOREIGN_REF, { enabled: true }]]), + }); + + const result = await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(result.warnings.join("\n")).toContain(FOREIGN_REF); + }); + + it("does not treat a project-only native ref as machine ownership", async () => { + const fixture = makeSync(toolId, { + machine: canonicallyOwned(toolId), + projectNativePluginRefs: [FOREIGN_REF], + hostRefs: new Map([[FOREIGN_REF, { enabled: true }]]), + }); + + const result = await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(fixture.activator.addedMarketplaces).toEqual([]); + expect(fixture.activator.enabledPlugins).toEqual([]); + expect(fixture.activator.removedMarketplaces).toEqual([]); + expect(await fixture.fs.readFile(CACHE_WITNESS)).toBe("foreign bytes stay byte-for-byte"); + expect(await fixture.fs.readFile(HOST_CATALOG_WITNESS)).toBe(fixture.hostCatalogBytes); + expect(result.warnings.join("\n")).toContain(FOREIGN_REF); + }); + + it("activates a freshly added AIDD ref without a redundant refresh", async () => { + const fixture = makeSync(toolId); + + await fixture.useCase.execute({ projectRoot: PROJECT_ROOT }); + + expect(fixture.activator.addedMarketplaces).toEqual([`/built/${toolId}`]); + expect(fixture.activator.enabledPlugins).toEqual([PLUGIN_REF]); + expect(fixture.activator.refreshedCatalogs).toEqual([]); + expect(fixture.machineRepo.getCurrent()?.getNativeRegistrations(toolId)?.marketplaces).toEqual([ + { + alias: CATALOG, + hostName: CATALOG, + provenance: { + kind: "effective-list", + root: `/built/${toolId}`, + sourceType: "local", + source: `/built/${toolId}`, + }, + }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-recovery-contract.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-recovery-contract.integration.test.ts new file mode 100644 index 000000000..033b66166 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-recovery-contract.integration.test.ts @@ -0,0 +1,811 @@ +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { UserSourceReferences } from "../../../../../src/contexts/framework/domain/ports/user-source-references.js"; +import type { HostMarketplaceRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-marketplace-registry-reader.js"; +import type { HostPluginRegistryReading } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, +} from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { NativePluginCliError } from "../../../../../src/kernel/errors.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { builtMarketplaceDir, userBuiltMarketplaceDir } from "../../../../../src/kernel/paths.js"; +import type { MarketplaceScope } from "../../../../../src/kernel/scope.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const ROOT = "/project"; +const ALIAS = "team-tools"; +const HOST = "team-catalog"; +const BUILT = "/built/claude"; +const REF = `review@${HOST}`; +const OLD = builtMarketplaceDir(ROOT, ALIAS, "claude"); +const ADD_ERROR = "catalogue already exists"; +const RECLAIM = `Marketplace '${HOST}' was registered to a directory that no longer exists; re-registering it for this project. Plugins installed from it are removed and the ones this CLI manages are put back.`; + +class RecoveringActivator extends FakeNativePluginActivator { + readonly adds: Array<{ source: string; scope: MarketplaceScope | undefined }> = []; + readonly removals: Array<{ name: string; scope: MarketplaceScope | undefined; force: boolean }> = + []; + + constructor(private readonly failure?: "remove" | "retry" | "crash") { + super({ available: true, registrationState: "dead" }); + } + + override addMarketplace(source: string, scope?: MarketplaceScope): void { + this.adds.push({ source, scope }); + if (this.adds.length === 1) throw new NativePluginCliError(ADD_ERROR); + if (this.failure === "retry") throw new NativePluginCliError("retry denied"); + if (this.failure === "crash") throw new Error("retry crashed"); + super.addMarketplace(source, scope); + } + + override removeMarketplace( + name: string, + scope?: MarketplaceScope, + options?: { force?: boolean } + ): void { + this.removals.push({ name, scope, force: options?.force === true }); + if (this.failure === "remove") throw new NativePluginCliError("remove denied"); + super.removeMarketplace(name, scope, options); + } +} + +interface RecoveryOptions { + source?: string; + userRoot?: string; + scope?: MarketplaceScope; + failure?: "remove" | "retry" | "crash"; + state?: "live" | "unknown"; + realpathFails?: readonly string[]; + host?: HostMarketplaceRegistryReader; + plugins?: readonly HostPluginRegistryReading[]; + sourceAfterCollision?: "absent" | "foreign"; + settings?: string; + ownRefs?: readonly string[]; +} + +async function recovery(options: RecoveryOptions = {}) { + const source = options.source ?? OLD; + const scope = options.scope ?? "project"; + const manifest = Manifest.create(); + const hasher = new DeterministicHasher(); + const settings = options.settings ?? "{}"; + manifest.addTool("claude", "1.0.0", [ + new InstallationFile({ + relativePath: ".claude/settings.json", + content: settings, + hash: hasher.hash(settings), + }), + ]); + manifest.addPlugin( + "claude", + InstalledPlugin.fromMetadata( + "review", + "1.0.0", + { kind: "local", path: "/plugins/review" }, + true, + "project", + ALIAS + ) + ); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [{ alias: ALIAS, hostName: HOST, provenance: { kind: "registry", source } }], + pluginRefs: options.ownRefs ?? [], + }); + const repo = new InMemoryManifestRepository(manifest, ROOT); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + ROOT, + Marketplace.create({ + name: ALIAS, + source: { kind: "local", path: "/source" }, + scope, + addedAt: "2026-09-15T00:00:00Z", + }) + ); + class MissingDirectories extends InMemoryFileAdapter { + override async realpath(path: string): Promise { + if (options.realpathFails?.includes(path)) throw new Error(`ENOENT: ${path}`); + return super.realpath(path); + } + } + const fs = new MissingDirectories({ + [`${BUILT}/.claude-plugin/marketplace.json`]: JSON.stringify({ + name: HOST, + plugins: [{ name: "review" }], + }), + ...(options.settings === undefined + ? {} + : { [resolve(ROOT, ".claude/settings.json")]: options.settings }), + }); + const logger = new CapturingLogger(); + const pluginReadings = [...(options.plugins ?? [{ location: "host plugins", refs: new Map() }])]; + const activator = + options.state === undefined + ? new RecoveringActivator(options.failure) + : new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: options.state, + }); + const delegate = new FakeNativeMarketplaceSourceReader( + activator, + "registry", + () => HOST, + new Map([[HOST, { kind: "registry", source }]]) + ); + const native: NativeMarketplaceSourceReader = { + read: async (root) => { + if (activator.removedMarketplaces.length > 0 && activator.addedMarketplaces.length === 0) { + return { location: "host source after removal", entries: new Map() }; + } + if ( + activator instanceof RecoveringActivator && + activator.adds.length > 0 && + activator.addedMarketplaces.length === 0 && + options.sourceAfterCollision !== undefined + ) { + return { + location: "host source after collision", + entries: + options.sourceAfterCollision === "absent" + ? new Map() + : new Map([[HOST, { kind: "registry", source: "/foreign/source" }]]), + }; + } + return delegate.read(root); + }, + }; + const useCase = new MarketplaceSyncSettingsUseCase( + fs, + repo, + registry, + hasher, + logger, + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + new Map([ + [ + "claude", + options.host ?? + new FakeHostMarketplaceRegistryReader({ + location: "host marketplaces", + entries: new Map([[HOST, source]]), + }), + ], + ]), + () => options.userRoot ?? "", + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + { + read: async () => { + const reading = pluginReadings[0]; + if (pluginReadings.length > 1) pluginReadings.shift(); + return reading; + }, + }, + ], + ]), + undefined, + new Map([["claude", native]]) + ); + const result = await useCase.execute({ projectRoot: ROOT }); + return { activator, manifest, repo, logger, fs, result, useCase }; +} + +describe("marketplace sync recovery preserves public ownership and failure outcomes", () => { + it.each(["project", "user"] as const)( + "reclaims an owned dead catalogue at its marketplace's %s scope", + async (scope) => { + const { activator, result, manifest, logger } = await recovery({ scope }); + expect(activator).toBeInstanceOf(RecoveringActivator); + if (!(activator instanceof RecoveringActivator)) + throw new Error("expected recovery activator"); + expect(activator.adds).toEqual([ + { source: BUILT, scope }, + { source: BUILT, scope }, + ]); + expect(activator.removals).toEqual([{ name: HOST, scope, force: true }]); + expect(activator.enabledPlugins).toEqual([REF]); + expect(result).toEqual({ + activated: ["claude"], + binaryMissing: [], + warnings: [RECLAIM], + errors: [], + }); + expect(logger.warnMessages).toEqual([RECLAIM]); + expect(manifest.getNativeRegistrations("claude")).toEqual({ + binary: "claude", + marketplaces: [ + { alias: ALIAS, hostName: HOST, provenance: { kind: "registry", source: BUILT } }, + ], + pluginRefs: [REF], + }); + } + ); + + it("recognizes the original project path even when its directory and project root no longer resolve", async () => { + const { activator, result } = await recovery({ realpathFails: [OLD, ROOT] }); + expect(activator.removedMarketplaces).toEqual([HOST]); + expect(activator.enabledPlugins).toEqual([REF]); + expect(result.warnings).toEqual([RECLAIM]); + expect(result.errors).toEqual([]); + }); + + it("recognizes an owned vanished user cache using the unresolved user root", async () => { + const userRoot = "/users/alice/aidd"; + const source = userBuiltMarketplaceDir(userRoot, "1.0.0", ALIAS, "claude"); + const { activator, result } = await recovery({ + source, + userRoot, + realpathFails: [source, userRoot], + }); + expect(activator.removedMarketplaces).toEqual([HOST]); + expect(activator.enabledPlugins).toEqual([REF]); + expect(result.warnings).toEqual([RECLAIM]); + expect(result.errors).toEqual([]); + }); + + it.each(["live", "unknown"] as const)( + "reports the add refusal for a %s registration without force-removing it", + async (state) => { + const { activator, result } = await recovery({ state }); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings).toEqual([ + `Native plugin activation — register marketplace '${HOST}' skipped: marketplace is already added from a different source; remove it before adding this source`, + ]); + expect(result.errors).toEqual([]); + } + ); + + it.each([ + "/foreign/cache", + builtMarketplaceDir(ROOT, "different-marketplace", "claude"), + builtMarketplaceDir(ROOT, ALIAS, "codex"), + userBuiltMarketplaceDir("/users/alice/aidd", "1.0.0", ALIAS, "claude"), + ])( + "refuses force removal for a source outside this catalogue's recognized cache: %s", + async (source) => { + const { activator, result, manifest } = await recovery({ source }); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([REF]); + expect(result.warnings).toEqual([ + `Native plugin activation — catalogue '${HOST}' host catalogue source is unproven; host registration left untouched. ${ADD_ERROR}`, + ]); + expect(manifest.getNativeRegistrations("claude")?.marketplaces[0]?.provenance).toEqual({ + kind: "registry", + source, + }); + } + ); + + it("keeps a failed remove recoverable and records the still-owned original source", async () => { + const { activator, result, manifest, logger } = await recovery({ failure: "remove" }); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings).toEqual([ + RECLAIM, + `Native plugin activation — unregister stale marketplace '${HOST}' skipped: remove denied`, + ]); + expect(logger.warnMessages).toEqual(result.warnings); + expect(result.errors).toEqual([]); + expect(manifest.getNativeRegistrations("claude")?.marketplaces[0]?.provenance).toEqual({ + kind: "registry", + source: OLD, + }); + }); + + it("reports a failed retry separately from a successful forced remove", async () => { + const { activator, result, logger } = await recovery({ failure: "retry" }); + expect(activator.removedMarketplaces).toEqual([HOST]); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(result.warnings).toEqual([ + RECLAIM, + `Native plugin activation — register marketplace '${HOST}' skipped: retry denied`, + ]); + expect(logger.warnMessages).toEqual(result.warnings); + expect(result.errors).toEqual([]); + }); + + it("returns a retry crash as a hard tool error and preserves earlier manifest ownership", async () => { + const { activator, result, manifest } = await recovery({ failure: "crash" }); + expect(activator.removedMarketplaces).toEqual([HOST]); + expect(activator.enabledPlugins).toEqual([]); + expect(result).toEqual({ + activated: [], + binaryMissing: [], + warnings: [RECLAIM], + errors: [{ scope: "claude", message: "retry crashed" }], + }); + expect(manifest.getNativeRegistrations("claude")?.marketplaces[0]?.provenance).toEqual({ + kind: "registry", + source: OLD, + }); + }); + + it.each(["absent", "foreign"] as const)( + "does not reclaim when the source becomes %s between the failed add and recovery", + async (sourceAfterCollision) => { + const { activator, result, manifest } = await recovery({ sourceAfterCollision }); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(result.warnings).toEqual([ + `Native plugin activation — catalogue '${HOST}' collides with unproven AIDD ownership; host registration left untouched. ${ADD_ERROR}`, + ]); + expect(manifest.getNativeRegistrations("claude")?.marketplaces).toEqual([ + { alias: ALIAS, hostName: HOST, provenance: { kind: "registry", source: OLD } }, + ]); + expect(result.errors).toEqual([]); + } + ); + + it("reports the foreign plugin that appeared after add failed and leaves the host untouched", async () => { + const foreign = `other@${HOST}`; + const { activator, result } = await recovery({ + plugins: [ + { location: "host plugins", refs: new Map() }, + { location: "host plugins", refs: new Map([[foreign, { enabled: true }]]) }, + ], + }); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(result.warnings).toEqual([ + `Native plugin activation — catalogue '${HOST}' collides with unproven AIDD ownership and foreign ref '${foreign}'; host registration left untouched. ${ADD_ERROR}`, + ]); + expect(result.errors).toEqual([]); + }); + + it("refuses recovery when the host marketplace registry disappears after the failed add", async () => { + const owned = { location: "host marketplaces", entries: new Map([[HOST, OLD]]) }; + const { activator, result } = await recovery({ + host: new FakeHostMarketplaceRegistryReader(owned, owned, { + location: "host marketplaces", + absent: true, + }), + }); + expect(activator.removedMarketplaces).toEqual([]); + expect(result.warnings).toEqual([ + `Native plugin activation — catalogue '${HOST}' host catalogue source is unproven; host registration left untouched. ${ADD_ERROR}`, + ]); + expect(result.errors).toEqual([]); + }); + + it("preserves an enabled project-owned ref without a machine manifest or another install", async () => { + const { activator, result, manifest } = await recovery({ + ownRefs: [REF], + plugins: [{ location: "host plugins", refs: new Map([[REF, { enabled: true }]]) }], + }); + expect(activator.removedMarketplaces).toEqual([HOST]); + expect(activator.enabledPlugins).toEqual([]); + expect(manifest.getNativeRegistrations("claude")?.pluginRefs).toEqual([REF]); + expect(result.errors).toEqual([]); + }); + + it("does not resave the manifest after a repeated sync produces identical native ownership and settings", async () => { + const { activator, result, useCase, repo, fs, manifest } = await recovery({ + settings: JSON.stringify({ + enabledPlugins: { [`review@${ALIAS}`]: true }, + permissions: { allow: ["Read"] }, + }), + }); + expect(result.errors).toEqual([]); + const saveCount = repo.saveCount; + const second = await useCase.execute({ projectRoot: ROOT }); + expect(second).toEqual({ activated: ["claude"], binaryMissing: [], warnings: [], errors: [] }); + expect(repo.saveCount).toBe(saveCount); + expect(activator.addedMarketplaces).toEqual([BUILT]); + expect(manifest.getToolFiles("claude")[0]?.hash.value).toBe( + new DeterministicHasher().hash(await fs.readFile(resolve(ROOT, ".claude/settings.json"))) + .value + ); + }); + + it.each(["null", "[]", "false", '"user value"'])( + "rebuilds managed settings from a JSON root that is not a settings object: %s", + async (settings) => { + const { fs, manifest, result, logger } = await recovery({ settings }); + const content = await fs.readFile(resolve(ROOT, ".claude/settings.json")); + expect(JSON.parse(content)).toEqual({ enabledPlugins: { [`review@${ALIAS}`]: true } }); + expect(manifest.getToolFiles("claude")[0]?.hash.value).toBe( + new DeterministicHasher().hash(content).value + ); + expect(logger.warnMessages).toEqual([RECLAIM]); + expect(result.errors).toEqual([]); + } + ); + + it("evicts only the obsolete marketplace key from otherwise empty managed settings", async () => { + const { fs, manifest, result } = await recovery({ + settings: JSON.stringify({ + extraKnownMarketplaces: {}, + permissions: { allow: ["Read"] }, + enabledPlugins: { [`review@${ALIAS}`]: false, "personal@external": true }, + }), + }); + const content = await fs.readFile(resolve(ROOT, ".claude/settings.json")); + expect(JSON.parse(content)).toEqual({ + permissions: { allow: ["Read"] }, + enabledPlugins: { [`review@${ALIAS}`]: false, "personal@external": true }, + }); + expect(manifest.getToolFiles("claude")[0]?.hash.value).toBe( + new DeterministicHasher().hash(content).value + ); + expect(result.errors).toEqual([]); + }); +}); + +async function ownedNativeSettings( + options: { + toolId?: "codex" | "copilot"; + names?: readonly string[]; + settings?: string; + withPlugins?: boolean; + cachedRoot?: string; + cachedCatalogue?: string; + } = {} +) { + const toolId = options.toolId ?? "copilot"; + const names = options.names ?? ["first"]; + const settings = options.settings ?? JSON.stringify({ permissions: { allow: ["Read"] } }); + const settingsPath = ".github/copilot/settings.json"; + const hasher = new DeterministicHasher(); + const project = Manifest.create(); + project.addTool( + toolId, + "1.0.0", + toolId === "copilot" + ? [ + new InstallationFile({ + relativePath: settingsPath, + content: settings, + hash: hasher.hash(settings), + }), + ] + : [] + ); + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + const registrations = names.map((name) => ({ + alias: `${name}-alias`, + hostName: `${name}-catalog`, + provenance: { + kind: "effective-list" as const, + root: + name === (options.cachedCatalogue ?? names[0]) + ? (options.cachedRoot ?? `/built/${toolId}/${name}`) + : `/built/${toolId}/${name}`, + sourceType: "local", + source: `/built/${toolId}/${name}`, + }, + })); + const refs = + options.withPlugins === false + ? [] + : registrations.map( + (registration, index) => `review-${names[index]}@${registration.hostName}` + ); + project.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: registrations, + pluginRefs: refs, + }); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: registrations, + pluginRefs: [], + pluginClaims: refs.map((ref) => ({ ref, dependents: [ROOT] })), + }); + const projectRepo = new InMemoryManifestRepository(project, ROOT); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + const fs = new InMemoryFileAdapter({ [resolve(ROOT, settingsPath)]: settings }); + const identities = new Map(); + const initial = new Map(); + for (const [index, name] of names.entries()) { + const registration = registrations[index]; + const builtDir = `/built/${toolId}/${name}`; + identities.set(builtDir, registration.hostName); + initial.set(registration.hostName, registration.provenance); + fs.setFile( + `${builtDir}/${toolId === "copilot" ? ".plugin" : ".agents/plugins"}/marketplace.json`, + JSON.stringify({ name: registration.hostName, plugins: [{ name: `review-${name}` }] }) + ); + await registry.save( + ROOT, + Marketplace.create({ + name: registration.alias, + source: { kind: "local", path: `/sources/${name}` }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + if (options.withPlugins !== false) + project.addPlugin( + toolId, + InstalledPlugin.fromMetadata( + `review-${name}`, + "1.0.0", + { kind: "local", path: `/sources/${name}/review` }, + true, + "project", + registration.alias + ) + ); + } + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + hasher, + logger, + new Map([[toolId, activator]]), + { + execute: async ({ marketplace }) => ({ + builtDir: `/built/${toolId}/${marketplace.name.replace(/-alias$/, "")}`, + version: "1.0.0", + rebuilt: false, + }), + }, + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + toolId, + { + read: async () => ({ + location: "host plugins", + refs: new Map(refs.map((ref) => [ref, { enabled: true }])), + }), + }, + ], + ]), + machineRepo, + new Map([ + [ + toolId, + new FakeNativeMarketplaceSourceReader( + activator, + "effective-list", + (path) => identities.get(path), + initial + ), + ], + ]) + ); + return { + sync, + fs, + activator, + logger, + projectRepo, + machineRepo, + hasher, + settingsPath, + registrations, + refs, + }; +} + +describe("proven native catalogues update declarative settings only when required", () => { + it("projects enabled refs from two distinct owned Copilot catalogues into the tracked shared settings", async () => { + const f = await ownedNativeSettings({ names: ["first", "second"] }); + const result = await f.sync.execute({ projectRoot: ROOT }); + const content = await f.fs.readFile(resolve(ROOT, f.settingsPath)); + expect(result).toEqual({ activated: ["copilot"], binaryMissing: [], warnings: [], errors: [] }); + expect(JSON.parse(content)).toEqual({ + permissions: { allow: ["Read"] }, + enabledPlugins: { "review-first@first-catalog": true, "review-second@second-catalog": true }, + }); + expect(f.projectRepo.getCurrent()?.getToolFiles("copilot")[0]?.hash.value).toBe( + f.hasher.hash(content).value + ); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("copilot")).toEqual({ + binary: "copilot", + marketplaces: f.registrations, + pluginRefs: f.refs, + }); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.projectRepo.saveCount).toBe(1); + expect(f.machineRepo.saveCount).toBe(0); + }); + + it("persists a missing declarative ref's hash even when native and machine ownership are already unchanged", async () => { + const f = await ownedNativeSettings(); + await f.sync.execute({ projectRoot: ROOT }); + const content = await f.fs.readFile(resolve(ROOT, f.settingsPath)); + expect(JSON.parse(content).enabledPlugins).toEqual({ "review-first@first-catalog": true }); + expect(f.projectRepo.getCurrent()?.getToolFiles("copilot")[0]?.hash.value).toBe( + f.hasher.hash(content).value + ); + expect(f.projectRepo.saveCount).toBe(1); + expect(f.machineRepo.saveCount).toBe(0); + await f.sync.execute({ projectRoot: ROOT }); + expect(f.projectRepo.saveCount).toBe(1); + expect(f.machineRepo.saveCount).toBe(0); + }); + + it("leaves malformed Copilot settings untouched without parsing warnings when no native refs are proven", async () => { + const settings = "{ personalSetting: true,"; + const f = await ownedNativeSettings({ withPlugins: false, settings }); + const result = await f.sync.execute({ projectRoot: ROOT }); + expect(result).toEqual({ activated: ["copilot"], binaryMissing: [], warnings: [], errors: [] }); + expect(await f.fs.readFile(resolve(ROOT, f.settingsPath))).toBe(settings); + expect(f.logger.warnMessages).toEqual([]); + expect(f.projectRepo.saveCount).toBe(0); + expect(f.machineRepo.saveCount).toBe(0); + }); + + it("re-registers an owned Codex local source whose cached root differs from its original build path", async () => { + const f = await ownedNativeSettings({ + toolId: "codex", + cachedRoot: "/host/cache/copied-marketplace", + }); + const result = await f.sync.execute({ projectRoot: ROOT }); + expect(result).toEqual({ activated: ["codex"], binaryMissing: [], warnings: [], errors: [] }); + expect(f.activator.addedMarketplaces).toEqual(["/built/codex/first"]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect( + f.projectRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces[0]?.provenance + ).toEqual({ + kind: "effective-list", + root: "/built/codex/first", + sourceType: "local", + source: "/built/codex/first", + }); + expect( + f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces[0]?.provenance + ).toEqual({ + kind: "effective-list", + root: "/built/codex/first", + sourceType: "local", + source: "/built/codex/first", + }); + expect(f.machineRepo.saveCount).toBe(1); + }); + + it("persists a second catalogue's updated source while retaining an already identical first catalogue", async () => { + const f = await ownedNativeSettings({ + toolId: "codex", + names: ["first", "second"], + cachedCatalogue: "second", + cachedRoot: "/host/cache/second-copy", + }); + const result = await f.sync.execute({ projectRoot: ROOT }); + expect(result.errors).toEqual([]); + expect(f.activator.addedMarketplaces).toEqual(["/built/codex/second"]); + const expected = f.registrations.map((registration) => ({ + ...registration, + provenance: { ...registration.provenance, root: registration.provenance.source }, + })); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual( + expected + ); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual( + expected + ); + expect(f.projectRepo.saveCount).toBe(1); + expect(f.machineRepo.saveCount).toBe(1); + }); +}); + +describe("shared framework references follow any successful native tool outcome", () => { + it.each(["project", "user"] as const)( + "records references according to the requested %s activation scope despite another tool's refusal", + async (scope) => { + const project = Manifest.create(); + project.addTool("claude", "1.0.0", []); + project.addTool("codex", "1.0.0", []); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "local", path: "/framework" }, + scope: "user", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + "/built/claude/.claude-plugin/marketplace.json": JSON.stringify({ + name: "aidd-framework", + plugins: [], + }), + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "aidd-framework", + plugins: [], + }), + }); + fs.setSymlink(ROOT, "/real/project"); + const claude = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const codex = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const recorded: Array<{ version: string; projectRoot: string }> = []; + const references: UserSourceReferences = { + addReference: async (version, projectRoot) => { + recorded.push({ version, projectRoot }); + }, + removeReference: async () => undefined, + listAllReferencingProjects: async () => [], + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(project), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([ + ["claude", claude], + ["codex", codex], + ]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + references, + { get: () => "2.0.0" }, + new Map([ + ["claude", { read: async () => ({ location: "host plugins", refs: new Map() }) }], + ["codex", { read: async () => ({ location: "host plugins", refs: new Map() }) }], + ]), + undefined, + new Map<"claude" | "codex", NativeMarketplaceSourceReader>([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + claude, + "registry", + () => "aidd-framework", + new Map() + ), + ], + [ + "codex", + { + read: async () => ({ + location: "host sources", + entries: new Map([["aidd-framework", null]]), + }), + }, + ], + ]) + ); + const result = await sync.execute({ projectRoot: ROOT, scope }); + expect(result.activated).toEqual(["claude", "codex"]); + expect(result.errors).toEqual([]); + expect(result.warnings.join(" ")).toContain("host source unproven"); + expect(claude.addedMarketplaces).toEqual(["/built/claude"]); + expect(codex.addedMarketplaces).toEqual([]); + expect(recorded).toEqual( + scope === "project" ? [{ version: "2.0.0", projectRoot: "/real/project" }] : [] + ); + } + ); +}); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts index 5287cc472..f81091f24 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-rollback-refusal.integration.test.ts @@ -13,6 +13,8 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -43,6 +45,22 @@ async function sync(options: { const logger = new CapturingLogger(); const manifest = Manifest.create(); manifest.addTool("claude", "test", []); + const machine = Manifest.create(); + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: name, + hostName: name, + ...(options.registeredPath === undefined + ? {} + : { provenance: { kind: "registry" as const, source: options.registeredPath } }), + }, + ], + pluginRefs: [], + pluginClaims: [], + }); await manifestRepo.save(manifest); await registry.save( PROJECT_ROOT, @@ -80,7 +98,33 @@ async function sync(options: { new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(() => builtDir), new Map([["claude", hostReader]]), - () => USER_CACHE_ROOT + () => USER_CACHE_ROOT, + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + refs: new Map(), + }), + ], + ]), + new InMemoryManifestRepository(machine), + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === builtDir ? name : undefined), + options.registeredPath === undefined + ? new Map() + : new Map([[name, { kind: "registry", source: options.registeredPath }]]) + ), + ], + ]) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT, @@ -139,7 +183,7 @@ describe("the sync write path refuses to roll a host back to an older aidd-frame registeredVersion: "1.0.0", }); - expect(activator.addedMarketplaces).toEqual([sharedPath("1.0.0")]); + expect(activator.addedMarketplaces).toEqual([]); expect(result.errors).toEqual([]); expect(result.warnings).toEqual([]); }); @@ -166,7 +210,13 @@ describe("the sync write path refuses to roll a host back to an older aidd-frame expect((await manifestRepo.load())?.getNativeRegistrations("claude")).toStrictEqual({ binary: "claude", - marketplaces: [{ alias: MARKETPLACE_NAME, hostName: MARKETPLACE_NAME }], + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: MARKETPLACE_NAME, + provenance: { kind: "registry", source: sharedPath("2.0.0") }, + }, + ], pluginRefs: [], }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts index f0885355d..4f7002f24 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-migration.integration.test.ts @@ -27,6 +27,8 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -37,6 +39,48 @@ const CLAUDE_BUILT_DIR = "/shared/built/claude"; const CODEX_BUILT_DIR = "/shared/built/codex"; const CATALOG_RELATIVE = ".claude-plugin/marketplace.json"; +function canonicallyOwned(toolId: "claude" | "codex"): Manifest { + const machine = Manifest.create(); + machine.addTool(toolId, "test", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [{ alias: FRAMEWORK_MARKETPLACE_NAME, hostName: FRAMEWORK_MARKETPLACE_NAME }], + pluginRefs: [], + pluginClaims: [], + }); + return machine; +} + +function readableHostPlugins(toolId: "claude" | "codex") { + return new Map([ + [ + toolId, + new FakeHostPluginRegistryReader({ + location: `/home/${toolId}/plugins`, + refs: new Map(), + }), + ], + ]); +} + +function freshClaudeSource( + activator: FakeNativePluginActivator, + builtDir: string, + name = FRAMEWORK_MARKETPLACE_NAME +) { + return new Map([ + [ + "claude" as const, + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === builtDir ? name : undefined), + new Map() + ), + ], + ]); +} + function fakeVersion(value: string): VersionReader { return { get: () => value }; } @@ -145,7 +189,7 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n }); } - it("reclaims a codex registration that still names the pre-migration path, by removing then re-adding the shared source", async () => { + it("refuses force reclaim for Codex even with an AIDD claim when host source is unreadable", async () => { const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, frameworkAtUserScope()); const manifest = Manifest.create(); @@ -169,16 +213,31 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n new DeterministicHasher(), new CapturingLogger(), new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace(() => CODEX_BUILT_DIR) + fakeEnsureBuiltMarketplace(() => CODEX_BUILT_DIR), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins("codex"), + new InMemoryManifestRepository(canonicallyOwned("codex")), + new Map([ + [ + "codex", + { read: async () => ({ location: "/host/codex/catalogues", unreadable: "EACCES" }) }, + ], + ]) ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(activator.removedMarketplaces).toEqual([FRAMEWORK_MARKETPLACE_NAME]); - expect(activator.addedMarketplaces).toEqual([CODEX_BUILT_DIR]); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.join("\n")).toContain("EACCES"); }); - it("warns with the reclaim message naming the tool that refuses the overwrite", async () => { + it("refuses an unproven reserved-name registration without force reclaim", async () => { const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, frameworkAtUserScope()); const manifest = Manifest.create(); @@ -208,10 +267,12 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - const reclaim = - "Marketplace 'aidd-framework' is registered from a different source and codex refuses to overwrite it in place; removing and re-registering it from the shared, machine-scope build. Plugins installed from it are removed and the ones this CLI manages are put back."; - expect(result.warnings).toStrictEqual([reclaim]); - expect(logger.warnMessages).toStrictEqual([reclaim]); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings).toEqual([ + expect.stringContaining("host source reader unavailable; reconcile manually"), + ]); + expect(logger.warnMessages).toEqual(result.warnings); }); it("never reclaims an arbitrary, non-reserved marketplace name this way — only the framework's own", async () => { @@ -258,7 +319,7 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n // `isUnguardedFrameworkMarketplace` excludes a tool declaring its own marketplace registry // from the reclaim door: a registry conflict is a reported error, never a silent remove-add. - it("never reclaims the reserved framework name for a tool that declares its own marketplace registry — claude reports the conflict instead", async () => { + it("does not force reclaim a reserved name that Claude's registry points at a foreign source", async () => { const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, frameworkAtUserScope()); const manifest = Manifest.create(); @@ -274,6 +335,11 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n version: "1.0.0", plugins: [], }), + [`/foreign/cache/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [{ name: "foreign-plugin" }], + }), }); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -282,7 +348,38 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n new DeterministicHasher(), new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR) + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "/host/claude/known_marketplaces.json", + entries: new Map([[FRAMEWORK_MARKETPLACE_NAME, "/foreign/cache"]]), + }), + ], + ]), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins("claude"), + undefined, + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/host/claude/catalogues", + entries: new Map([ + [ + FRAMEWORK_MARKETPLACE_NAME, + { kind: "registry" as const, source: "/foreign/cache" }, + ], + ]), + }), + }, + ], + ]) // No `hostMarketplaceRegistries` reader for claude: `guardAgainstConflict` returns // "proceed" without reading one, so the exclusion is decided in `reclaimOrReport`. ); @@ -291,7 +388,67 @@ describe("MarketplaceSyncSettingsUseCase — codex and copilot refuse the same n expect(activator.removedMarketplaces).toEqual([]); expect(activator.addedMarketplaces).toEqual([]); - expect(result.warnings.some((w) => w.includes("skipped:"))).toBe(true); + expect(result.errors[0]?.message).toContain("different catalog"); + expect(result.errors[0]?.message).toContain("/foreign/cache"); + }); +}); + +describe("MarketplaceSyncSettingsUseCase — fresh Claude host provenance", () => { + async function syncWithHostRegistry(reading: { absent?: true; unreadable?: string }) { + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, projectScopeEntry({ kind: "local", path: "." })); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter(catalogFixture(CLAUDE_BUILT_DIR)), + new InMemoryManifestRepository(manifest), + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), + new Map([ + [ + "claude", + new FakeHostMarketplaceRegistryReader({ + location: "/home/.claude/plugins/known_marketplaces.json", + ...reading, + }), + ], + ]), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + absent: true, + }), + ], + ]), + undefined, + freshClaudeSource(activator, CLAUDE_BUILT_DIR) + ); + + return { activator, result: await useCase.execute({ projectRoot: PROJECT_ROOT }) }; + } + + it("adds a fresh catalogue when the host registry is explicitly absent", async () => { + const { activator, result } = await syncWithHostRegistry({ absent: true }); + + expect(activator.addedMarketplaces).toEqual([CLAUDE_BUILT_DIR]); + expect(result.warnings).toEqual([]); + }); + + it("does not add when the host registry exists but cannot be read", async () => { + const { activator, result } = await syncWithHostRegistry({ unreadable: "EACCES" }); + + expect(activator.addedMarketplaces).toEqual([]); + expect(result.warnings.join("\n")).toContain("host registry is unreadable"); }); }); @@ -308,7 +465,7 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi ); } - it("registers the shared source without breaking, and records a reference for both this project and the one the host used to point at", async () => { + it("leaves another project's cache and both references untouched without source provenance", async () => { const foreignProjectCache = builtMarketplaceDir( "/other-project", FRAMEWORK_MARKETPLACE_NAME, @@ -358,20 +515,22 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi () => USER_CACHE_ROOT, undefined, noSourceReferences(added), - fakeVersion(CURRENT_VERSION) + fakeVersion(CURRENT_VERSION), + readableHostPlugins("claude"), + new InMemoryManifestRepository(canonicallyOwned("claude")) ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); + const before = await fs.readFile(`${foreignProjectCache}/${CATALOG_RELATIVE}`); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - // The host is repointed onto the shared build without throwing: same catalog, foreign path, - // which `guardAgainstConflict` treats as an ordinary migration. - expect(activator.addedMarketplaces).toEqual([sharedBuiltDir()]); - const roots = added.map((a) => a.projectRoot); - expect(roots).toContain(PROJECT_ROOT); - expect(roots).toContain("/other-project"); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(added).toEqual([]); + expect(await fs.readFile(`${foreignProjectCache}/${CATALOG_RELATIVE}`)).toBe(before); + expect(result.warnings.join("\n")).toContain("host source unproven"); }); - it("never records a reference for the foreign project's own root once that root no longer exists", async () => { + it("does not infer ownership or add references when the old project root is gone", async () => { const foreignProjectCache = builtMarketplaceDir( "/gone-project", FRAMEWORK_MARKETPLACE_NAME, @@ -416,15 +575,18 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi () => USER_CACHE_ROOT, undefined, noSourceReferences(added), - fakeVersion(CURRENT_VERSION) + fakeVersion(CURRENT_VERSION), + readableHostPlugins("claude"), + new InMemoryManifestRepository(canonicallyOwned("claude")) ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); + const before = await fs.readFile(`${sharedBuiltDir()}/${CATALOG_RELATIVE}`); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(activator.addedMarketplaces).toEqual([sharedBuiltDir()]); - const roots = added.map((a) => a.projectRoot); - expect(roots).toContain(PROJECT_ROOT); - expect(roots).not.toContain("/gone-project"); + expect(activator.addedMarketplaces).toEqual([]); + expect(added).toEqual([]); + expect(await fs.readFile(`${sharedBuiltDir()}/${CATALOG_RELATIVE}`)).toBe(before); + expect(result.warnings.join("\n")).toContain("host source unproven"); }); function hostOnForeignCache(): { @@ -471,7 +633,7 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi return { fs, hostReader, registry, manifest, activator }; } - it("still repoints the host when this run has nowhere to record references at all", async () => { + it("does not repoint a legacy host entry when no reference ledger is available", async () => { const { fs, hostReader, registry, manifest, activator } = hostOnForeignCache(); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -482,16 +644,23 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(() => sharedBuiltDir()), new Map([["claude", hostReader]]), - () => USER_CACHE_ROOT + () => USER_CACHE_ROOT, + undefined, + undefined, + undefined, + readableHostPlugins("claude"), + new InMemoryManifestRepository(canonicallyOwned("claude")) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); expect(result.errors).toStrictEqual([]); - expect(activator.addedMarketplaces).toStrictEqual([sharedBuiltDir()]); + expect(activator.addedMarketplaces).toStrictEqual([]); + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(result.warnings.join("\n")).toContain("host source unproven"); }); - it("still repoints the host, recording nothing, when the version to record under is unknown", async () => { + it("does not repoint or record a legacy host entry when the version is unknown", async () => { const { fs, hostReader, registry, manifest, activator } = hostOnForeignCache(); const added: Array<{ version: string; projectRoot: string }> = []; const useCase = new MarketplaceSyncSettingsUseCase( @@ -505,13 +674,18 @@ describe("MarketplaceSyncSettingsUseCase — the host still tracks another, unmi new Map([["claude", hostReader]]), () => USER_CACHE_ROOT, undefined, - noSourceReferences(added) + noSourceReferences(added), + undefined, + readableHostPlugins("claude"), + new InMemoryManifestRepository(canonicallyOwned("claude")) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); expect(result.errors).toStrictEqual([]); - expect(activator.addedMarketplaces).toStrictEqual([sharedBuiltDir()]); + expect(activator.addedMarketplaces).toStrictEqual([]); + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(result.warnings.join("\n")).toContain("host source unproven"); expect(added).toStrictEqual([]); }); }); @@ -737,6 +911,7 @@ describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migr it("purges only after native activation has run against the still-present cache, never before", async () => { class OrderObservingActivator implements NativePluginActivator { readonly cacheStillPresentAtAdd: boolean[] = []; + addedSource?: string; constructor( private readonly fs: InMemoryFileAdapter, private readonly probe: string @@ -747,7 +922,8 @@ describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migr enablesPlugins(): boolean { return false; } - addMarketplace(): void { + addMarketplace(source: string): void { + this.addedSource = source; this.cacheStillPresentAtAdd.push(this.fs.has(this.probe)); } removeMarketplace(): void {} @@ -781,7 +957,31 @@ describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migr fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), new Map(), () => "", - new MarketplaceRegisterFrameworkUseCase(registry) + new MarketplaceRegisterFrameworkUseCase(registry), + undefined, + undefined, + readableHostPlugins("claude"), + undefined, + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/host/claude/catalogues", + entries: new Map( + activator.addedSource === undefined + ? [] + : [ + [ + FRAMEWORK_MARKETPLACE_NAME, + { kind: "registry" as const, source: activator.addedSource }, + ], + ] + ), + }), + }, + ], + ]) ); await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); @@ -920,19 +1120,23 @@ describe("MarketplaceSyncSettingsUseCase — purging this project's own pre-migr ...catalogFixture(CLAUDE_BUILT_DIR), }); fs.setSymlink(OLD_CACHE_DIR, "/etc/evil"); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); const useCase = new MarketplaceSyncSettingsUseCase( fs, new InMemoryManifestRepository(manifestWithClaude()), registry, new DeterministicHasher(), logger, - new Map([ - ["claude", new FakeNativePluginActivator({ available: true, enablesPlugins: false })], - ]), + new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(() => CLAUDE_BUILT_DIR), new Map(), () => "", - new MarketplaceRegisterFrameworkUseCase(registry) + new MarketplaceRegisterFrameworkUseCase(registry), + undefined, + undefined, + readableHostPlugins("claude"), + undefined, + freshClaudeSource(activator, CLAUDE_BUILT_DIR) ); await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); @@ -1199,6 +1403,24 @@ describe("MarketplaceSyncSettingsUseCase + DoctorRegistrationUseCase — the ful version: CURRENT_VERSION, plugins: [], }), + [`${preMigrationCache}/${CATALOG_RELATIVE}`]: JSON.stringify({ + name: FRAMEWORK_MARKETPLACE_NAME, + version: "1.0.0", + plugins: [], + }), + }); + const machine = canonicallyOwned("claude"); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: FRAMEWORK_MARKETPLACE_NAME, + hostName: FRAMEWORK_MARKETPLACE_NAME, + provenance: { kind: "registry", source: preMigrationCache }, + }, + ], + pluginRefs: [], + pluginClaims: [], }); const sync = new MarketplaceSyncSettingsUseCase( syncFs, @@ -1212,7 +1434,25 @@ describe("MarketplaceSyncSettingsUseCase + DoctorRegistrationUseCase — the ful () => USER_CACHE_ROOT, new MarketplaceRegisterFrameworkUseCase(registry), undefined, - fakeVersion(CURRENT_VERSION) + fakeVersion(CURRENT_VERSION), + readableHostPlugins("claude"), + new InMemoryManifestRepository(machine), + new Map([ + [ + "claude", + { + read: async () => ({ + location: REGISTRY_LOCATION, + entries: new Map( + [...hostEntries].map(([name, source]) => [ + name, + { kind: "registry" as const, source }, + ]) + ), + }), + }, + ], + ]) ); const syncResult = await sync.execute({ diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts index e715a0b63..361847663 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-scope.integration.test.ts @@ -1,6 +1,6 @@ import { resolve } from "node:path"; import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; @@ -9,6 +9,8 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -42,11 +44,130 @@ function seededBuiltCatalog(): InMemoryFileAdapter { }); } +function freshHostProof(activator: FakeNativePluginActivator) { + return { + plugins: new Map([ + [ + "claude" as const, + new FakeHostPluginRegistryReader({ + location: "/host/installed_plugins.json", + refs: new Map(), + }), + ], + ]), + sources: new Map([ + [ + "claude" as const, + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === "/built/claude" ? MARKETPLACE : undefined), + new Map() + ), + ], + ]), + }; +} + +describe("settings synchronization under the machine ownership lock", () => { + async function fixture() { + const projectRepo = manifestWithTool(); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save(PROJECT_ROOT, marketplace()); + const fs = seededBuiltCatalog(); + const activator = new FakeNativePluginActivator({ available: true }); + const proof = freshHostProof(activator); + let locked = false; + const accesses: boolean[] = []; + const originalLoad = projectRepo.load.bind(projectRepo); + const load = vi.spyOn(projectRepo, "load"); + load.mockImplementation(async () => { + accesses.push(locked); + return originalLoad(); + }); + const acquireLock = vi.fn(() => {}); + const withExclusiveAccess = async (action: () => Promise): Promise => { + acquireLock(); + locked = true; + try { + return await action(); + } finally { + locked = false; + } + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + proof.plugins, + { + path: machineRepo.path, + load: () => machineRepo.load(), + save: (manifest) => machineRepo.save(manifest), + delete: () => machineRepo.delete(), + withExclusiveAccess, + }, + proof.sources + ); + return { sync, fs, activator, projectRepo, machineRepo, acquireLock, accesses, load }; + } + + it.each([undefined, "project"] as const)( + "reads project ownership only inside the machine lock for scope %s", + async (scope) => { + const f = await fixture(); + const result = await f.sync.execute({ projectRoot: PROJECT_ROOT, scope }); + expect(result.errors).toEqual([]); + expect(f.acquireLock).toHaveBeenCalledTimes(1); + expect(f.accesses.length).toBeGreaterThan(0); + expect(f.accesses.every(Boolean)).toBe(true); + expect(f.activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(f.machineRepo.saveCount).toBe(1); + } + ); + + it("does not reacquire the project ownership lock during an explicit user-scope sync", async () => { + const f = await fixture(); + const result = await f.sync.execute({ projectRoot: PROJECT_ROOT, scope: "user" }); + expect(result.errors).toEqual([]); + expect(f.acquireLock).not.toHaveBeenCalled(); + expect(f.activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(f.machineRepo.saveCount).toBe(1); + }); + + it("propagates a machine lock failure before reading or changing project and host state", async () => { + const f = await fixture(); + const error = new Error("machine ownership lock unavailable"); + f.acquireLock.mockImplementationOnce(() => { + throw error; + }); + await expect(f.sync.execute({ projectRoot: PROJECT_ROOT })).rejects.toBe(error); + expect(f.load).not.toHaveBeenCalled(); + expect(f.projectRepo.saveCount).toBe(0); + expect(f.machineRepo.saveCount).toBe(0); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.fs.getFile(`${PROJECT_ROOT}/.claude/settings.json`)).toBeUndefined(); + }); +}); + describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks for", () => { it("enables at project scope by default, never claude's own implicit default", async () => { const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, marketplace()); + const proof = freshHostProof(activator); const useCase = new MarketplaceSyncSettingsUseCase( seededBuiltCatalog(), manifestWithTool(), @@ -54,7 +175,15 @@ describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks new DeterministicHasher(), new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + proof.plugins, + undefined, + proof.sources ); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -91,6 +220,7 @@ describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks plugins: [{ name: "aidd-telemetry" }], }), }); + const proof = freshHostProof(activator); const useCase = new MarketplaceSyncSettingsUseCase( catalog, new InMemoryManifestRepository(manifest), @@ -98,7 +228,15 @@ describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks new DeterministicHasher(), new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + proof.plugins, + undefined, + proof.sources ); await useCase.execute({ projectRoot: PROJECT_ROOT, scope: "user" }); @@ -244,6 +382,7 @@ describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks it("still records a shared-source reference at project scope, unaffected", async () => { const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const proof = freshHostProof(activator); const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, marketplace()); const added: Array<{ version: string; projectRoot: string }> = []; @@ -266,7 +405,10 @@ describe("MarketplaceSyncSettingsUseCase — the activation scope a caller asks () => "", undefined, userSourceReferences, - new FakeCurrentVersion() + new FakeCurrentVersion(), + proof.plugins, + undefined, + proof.sources ); await useCase.execute({ projectRoot: PROJECT_ROOT }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts index 4adb44eb2..368cee022 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings-use-case-manifest-writes.unit.test.ts @@ -9,9 +9,12 @@ import type { NativeRegistrations } from "../../../../../src/contexts/framework/ import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { InstallationFile } from "../../../../../src/kernel/file.js"; +import { builtMarketplaceDir } from "../../../../../src/kernel/paths.js"; import type { ToolId } from "../../../../../src/kernel/tool.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -42,6 +45,8 @@ interface Setup { readonly settingsOnDisk?: string; readonly otherTrackedFiles?: readonly string[]; readonly existingRegistrations?: NativeRegistrations; + readonly existingMachineRegistrations?: NativeRegistrations; + readonly hostRegisteredSource?: string; } function catalogPath(marketplace: string, target: string): string { @@ -124,6 +129,12 @@ async function build(setup: Setup = {}) { } const activator = setup.activator ?? new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const machineRegistrations = setup.existingMachineRegistrations; + const machine = machineRegistrations === undefined ? undefined : Manifest.create(); + if (machine !== undefined && machineRegistrations !== undefined) { + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", machineRegistrations); + } const useCase = new MarketplaceSyncSettingsUseCase( fs, manifestRepo, @@ -131,7 +142,47 @@ async function build(setup: Setup = {}) { hasher, new CapturingLogger(), new Map(toolIds.map((toolId) => [toolId, activator])), - buildPerMarketplace(setup.failingBuilds ?? []) + buildPerMarketplace(setup.failingBuilds ?? []), + setup.hostRegisteredSource === undefined + ? new Map() + : new Map([ + [ + "claude", + { + read: async () => ({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([[MARKETPLACE, setup.hostRegisteredSource]]), + }), + }, + ], + ]), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + refs: new Map(), + }), + ], + ]), + machine === undefined ? undefined : new InMemoryManifestRepository(machine), + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => names.find((name) => path === `/built/${name}/claude`), + setup.hostRegisteredSource === undefined + ? new Map() + : new Map([[MARKETPLACE, { kind: "registry", source: setup.hostRegisteredSource }]]) + ), + ], + ]) ); return { useCase, manifestRepo, manifest, fs, hasher }; } @@ -147,10 +198,31 @@ function trackedSettingsHash(manifest: Manifest): string | undefined { const FRAMEWORK_ONLY: NativeRegistrations = { binary: "claude", - marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], + marketplaces: [ + { + alias: MARKETPLACE, + hostName: MARKETPLACE, + provenance: { kind: "registry", source: `/built/${MARKETPLACE}/claude` }, + }, + ], pluginRefs: [], }; +function ownedMachineEntry(alias: string, hostName: string, source?: string): NativeRegistrations { + return { + binary: "claude", + marketplaces: [ + { + alias, + hostName, + ...(source === undefined ? {} : { provenance: { kind: "registry" as const, source } }), + }, + ], + pluginRefs: [], + pluginClaims: [], + }; +} + describe("how many times the manifest is written back", () => { it("zero times when a run changed nothing, writing no project file either", async () => { const activator = new FakeNativePluginActivator({ available: false }); @@ -329,8 +401,16 @@ describe("when a recorded registration is replaced", () => { expect(recorded(manifest)).toStrictEqual({ binary: "claude", marketplaces: [ - { alias: "market-a", hostName: "market-a" }, - { alias: "market-b", hostName: "market-b" }, + { + alias: "market-a", + hostName: "market-a", + provenance: { kind: "registry", source: "/built/market-a/claude" }, + }, + { + alias: "market-b", + hostName: "market-b", + provenance: { kind: "registry", source: "/built/market-b/claude" }, + }, ], pluginRefs: [], }); @@ -349,15 +429,14 @@ describe("when a recorded registration is replaced", () => { await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(recorded(manifest)?.marketplaces).toStrictEqual([ - { alias: MARKETPLACE, hostName: MARKETPLACE }, - ]); + expect(recorded(manifest)?.marketplaces).toStrictEqual(FRAMEWORK_ONLY.marketplaces); }); it("keeps a marketplace an earlier run registered when its build now fails", async () => { const { useCase, manifest } = await build({ marketplaceNames: [MARKETPLACE, "broken"], failingBuilds: ["broken"], + existingMachineRegistrations: ownedMachineEntry("broken", "broken-catalog"), existingRegistrations: { binary: "claude", marketplaces: [{ alias: "broken", hostName: "broken-catalog" }], @@ -368,7 +447,7 @@ describe("when a recorded registration is replaced", () => { await useCase.execute({ projectRoot: PROJECT_ROOT }); expect(recorded(manifest)?.marketplaces).toStrictEqual([ - { alias: MARKETPLACE, hostName: MARKETPLACE }, + ...FRAMEWORK_ONLY.marketplaces, { alias: "broken", hostName: "broken-catalog" }, ]); }); @@ -394,6 +473,7 @@ describe("a marketplace the host refused is not this project's to remove", () => it("keeps a marketplace an earlier run registered when the host now refuses it", async () => { const { useCase, manifest } = await build({ activator: refusingHost("live"), + existingMachineRegistrations: ownedMachineEntry(MARKETPLACE, MARKETPLACE), existingRegistrations: { binary: "claude", marketplaces: [{ alias: MARKETPLACE, hostName: MARKETPLACE }], @@ -423,12 +503,18 @@ describe("a marketplace the host refused is not this project's to remove", () => }); it("records a marketplace this run took back from a dead registration", async () => { - const { useCase, manifest } = await build({ activator: refusingHost("dead") }); + const { useCase, manifest } = await build({ + activator: refusingHost("dead"), + existingMachineRegistrations: ownedMachineEntry( + MARKETPLACE, + MARKETPLACE, + builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude") + ), + hostRegisteredSource: builtMarketplaceDir(PROJECT_ROOT, MARKETPLACE, "claude"), + }); await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(recorded(manifest)?.marketplaces).toStrictEqual([ - { alias: MARKETPLACE, hostName: MARKETPLACE }, - ]); + expect(recorded(manifest)?.marketplaces).toStrictEqual(FRAMEWORK_ONLY.marketplaces); }); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts index 01a9879cb..0b9ceed2f 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-settings.unit.test.ts @@ -14,6 +14,8 @@ import type { PluginSource } from "../../../../../src/kernel/source.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -47,6 +49,8 @@ interface SyncSetup { /** Refs that fail to enable — a recoverable, best-effort `NativePluginCliError`. */ readonly failOnPlugins?: readonly string[]; readonly activator?: FakeNativePluginActivator; + /** Explicitly prove the catalogue is already AIDD-owned before testing exact host refresh. */ + readonly ownedCatalog?: true; readonly marketplaceSource?: (name: string) => PluginSource; } @@ -72,9 +76,41 @@ function seededBuiltCatalog(name = "aidd-framework"): InMemoryFileAdapter { }); } +function readableClaudePluginRegistry() { + return new Map([ + [ + "claude" as const, + new FakeHostPluginRegistryReader({ + location: "/home/.claude/plugins/installed_plugins.json", + refs: new Map(), + }), + ], + ]); +} + +function freshClaudeSource(activator: FakeNativePluginActivator, name = "aidd-framework") { + return new Map([ + [ + "claude" as const, + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === "/built/claude" ? name : undefined), + new Map() + ), + ], + ]); +} + async function sync(setup: SyncSetup = {}) { const names = setup.marketplaceNames ?? ["aidd-framework"]; const fs = seededBuiltCatalog(names[0]); + for (const name of names.slice(1)) { + await fs.writeFile( + `/built/claude-${name}/.claude-plugin/marketplace.json`, + JSON.stringify({ name, version: "1.0.0", plugins: [] }) + ); + } const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); const logger = new CapturingLogger(); @@ -111,6 +147,22 @@ async function sync(setup: SyncSetup = {}) { crashOnAddMarketplace: setup.crashOnAddMarketplace ?? false, failOnPlugins: setup.failOnPlugins ?? [], }); + const machine = setup.ownedCatalog ? Manifest.create() : undefined; + if (machine !== undefined) { + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: names[0], + hostName: names[0], + provenance: { kind: "registry", source: "/built/claude" }, + }, + ], + pluginRefs: [], + pluginClaims: [], + }); + } const useCase = new MarketplaceSyncSettingsUseCase( fs, manifestRepo, @@ -118,7 +170,39 @@ async function sync(setup: SyncSetup = {}) { new DeterministicHasher(), logger, new Map([["claude", activator]]), - setup.ensureBuilt ?? fakeEnsureBuiltMarketplace() + setup.ensureBuilt ?? { + execute: async ({ marketplace, target }) => ({ + builtDir: + marketplace.name === names[0] + ? `/built/${target}` + : `/built/${target}-${marketplace.name}`, + version: "test", + rebuilt: true, + }), + }, + new Map(), + () => "", + undefined, + undefined, + undefined, + readableClaudePluginRegistry(), + machine === undefined ? undefined : new InMemoryManifestRepository(machine), + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => + path === "/built/claude" + ? names[0] + : names.slice(1).find((name) => path === `/built/claude-${name}`), + setup.ownedCatalog + ? new Map([[names[0], { kind: "registry", source: "/built/claude" }]]) + : new Map() + ), + ], + ]) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); const written = (await fs.fileExists(SHARED_SETTINGS)) @@ -340,7 +424,15 @@ describe("toolIds narrows which tool's CLI is driven", () => { ["claude", claudeActivator], ["codex", codexActivator], ]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableClaudePluginRegistry(), + undefined, + freshClaudeSource(claudeActivator) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT, toolIds: ["claude"] }); @@ -381,7 +473,15 @@ describe("toolIds narrows which tool's CLI is driven", () => { ["claude", claudeActivator], ["codex", codexActivator], ]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableClaudePluginRegistry(), + undefined, + freshClaudeSource(claudeActivator) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT, toolIds: ["claude"] }); @@ -421,10 +521,10 @@ describe("what a step that could not complete leaves in warnings and errors", () it("warns about a refused marketplace upgrade and still enables the plugin", async () => { const activator = new ActivatorFailingAtUpgrade(new NativePluginCliError("registry locked")); - const { result } = await sync({ activator }); + const { result } = await sync({ activator, ownedCatalog: true }); expect(result.warnings).toStrictEqual([ - "Native plugin activation — upgrade marketplaces skipped: registry locked", + "Native plugin activation — upgrade marketplace 'aidd-framework' skipped: registry locked", ]); expect(result.errors).toStrictEqual([]); expect(activator.enabledPlugins).toStrictEqual(["aidd-context@aidd-framework"]); @@ -433,7 +533,7 @@ describe("what a step that could not complete leaves in warnings and errors", () it("reports an activator bug during the upgrade as an error, never as a warning", async () => { const activator = new ActivatorFailingAtUpgrade(new Error("activator bug")); - const { result } = await sync({ activator }); + const { result } = await sync({ activator, ownedCatalog: true }); expect(result.errors).toStrictEqual([{ scope: "claude", message: "activator bug" }]); expect(result.warnings).toStrictEqual([]); @@ -498,7 +598,15 @@ describe("a project whose manifest also lists a tool with no plugin system", () new DeterministicHasher(), new CapturingLogger(), new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableClaudePluginRegistry(), + undefined, + freshClaudeSource(activator) ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts index ae1b02f85..a2379b446 100644 --- a/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-shared-source-reference.unit.test.ts @@ -16,6 +16,8 @@ import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; import { FakeCurrentVersion } from "../../../../helpers/ports/fake-current-version.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -24,8 +26,7 @@ import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory const PROJECT_ROOT = "/test-project"; const VERSION = "1.0.0"; -/** A manifest with claude installed and no plugin — enough for `sync` to run its - * settings pass, without a registered activator to drive (no catalog fixture needed). */ +/** A manifest with Claude installed and no plugin, for both registry and activation checks. */ async function manifestRepoWithClaudeInstalled(): Promise { const manifestRepo = new InMemoryManifestRepository(); const manifest = Manifest.create(); @@ -34,6 +35,35 @@ async function manifestRepoWithClaudeInstalled(): Promise (path === "/built/claude" ? FRAMEWORK_MARKETPLACE_NAME : undefined), + new Map() + ), + ], + ]), + }; +} + function frameworkMarketplace(): Marketplace { return Marketplace.create({ name: FRAMEWORK_MARKETPLACE_NAME, @@ -47,6 +77,7 @@ describe("the shared source's own reference, recorded by sync", () => { it("records this project's reference when the framework marketplace is already registered", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); fs.setFile(`${PROJECT_ROOT}/marker`, ""); + seedFrameworkCatalog(fs); const manifestRepo = await manifestRepoWithClaudeInstalled(); const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, frameworkMarketplace()); @@ -54,6 +85,8 @@ describe("the shared source's own reference, recorded by sync", () => { fs, () => "/fake-home/.config/aidd" ); + const activator = new FakeNativePluginActivator({ available: true }); + const proof = freshHostProof(activator); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -61,13 +94,16 @@ describe("the shared source's own reference, recorded by sync", () => { registry, new DeterministicHasher(), new CapturingLogger(), - new Map(), // no activators: this run only proves the reference, not native activation + new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(), new Map(), () => "", undefined, userSourceReferences, - new FakeCurrentVersion(VERSION) + new FakeCurrentVersion(VERSION), + proof.pluginRegistries, + undefined, + proof.sources ); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -81,6 +117,7 @@ describe("the shared source's own reference, recorded by sync", () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); fs.setFile(`${PROJECT_ROOT}/marker`, ""); fs.setFile("/fake-home/.config/aidd/references.json", "not json"); + seedFrameworkCatalog(fs); const manifestRepo = await manifestRepoWithClaudeInstalled(); const registry = new InMemoryMarketplaceRegistry(); await registry.save(PROJECT_ROOT, frameworkMarketplace()); @@ -89,6 +126,8 @@ describe("the shared source's own reference, recorded by sync", () => { () => "/fake-home/.config/aidd" ); const logger = new CapturingLogger(); + const activator = new FakeNativePluginActivator({ available: true }); + const proof = freshHostProof(activator); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -96,13 +135,16 @@ describe("the shared source's own reference, recorded by sync", () => { registry, new DeterministicHasher(), logger, - new Map(), + new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(), new Map(), () => "", undefined, userSourceReferences, - new FakeCurrentVersion(VERSION) + new FakeCurrentVersion(VERSION), + proof.pluginRegistries, + undefined, + proof.sources ); const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -155,6 +197,7 @@ describe("the shared source's own reference, recorded by sync", () => { it("recreates the framework marketplace when this machine's registry holds nothing at all", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); fs.setFile(`${PROJECT_ROOT}/marker`, ""); + seedFrameworkCatalog(fs); const manifestRepo = await manifestRepoWithClaudeInstalled(); const registry = new InMemoryMarketplaceRegistry(); // empty: nothing registered yet const userSourceReferences = new UserSourceReferencesAdapter( @@ -169,6 +212,8 @@ describe("the shared source's own reference, recorded by sync", () => { return { registered: true, scope: "user" as const }; }, }; + const activator = new FakeNativePluginActivator({ available: true }); + const proof = freshHostProof(activator); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -176,13 +221,16 @@ describe("the shared source's own reference, recorded by sync", () => { registry, new DeterministicHasher(), new CapturingLogger(), - new Map(), + new Map([["claude", activator]]), fakeEnsureBuiltMarketplace(), new Map(), () => "", registerFramework, userSourceReferences, - new FakeCurrentVersion(VERSION) + new FakeCurrentVersion(VERSION), + proof.pluginRegistries, + undefined, + proof.sources ); await useCase.execute({ projectRoot: PROJECT_ROOT, recreateFrameworkIfMissing: true }); @@ -190,6 +238,8 @@ describe("the shared source's own reference, recorded by sync", () => { expect((await registry.list(PROJECT_ROOT)).map((m) => m.name)).toContain( FRAMEWORK_MARKETPLACE_NAME ); + expect(activator.addedMarketplaces).toEqual(["/built/claude"]); + await useCase.execute({ projectRoot: PROJECT_ROOT }); expect(await userSourceReferences.listAllReferencingProjects()).toContain(PROJECT_ROOT); }); diff --git a/cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts b/cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts new file mode 100644 index 000000000..6b0a0a9d2 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/marketplace-sync-source-provenance.integration.test.ts @@ -0,0 +1,808 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { HostMarketplaceRegistryReading } from "../../../../../src/contexts/tools/domain/ports/host-marketplace-registry-reader.js"; +import type { HostPluginRegistryReading } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostMarketplaceRegistryReader } from "../../../../helpers/ports/fake-host-marketplace-registry-reader.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../helpers/ports/fake-native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +describe("native catalogue source provenance", () => { + async function hostPluginProofFixture( + reading?: HostPluginRegistryReading, + options: { + toolId?: "claude" | "codex"; + catalogue?: HostMarketplaceRegistryReading; + marketplaceScope?: "project" | "user"; + plugin?: boolean; + } = {} + ) { + const toolId = options.toolId ?? "codex"; + const builtDir = `/built/${toolId}`; + const project = Manifest.create(); + project.addTool(toolId, "1.0.0", []); + if (options.plugin !== false) + project.addPlugin( + toolId, + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: options.marketplaceScope ?? "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + [`${builtDir}/${toolId === "codex" ? ".agents/plugins" : ".claude-plugin"}/marketplace.json`]: + JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }), + }); + fs.setFile("/foreign/cache/marker.json", "foreign bytes"); + const activator = new FakeNativePluginActivator({ available: true }); + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([[toolId, activator]]), + fakeEnsureBuiltMarketplace(), + options.catalogue === undefined + ? new Map() + : new Map([[toolId, new FakeHostMarketplaceRegistryReader(options.catalogue)]]), + () => "", + undefined, + undefined, + undefined, + reading === undefined + ? new Map() + : new Map([[toolId, new FakeHostPluginRegistryReader(reading)]]), + machineRepo, + new Map([ + [ + toolId, + new FakeNativeMarketplaceSourceReader( + activator, + toolId === "codex" ? "effective-list" : "registry", + (path) => (path === builtDir ? "same-name" : undefined), + new Map() + ), + ], + ]) + ); + return { sync, fs, activator, projectRepo, machineRepo }; + } + + it.each(["project", "user"] as const)( + "refuses a %s-scope Claude catalogue if its registry becomes unreadable after the source check", + async (marketplaceScope) => { + const f = await hostPluginProofFixture( + { location: "/host/plugins.json", refs: new Map() }, + { + toolId: "claude", + marketplaceScope, + catalogue: { location: "/host/catalogues.json", unreadable: "permission denied" }, + } + ); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings.join(" ")).toMatch(/same-name.*host registry is unreadable/); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("claude")).toBeUndefined(); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual( + [] + ); + expect(f.machineRepo.saveCount).toBe(0); + expect(f.fs.getFile("/foreign/cache/marker.json")).toBe("foreign bytes"); + } + ); + + it.each(["project", "user"] as const)( + "refuses a newly appearing %s-scope Claude catalogue without a canonical claim even with identical plugin names", + async (marketplaceScope) => { + const f = await hostPluginProofFixture( + { location: "/host/plugins.json", refs: new Map() }, + { + toolId: "claude", + marketplaceScope, + catalogue: { + location: "/host/catalogues.json", + entries: new Map([["same-name", "/foreign/claude"]]), + }, + } + ); + const foreignCatalog = JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }); + f.fs.setFile("/foreign/claude/.claude-plugin/marketplace.json", foreignCatalog); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings.join(" ")).toContain("without a canonical AIDD claim"); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("claude")).toMatchObject({ + marketplaces: [], + pluginRefs: [], + }); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("claude")).toBeUndefined(); + expect(f.machineRepo.saveCount).toBe(0); + expect(f.fs.getFile("/foreign/claude/.claude-plugin/marketplace.json")).toBe(foreignCatalog); + } + ); + + it.each([ + { + toolId: "claude", + marketplaceScope: "user", + machineCatalogue: true, + otherHost: "other-catalog", + }, + { + toolId: "claude", + marketplaceScope: "project", + machineCatalogue: false, + otherHost: "other-catalog", + }, + { + toolId: "codex", + marketplaceScope: "user", + machineCatalogue: true, + otherHost: "other-catalog", + }, + { + toolId: "codex", + marketplaceScope: "project", + machineCatalogue: true, + otherHost: "other-catalog", + }, + { toolId: "claude", marketplaceScope: "user", machineCatalogue: true, otherHost: "same-name" }, + { + toolId: "codex", + marketplaceScope: "project", + machineCatalogue: true, + otherHost: "same-name", + }, + ] as const)( + "owns only the catalogue, not undeclared plugins, and is idempotent for $toolId / $marketplaceScope / $otherHost", + async ({ toolId, marketplaceScope, machineCatalogue, otherHost }) => { + const f = await hostPluginProofFixture( + { location: "/host/plugins.json", refs: new Map() }, + { toolId, marketplaceScope, plugin: false } + ); + const machine = f.machineRepo.getCurrent(); + if (machine === null) throw new Error("fixture lacks machine manifest"); + const target = { + alias: "alias", + hostName: "same-name", + provenance: + toolId === "claude" + ? { kind: "registry" as const, source: "/built/claude" } + : { + kind: "effective-list" as const, + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + }; + const other = { + alias: "other-alias", + hostName: otherHost, + provenance: + otherHost === target.hostName + ? target.provenance + : toolId === "claude" + ? { kind: "registry" as const, source: "/other/source" } + : { + kind: "effective-list" as const, + root: "/other/source", + sourceType: "local", + source: "/other/source", + }, + }; + const otherRef = `other-plugin@${otherHost}`; + machine.addTool(toolId, "0.9.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [other], + pluginRefs: [otherRef], + pluginClaims: [{ ref: otherRef, dependents: ["/B"] }], + }); + + for (let run = 0; run < 2; run += 1) { + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations(toolId)).toEqual({ + binary: toolId, + marketplaces: [target], + pluginRefs: [], + }); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations(toolId)).toEqual({ + binary: toolId, + marketplaces: machineCatalogue ? [other, target] : [other], + pluginRefs: [otherRef], + pluginClaims: [{ ref: otherRef, dependents: ["/B"] }], + }); + expect(f.machineRepo.saveCount).toBe(machineCatalogue ? 1 : 0); + expect(f.machineRepo.getCurrent()?.getToolVersion(toolId)).toBe("0.9.0"); + } + expect(f.activator.addedMarketplaces).toEqual([`/built/${toolId}`]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.activator.upgradeCount).toBe(1); + expect(f.fs.getFile("/foreign/cache/marker.json")).toBe("foreign bytes"); + } + ); + + it.each(["claude", "codex"] as const)( + "initializes a fresh %s machine tool record for a catalogue without claiming its available plugins", + async (toolId) => { + const f = await hostPluginProofFixture( + { location: "/host/plugins.json", refs: new Map() }, + { toolId, marketplaceScope: "user", plugin: false } + ); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(f.machineRepo.getCurrent()?.hasTool(toolId)).toBe(true); + expect(f.machineRepo.getCurrent()?.getToolVersion(toolId)).toBe("1.0.0"); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations(toolId)).toMatchObject({ + binary: toolId, + marketplaces: [{ alias: "alias", hostName: "same-name" }], + pluginRefs: [], + pluginClaims: [], + }); + expect(f.machineRepo.saveCount).toBe(1); + expect(f.activator.enabledPlugins).toEqual([]); + } + ); + + it("allows fresh Claude registration when the catalogue registry is explicitly absent", async () => { + const f = await hostPluginProofFixture( + { location: "/host/plugins.json", absent: true }, + { toolId: "claude", catalogue: { location: "/host/catalogues.json", absent: true } } + ); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(f.activator.addedMarketplaces).toEqual(["/built/claude"]); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name"]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("claude")?.marketplaces).toEqual([ + { + alias: "alias", + hostName: "same-name", + provenance: { kind: "registry", source: "/built/claude" }, + }, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("claude")).toBeUndefined(); + }); + + it.each([ + { label: "missing registry reader", reading: undefined, reason: "undefined" }, + { + label: "unreadable registry with a diagnostic", + reading: { location: "/host/plugins.json", unreadable: "permission denied" }, + reason: "permission denied", + }, + { + label: "unreadable registry without a diagnostic", + reading: { location: "/host/plugins.json" }, + reason: "/host/plugins.json", + }, + ])("refuses fresh Codex activation with $label", async ({ reading, reason }) => { + const f = await hostPluginProofFixture(reading); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.warnings.join(" ")).toContain("unreadable host plugin registry"); + expect(result.warnings.join(" ")).toContain(reason); + expect(f.activator.addedMarketplaces).toEqual([]); + expect(f.activator.enabledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.activator.upgradeCount).toBe(0); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")).toMatchObject({ + marketplaces: [], + pluginRefs: [], + }); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")).toBeUndefined(); + expect(f.machineRepo.saveCount).toBe(0); + expect(f.fs.getFile("/foreign/cache/marker.json")).toBe("foreign bytes"); + }); + + it("accepts an explicitly absent host plugin registry only after proving the newly added source", async () => { + const f = await hostPluginProofFixture({ location: "/host/plugins.json", absent: true }); + const result = await f.sync.execute({ projectRoot: "/A" }); + expect(result.errors).toEqual([]); + expect(result.warnings).toEqual([]); + expect(f.activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(f.activator.enabledPlugins).toEqual(["test-plugin@same-name"]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: "test-plugin@same-name", dependents: ["/A"] }, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual([ + { + alias: "alias", + hostName: "same-name", + provenance: { + kind: "effective-list", + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + }, + ]); + }); + + it.each([ + "foreign source", + "foreign root", + "non-local source type", + "unreadable", + "missing catalogue", + "unproven catalogue", + ] as const)( + "keeps foreign bytes and records no claim or enablement when post-add Codex proof is %s", + async (proof) => { + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }), + }); + fs.setFile("/foreign/cache/marker.json", "foreign bytes"); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => { + if (activator.addedMarketplaces.length === 0) + return { location: "host", entries: new Map() }; + if (proof === "unreadable") + return { location: "host", unreadable: "malformed source listing" }; + if (proof === "missing catalogue") return { location: "host", entries: new Map() }; + return { + location: "host", + entries: new Map([ + [ + "same-name", + proof === "unproven catalogue" + ? null + : { + kind: "effective-list", + root: + proof === "foreign root" || proof === "foreign source" + ? "/foreign" + : "/built/codex", + sourceType: proof === "non-local source type" ? "github" : "local", + source: proof === "foreign source" ? "/foreign" : "/built/codex", + }, + ], + ]), + }; + }, + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "codex", + new FakeHostPluginRegistryReader({ location: "host-plugins", refs: new Map() }), + ], + ]), + machineRepo, + new Map([["codex", source]]) + ); + const result = await sync.execute({ projectRoot: "/A" }); + expect(result.warnings.join(" ")).toMatch( + /post-add|add returned.*effective host source.*reconcile manually/i + ); + expect(activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(activator.enabledPlugins).toEqual([]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")).toBeUndefined(); + expect( + projectRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces ?? [] + ).toStrictEqual([]); + expect( + projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs ?? [] + ).toStrictEqual([]); + expect(activator.removedMarketplaces).toStrictEqual([]); + expect(activator.upgradeCount).toBe(0); + expect(await fs.readFile("/foreign/cache/marker.json")).toBe("foreign bytes"); + } + ); + it.each([ + { + label: "unreadable CLI source", + reading: { + location: "copilot plugin marketplace list --json", + unreadable: + "Copilot CLI marketplace list --json unavailable; upgrade to a supported binary and retry", + }, + reason: "Copilot CLI marketplace list --json unavailable", + }, + { + label: "foreign effective source", + reading: { + location: "copilot effective marketplace list", + entries: new Map([ + [ + "same-name", + { + kind: "effective-list" as const, + root: "/foreign", + sourceType: "local", + source: "/foreign", + }, + ], + ]), + }, + reason: "host source unproven", + }, + ])( + "refuses Copilot activation with $label without changing declarative settings", + async ({ reading, reason }) => { + const projectRoot = "/A"; + const project = Manifest.create(); + project.addTool("copilot", "1.0.0", []); + project.addPlugin( + "copilot", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + "/built/copilot/.plugin/marketplace.json": JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }), + }); + const settingsPath = `${projectRoot}/.github/copilot/settings.json`; + const foreignSettings = JSON.stringify({ + enabledPlugins: { "foreign@foreign-catalog": true }, + extraKnownMarketplaces: { "foreign-catalog": { source: "github" } }, + }); + fs.setFile(settingsPath, foreignSettings); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => reading, + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["copilot", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "copilot", + new FakeHostPluginRegistryReader({ location: "host-plugins", refs: new Map() }), + ], + ]), + machineRepo, + new Map([["copilot", source]]) + ); + const result = await sync.execute({ projectRoot }); + expect(result.warnings.join(" ")).toContain(reason); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(activator.upgradeCount).toBe(0); + expect(machineRepo.getCurrent()?.getNativeRegistrations("copilot")).toBeUndefined(); + expect(await fs.readFile(settingsPath)).toBe(foreignSettings); + } + ); + it("claims a fresh Codex source after post-read and attaches B without replacing it", async () => { + const projectRoot = "/A"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(Manifest.create()); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }), + }); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "host", + entries: + activator.addedMarketplaces.length === 0 + ? new Map() + : new Map([ + [ + "same-name", + { + kind: "effective-list", + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }, + ], + ]), + }), + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + ["codex", new FakeHostPluginRegistryReader({ location: "host-plugins", refs: new Map() })], + ]), + machineRepo, + new Map([["codex", source]]) + ); + const result = await sync.execute({ projectRoot }); + expect(result.errors).toEqual([]); + expect(activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(activator.enabledPlugins).toEqual(["test-plugin@same-name"]); + expect( + machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces[0].provenance + ).toEqual({ + kind: "effective-list", + root: "/built/codex", + sourceType: "local", + source: "/built/codex", + }); + const projectB = Manifest.create(); + projectB.addTool("codex", "1.0.0", []); + projectB.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const registryB = new InMemoryMarketplaceRegistry(); + await registryB.save( + "/B", + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const syncB = new MarketplaceSyncSettingsUseCase( + fs, + new InMemoryManifestRepository(projectB), + registryB, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + [ + "codex", + new FakeHostPluginRegistryReader({ + location: "host-plugins", + refs: new Map([["test-plugin@same-name", { enabled: true }]]), + }), + ], + ]), + machineRepo, + new Map([["codex", source]]) + ); + await syncB.execute({ projectRoot: "/B" }); + expect(activator.addedMarketplaces).toEqual(["/built/codex"]); + expect(machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: "test-plugin@same-name", dependents: ["/A", "/B"] }, + ]); + }); + it("does not add, upgrade, or enable a foreign same-name Codex catalogue with a stale AIDD claim and empty host refs", async () => { + const projectRoot = "/A"; + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/source" }, + true, + "project", + "alias" + ) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { + alias: "alias", + hostName: "same-name", + provenance: { kind: "effective-list", root: "/old", sourceType: "local", source: "/old" }, + }, + ], + pluginRefs: [], + pluginClaims: [], + }); + const machineRepo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + projectRoot, + Marketplace.create({ + name: "alias", + source: { kind: "local", path: "/source" }, + scope: "project", + addedAt: "2026-09-15T00:00:00Z", + }) + ); + const fs = new InMemoryFileAdapter({ + "/built/codex/.agents/plugins/marketplace.json": JSON.stringify({ + name: "same-name", + plugins: [{ name: "test-plugin" }], + }), + }); + fs.setFile("/foreign/marker.json", "foreign bytes"); + const activator = new FakeNativePluginActivator({ available: true }); + const source: NativeMarketplaceSourceReader = { + read: async () => ({ + location: "host", + entries: new Map([ + [ + "same-name", + { kind: "effective-list", root: "/foreign", sourceType: "local", source: "/foreign" }, + ], + ]), + }), + }; + const sync = new MarketplaceSyncSettingsUseCase( + fs, + projectRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + ["codex", new FakeHostPluginRegistryReader({ location: "host-plugins", refs: new Map() })], + ]), + machineRepo, + new Map([["codex", source]]) + ); + const result = await sync.execute({ projectRoot }); + expect(result.warnings.join(" ")).toMatch(/same-name.*source.*reconcile manually/i); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(activator.upgradeCount).toBe(0); + expect(await fs.readFile("/foreign/marker.json")).toBe("foreign bytes"); + expect( + machineRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces[0].provenance?.source + ).toBe("/old"); + }); +}); diff --git a/cli/tests/contexts/framework/application/flows/native-plugin-claims.integration.test.ts b/cli/tests/contexts/framework/application/flows/native-plugin-claims.integration.test.ts new file mode 100644 index 000000000..47fbff596 --- /dev/null +++ b/cli/tests/contexts/framework/application/flows/native-plugin-claims.integration.test.ts @@ -0,0 +1,410 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { CleanUserScopeUseCase } from "../../../../../src/contexts/framework/application/clean/clean-user-scope-use-case.js"; +import { MarketplaceSyncSettingsUseCase } from "../../../../../src/contexts/framework/application/flows/marketplace-sync-settings-use-case.js"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import { UserMarketplaceRemoveUseCase } from "../../../../../src/contexts/framework/application/ownership/user-marketplace-remove-use-case.js"; +import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { UserPluginUpdateUseCase } from "../../../../../src/contexts/framework/application/ownership/user-plugin-update-use-case.js"; +import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { nativeActivationOf } from "../../../../../src/contexts/tools/domain/registry.js"; +import { resolveHomeDir } from "../../../../../src/kernel/reading/home-dir.js"; +import type { AiToolId } from "../../../../../src/kernel/tool.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +for (const [toolId, catalogPath] of [ + ["codex", ".agents/plugins/marketplace.json"], + ["copilot", ".plugin/marketplace.json"], +] as const) { + const effectiveSource = (hostName: string, source: string) => + new Map([ + [ + toolId, + { + read: async (projectRoot) => ({ + location: `/host/${toolId}/catalogue (cwd ${projectRoot})`, + entries: new Map([ + [ + hostName, + { kind: "effective-list" as const, root: source, sourceType: "local", source }, + ], + ]), + }), + }, + ], + ]); + describe(`${toolId} machine plugin claims`, () => { + it("global clean refuses live plugin dependents and preserves claims if host unregister fails", async () => { + const ref = "test-plugin@real-catalog"; + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [ + { + alias: "local-alias", + hostName: "real-catalog", + provenance: { + kind: "effective-list", + root: `/built/${toolId}`, + sourceType: "local", + source: `/built/${toolId}`, + }, + }, + ], + pluginRefs: [ref], + pluginClaims: [{ ref, dependents: ["/B"] }], + }); + const repo = new InMemoryManifestRepository(machine); + const fs = new InMemoryFileAdapter({ + [`/built/${toolId}/${catalogPath}`]: JSON.stringify({ name: "real-catalog", plugins: [] }), + }); + const registry = new InMemoryMarketplaceRegistry(); + const failing = new FakeNativePluginActivator({ available: true, failOnUninstall: [ref] }); + const sources = effectiveSource("real-catalog", `/built/${toolId}`); + const hostPlugins = new Map([ + [ + toolId, + { + read: async () => ({ + location: `/host/${toolId}/plugins`, + refs: new Map([[ref, { enabled: true, scope: "user" as const }]]), + }), + }, + ], + ]); + const clean = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + registry, + () => "/machine", + new Map([[toolId, failing]]), + new Map(), + () => "/home", + undefined, + undefined, + sources, + hostPlugins + ); + await expect(clean.execute({ projectRoot: "/B", force: true })).rejects.toThrow( + /active projects.*\/B/ + ); + expect(failing.uninstalledPlugins).toEqual([]); + expect( + repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims?.[0]?.dependents + ).toEqual(["/B"]); + const registrations = machine.getNativeRegistrations(toolId); + if (registrations === undefined) throw new Error("seeded machine claim missing"); + machine.setNativeRegistrations(toolId, { + ...registrations, + pluginClaims: [{ ref, dependents: [] }], + }); + await repo.save(machine); + await expect(clean.execute({ projectRoot: "/B", force: true })).rejects.toThrow( + /uninstall.*failed.*claims retained/ + ); + expect(repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref, dependents: [] }, + ]); + const working = new FakeNativePluginActivator({ available: true }); + const finish = new CleanUserScopeUseCase( + fs, + repo, + new CapturingLogger(), + registry, + () => "/machine", + new Map([[toolId, working]]), + new Map(), + () => "/home", + undefined, + undefined, + sources, + hostPlugins + ); + await finish.execute({ projectRoot: "/B", force: true }); + expect(working.uninstalledPlugins).toEqual([ref]); + expect(working.removedMarketplaces).toEqual(["real-catalog"]); + expect(repo.getCurrent()).toBeNull(); + }); + it("attaches B to AIDD's exact host ref without re-enabling it, but never claims a foreign ref", async () => { + const alias = "local-alias"; + const hostName = "real-catalog"; + const ref = `test-plugin@${hostName}`; + const builtDir = `/built/${toolId}`; + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: alias, + source: { kind: "local", path: "/src" }, + scope: "user", + addedAt: "2026-01-01T00:00:00Z", + }) + ); + const project = () => { + const manifest = Manifest.create(); + manifest.addTool(toolId, "1.0.0", []); + manifest.addPlugin( + toolId, + InstalledPlugin.fromMetadata( + "test-plugin", + "1.0.0", + { kind: "local", path: "/src" }, + false, + "project", + alias + ) + ); + return new InMemoryManifestRepository(manifest); + }; + const a = project(); + const b = project(); + const foreign = project(); + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [ + { + alias, + hostName, + provenance: { + kind: "effective-list", + root: builtDir, + sourceType: "local", + source: builtDir, + }, + }, + ], + pluginRefs: [], + pluginClaims: [], + }); + const user = new InMemoryManifestRepository(machine); + const activator = new FakeNativePluginActivator({ available: true }); + const sources = effectiveSource(hostName, builtDir); + let enabled = false; + const hostReader: HostPluginRegistryReader = { + read: async () => ({ + location: "/host/registry", + refs: new Map(enabled ? [[ref, { enabled: true, scope: "user" as const }]] : []), + }), + }; + const useCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({ + [`${builtDir}/${catalogPath}`]: JSON.stringify({ + name: hostName, + version: "1.0.0", + plugins: [], + }), + }), + a, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([[toolId, activator]]), + fakeEnsureBuiltMarketplace(() => builtDir), + new Map(), + () => "/built", + undefined, + undefined, + undefined, + new Map([[toolId, hostReader]]), + user, + sources + ); + + await useCase.execute({ projectRoot: "/A", manifestRepo: a, toolIds: [toolId] }); + enabled = true; + await useCase.execute({ projectRoot: "/B", manifestRepo: b, toolIds: [toolId] }); + expect(activator.enabledPlugins).toEqual([ref]); + expect(b.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toContain(ref); + expect(user.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref, dependents: ["/A", "/B"] }, + ]); + const updateFs = new InMemoryFileAdapter(); + const update = new UserPluginUpdateUseCase( + user, + new UserPluginFileUpdater( + updateFs, + new UserPluginDistributionLoader( + new FixturePluginFetcher(), + new PluginDistributionReaderAdapter(updateFs) + ), + new DeterministicHasher() + ), + new CapturingLogger(), + new NativeHostRegistrationGate( + new Map([[toolId, activator]]), + new Map([[toolId, hostReader]]), + sources + ) + ); + if (toolId === "copilot") { + expect( + await update.execute({ + pluginNames: ["test-plugin"], + toolIds: [toolId], + projectRoot: "/A", + scope: "user", + }) + ).toEqual([ref]); + expect(activator.updatedPlugins).toEqual([ref]); + const failed = new FakeNativePluginActivator({ available: true, failOnUpdate: [ref] }); + const failingUpdate = new UserPluginUpdateUseCase( + user, + new UserPluginFileUpdater( + updateFs, + new UserPluginDistributionLoader( + new FixturePluginFetcher(), + new PluginDistributionReaderAdapter(updateFs) + ), + new DeterministicHasher() + ), + new CapturingLogger(), + new NativeHostRegistrationGate( + new Map([[toolId, failed]]), + new Map([[toolId, hostReader]]), + sources + ) + ); + await expect( + failingUpdate.execute({ + pluginNames: ["test-plugin"], + toolIds: [toolId], + projectRoot: "/A", + scope: "user", + }) + ).rejects.toThrow(/plugin update.*failed/); + expect(user.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref, dependents: ["/A", "/B"] }, + ]); + } else { + await expect( + update.execute({ + pluginNames: ["test-plugin"], + toolIds: [toolId], + projectRoot: "/A", + scope: "user", + }) + ).rejects.toThrow(/does not support targeted.*update/); + expect(activator.updatedPlugins).toEqual([]); + } + const removeMarketplace = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + user, + registry, + new NativeHostRegistrationGate( + new Map([[toolId, activator]]), + new Map([[toolId, hostReader]]), + sources + ) + ); + await expect( + removeMarketplace.execute({ + name: alias, + projectRoot: "/A", + autoConfirm: true, + scope: "user", + }) + ).rejects.toThrow(/active projects/); + + const foreignUser = new InMemoryManifestRepository(Manifest.create()); + const foreignUseCase = new MarketplaceSyncSettingsUseCase( + new InMemoryFileAdapter({ + [`${builtDir}/${catalogPath}`]: JSON.stringify({ + name: hostName, + version: "1.0.0", + plugins: [], + }), + }), + foreign, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([[toolId, new FakeNativePluginActivator({ available: true })]]), + fakeEnsureBuiltMarketplace(() => builtDir), + new Map(), + () => "/built", + undefined, + undefined, + undefined, + new Map([[toolId, hostReader]]), + foreignUser, + sources + ); + await foreignUseCase.execute({ + projectRoot: "/foreign", + manifestRepo: foreign, + toolIds: [toolId], + }); + expect(foreign.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([]); + expect(foreignUser.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims ?? []).toEqual( + [] + ); + const removeFs = new InMemoryFileAdapter(); + const cacheRoot = nativeActivationOf(toolId)?.pluginCacheDir?.(resolveHomeDir()); + const cachedBytes = + cacheRoot === undefined ? undefined : join(cacheRoot, hostName, "test-plugin", "skill.md"); + if (cachedBytes !== undefined) + removeFs.setFile(cachedBytes, "B still uses this cached plugin"); + const remove = (repo: InMemoryManifestRepository) => + new PluginRemoveUseCase( + removeFs, + repo, + new CapturingLogger(), + new Map([[toolId, activator]]), + new Map([[toolId, hostReader]]), + undefined, + registry, + user, + sources + ); + await remove(a).execute({ pluginName: "test-plugin", toolIds: [toolId], projectRoot: "/A" }); + expect(activator.uninstalledPlugins).toEqual([]); + if (cachedBytes !== undefined) + expect(removeFs.getFile(cachedBytes)).toBe("B still uses this cached plugin"); + expect(user.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref, dependents: ["/B"] }, + ]); + await remove(b).execute({ pluginName: "test-plugin", toolIds: [toolId], projectRoot: "/B" }); + expect(activator.uninstalledPlugins).toEqual([]); + expect(user.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref, dependents: [] }, + ]); + await remove(a).execute({ + pluginName: "test-plugin", + toolIds: [toolId], + projectRoot: "/A", + scope: "user", + }); + expect(activator.uninstalledPlugins).toEqual([ref]); + expect(user.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + enabled = false; + await removeMarketplace.execute({ + name: alias, + projectRoot: "/A", + autoConfirm: true, + scope: "user", + }); + expect(activator.removedMarketplaces).toEqual([hostName]); + expect((await registry.list("/A")).find((entry) => entry.name === alias)).toBeUndefined(); + }); + }); +} diff --git a/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts b/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts index 56015deba..b95b6ddf3 100644 --- a/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/plugin-add-hooks-trust-notice.integration.test.ts @@ -29,7 +29,8 @@ async function installWithLogger(toolId: "codex" | "claude") { deps.hasher, logger, new InMemoryMarketplaceRegistry(), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, diff --git a/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts b/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts index b9748eee7..25ac41a91 100644 --- a/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/plugin-add-opencode-hooks-install.integration.test.ts @@ -29,7 +29,8 @@ async function installSamplePlugin() { deps.hasher, capturingLogger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, diff --git a/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts index a2696bb00..60ad9e0ac 100644 --- a/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/built-tree-cursor-materialization.integration.test.ts @@ -1,5 +1,5 @@ import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Marketplace } from "../../../../../../src/contexts/distribution/domain/marketplace.js"; import { BuiltTreeMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/built-tree-materialization-translator.js"; import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; @@ -37,6 +37,43 @@ async function makeRegistry(): Promise { } describe("BuiltTreeMaterializationTranslator — cursor (integration)", () => { + it("counts changed files and skips byte-identical files on repeated materialization", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/plugins/sample-plugin/skills/demo/SKILL.md`, "built skill"); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => HOME, + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + const install = () => { + const restored = Manifest.create(); + restored.addTool("cursor", "test", []); + return translator.addPlugin( + dist(), + "cursor", + { kind: "local", path: "/source" }, + PROJECT_ROOT, + restored, + "aidd-framework" + ); + }; + const first = await install(); + expect(first.written).toBe(1); + const writes = vi.spyOn(fs, "writeFile"); + expect((await install()).written).toBe(0); + expect(writes).not.toHaveBeenCalled(); + fs.setFile( + `${HOME}/.cursor/plugins/local/sample-plugin/skills/demo/SKILL.md`, + "changed by user" + ); + expect((await install()).written).toBe(1); + expect( + await fs.readFile(`${HOME}/.cursor/plugins/local/sample-plugin/skills/demo/SKILL.md`) + ).toBe("built skill"); + }); + it("copies the built plugin subtree verbatim into the user plugin dir", async () => { const fs = new InMemoryFileAdapter(); // Built cursor tree (transformed content already): @ expanded, .mdc rule, dotted .mcp.json. @@ -78,6 +115,36 @@ describe("BuiltTreeMaterializationTranslator — cursor (integration)", () => { expect(installed?.files.size).toBe(4); }); + it("does not rewrite or claim an occupied Cursor user plugin directory", async () => { + const fs = new InMemoryFileAdapter(); + fs.setFile(`${BUILT}/plugins/sample-plugin/skills/demo/SKILL.md`, "built bytes"); + const occupied = `${HOME}/.cursor/plugins/local/sample-plugin/skills/demo/SKILL.md`; + fs.setFile(occupied, "foreign bytes"); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + const translator = new BuiltTreeMaterializationTranslator( + fs, + new DeterministicHasher(), + () => HOME, + fakeEnsureBuiltMarketplace(), + await makeRegistry() + ); + + const result = await translator.addPlugin( + dist(), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + "aidd-framework", + new Map(), + true + ); + expect(result.written).toBe(0); + expect(fs.getFile(occupied)).toBe("foreign bytes"); + expect(manifest.getPlugins("cursor")[0]?.files.size).toBe(0); + }); + it("falls back to flat materialization when no marketplace is given (raw local install)", async () => { const fs = new InMemoryFileAdapter(); const manifest = Manifest.create(); diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts index 5161e01fa..ceb829395 100644 --- a/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-claude-mode-a.integration.test.ts @@ -9,6 +9,8 @@ import { PluginDistribution } from "../../../../../../src/contexts/translate/dom import { CapturingLogger } from "../../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -17,6 +19,44 @@ import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-mem const PROJECT_ROOT = "/test-project"; const MARKETPLACE_NAME = "aidd-framework"; +function syncWithProvenFreshHost( + fs: InMemoryFileAdapter, + manifestRepo: InMemoryManifestRepository, + registry: InMemoryMarketplaceRegistry, + hasher: DeterministicHasher, + activator: FakeNativePluginActivator +): MarketplaceSyncSettingsUseCase { + return new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + new CapturingLogger(), + new Map([["claude", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + ["claude", new FakeHostPluginRegistryReader({ location: "fake claude", refs: new Map() })], + ]), + undefined, + new Map([ + [ + "claude", + new FakeNativeMarketplaceSourceReader( + activator, + "registry", + (path) => (path === "/built/claude" ? MARKETPLACE_NAME : undefined), + new Map() + ), + ], + ]) + ); +} + /** A readable catalog at the path the default `fakeEnsureBuiltMarketplace()` resolves * "claude" to — a real build always leaves one there, and an unreadable one now fails hard. */ async function seedBuiltCatalog(fs: InMemoryFileAdapter, name = MARKETPLACE_NAME): Promise { @@ -73,15 +113,7 @@ describe("install claude plugin via Mode A (integration)", () => { }) ); - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - hasher, - new CapturingLogger(), - new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() - ); + const useCase = syncWithProvenFreshHost(fs, manifestRepo, registry, hasher, activator); await useCase.execute({ projectRoot: PROJECT_ROOT }); const shared = JSON.parse( @@ -150,15 +182,7 @@ describe("install claude plugin via Mode A (integration)", () => { }) ); - const useCase = new MarketplaceSyncSettingsUseCase( - fs, - manifestRepo, - registry, - hasher, - new CapturingLogger(), - new Map([["claude", activator]]), - fakeEnsureBuiltMarketplace() - ); + const useCase = syncWithProvenFreshHost(fs, manifestRepo, registry, hasher, activator); // What a project installed before the split looks like: the registration sitting in // the committed file, naming a path that belongs to whoever ran the install. diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts index b917af5df..4e587513b 100644 --- a/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-codex-mode-a.integration.test.ts @@ -12,6 +12,8 @@ import type { PluginSource } from "../../../../../../src/kernel/source.js"; import { CapturingLogger } from "../../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -20,6 +22,45 @@ import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-mem const PROJECT_ROOT = "/test-project"; const MARKETPLACE_NAME = "aidd-framework"; +function syncWithProvenFreshHost( + fs: InMemoryFileAdapter, + manifestRepo: InMemoryManifestRepository, + registry: InMemoryMarketplaceRegistry, + hasher: DeterministicHasher, + logger: CapturingLogger, + activator: FakeNativePluginActivator +): MarketplaceSyncSettingsUseCase { + return new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + hasher, + logger, + new Map([["codex", activator]]), + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + new Map([ + ["codex", new FakeHostPluginRegistryReader({ location: "fake codex", refs: new Map() })], + ]), + undefined, + new Map([ + [ + "codex", + new FakeNativeMarketplaceSourceReader( + activator, + "effective-list", + (path) => (path === "/built/codex" ? MARKETPLACE_NAME : undefined), + new Map() + ), + ], + ]) + ); +} + /** A readable catalog at the path `fakeEnsureBuiltMarketplace()` resolves "codex" to, at its * own `distributionProbes.marketplace` relative path: a real build always leaves one there, and * an unreadable catalog is now `UnreadableBuiltCatalogError` rather than a fall back. */ @@ -112,14 +153,13 @@ describe("install codex plugin via Mode A (integration)", () => { const activator = new FakeNativePluginActivator({ available: true }); await seedCodexPlugin(manifestRepo, registry); - const useCase = new MarketplaceSyncSettingsUseCase( + const useCase = syncWithProvenFreshHost( fs, manifestRepo, registry, hasher, new CapturingLogger(), - new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace() + activator ); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -127,7 +167,7 @@ describe("install codex plugin via Mode A (integration)", () => { // succeeds outright — no pre-emptive remove on a clean install. expect(activator.removedMarketplaces).toEqual([]); expect(activator.addedMarketplaces).toEqual(["/built/codex"]); - expect(activator.upgradeCount).toBe(1); + expect(activator.upgradeCount).toBe(0); expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); expect(await fs.fileExists(resolve(PROJECT_ROOT, ".codex/config.json"))).toBe(false); }); @@ -143,14 +183,13 @@ describe("install codex plugin via Mode A (integration)", () => { repo: "ai-driven-dev/framework", }); - const useCase = new MarketplaceSyncSettingsUseCase( + const useCase = syncWithProvenFreshHost( fs, manifestRepo, registry, new DeterministicHasher(), new CapturingLogger(), - new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace() + activator ); await useCase.execute({ projectRoot: PROJECT_ROOT }); @@ -170,14 +209,13 @@ describe("install codex plugin via Mode A (integration)", () => { }); await seedTwoCodexPlugins(manifestRepo, registry); - const useCase = new MarketplaceSyncSettingsUseCase( + const useCase = syncWithProvenFreshHost( fs, manifestRepo, registry, new DeterministicHasher(), logger, - new Map([["codex", activator]]), - fakeEnsureBuiltMarketplace() + activator ); await useCase.execute({ projectRoot: PROJECT_ROOT }); diff --git a/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts index 38202cfdc..9dacea07a 100644 --- a/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/install-plugin-copilot-mode-a.integration.test.ts @@ -9,6 +9,8 @@ import { PluginDistribution } from "../../../../../../src/contexts/translate/dom import { CapturingLogger } from "../../../../../helpers/ports/capturing-logger.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; import { fakeEnsureBuiltMarketplace } from "../../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeHostPluginRegistryReader } from "../../../../../helpers/ports/fake-host-plugin-registry-reader.js"; +import { FakeNativeMarketplaceSourceReader } from "../../../../../helpers/ports/fake-native-marketplace-source-reader.js"; import { FakeNativePluginActivator } from "../../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -17,6 +19,30 @@ import { InMemoryMarketplaceRegistry } from "../../../../../helpers/ports/in-mem const PROJECT_ROOT = "/test-project"; const MARKETPLACE_NAME = "aidd-framework"; +function ownedMachineCatalog(): InMemoryManifestRepository { + const machine = Manifest.create(); + machine.addTool("copilot", "test", []); + machine.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [{ alias: MARKETPLACE_NAME, hostName: MARKETPLACE_NAME }], + pluginRefs: [], + pluginClaims: [], + }); + return new InMemoryManifestRepository(machine); +} + +function readableHostPlugins() { + return new Map([ + [ + "copilot" as const, + new FakeHostPluginRegistryReader({ + location: "/home/.copilot/plugins", + refs: new Map(), + }), + ], + ]); +} + /** A real build always leaves a catalog at copilot's own `distributionProbes.marketplace` * path, and an unreadable one is a hard failure, so the fixture must leave one too. */ async function seedBuiltCatalog(fs: InMemoryFileAdapter, name = MARKETPLACE_NAME): Promise { @@ -69,12 +95,18 @@ function buildDist(name = "aidd-context"): PluginDistribution { } describe("install copilot plugin via Mode A (integration)", () => { - it("recommends plugins in the shared file and puts no path in it", async () => { + it("does not auto-install an unproven plugin or rewrite a foreign marketplace overlay", async () => { const fs = new InMemoryFileAdapter(); const hasher = new DeterministicHasher(); const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); await seedCopilotPlugin(manifestRepo, registry); + const settingsPath = resolve(PROJECT_ROOT, ".github/copilot/settings.json"); + const foreignSettings = JSON.stringify({ + enabledPlugins: { "foreign@foreign-catalog": true }, + extraKnownMarketplaces: { "foreign-catalog": { source: "github" } }, + }); + await fs.writeFile(settingsPath, foreignSettings); const useCase = new MarketplaceSyncSettingsUseCase( fs, @@ -87,19 +119,10 @@ describe("install copilot plugin via Mode A (integration)", () => { ); await useCase.execute({ projectRoot: PROJECT_ROOT }); - const settingsPath = resolve(PROJECT_ROOT, ".github/copilot/settings.json"); - const settings = JSON.parse(await fs.readFile(settingsPath)) as Record; - - // VS Code reads this file to recommend plugins to teammates, so it carries names. - expect(settings.enabledPlugins).toBeDefined(); - - // No marketplace registration: that names the built tree by absolute path, which belongs - // to whoever ran the install; copilot learns its marketplaces from its own CLI instead. - expect(settings.extraKnownMarketplaces).toBeUndefined(); - expect(JSON.stringify(settings)).not.toContain("/built/copilot"); + expect(await fs.readFile(settingsPath)).toBe(foreignSettings); }); - it("drives the copilot CLI activator and still writes the settings file", async () => { + it("projects only a plugin ref whose marketplace source a future host reader verifies", async () => { const fs = new InMemoryFileAdapter(); await seedBuiltCatalog(fs); const manifestRepo = new InMemoryManifestRepository(); @@ -114,25 +137,40 @@ describe("install copilot plugin via Mode A (integration)", () => { new DeterministicHasher(), new CapturingLogger(), new Map([["copilot", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + undefined, + new Map([ + [ + "copilot", + new FakeNativeMarketplaceSourceReader( + activator, + "effective-list", + (path) => (path === "/built/copilot" ? MARKETPLACE_NAME : undefined), + new Map() + ), + ], + ]) ); await useCase.execute({ projectRoot: PROJECT_ROOT }); - // Registers the BUILT copilot tree (not the raw github source). A fresh add - // succeeds outright — no pre-emptive remove. expect(activator.removedMarketplaces).toEqual([]); expect(activator.addedMarketplaces).toEqual(["/built/copilot"]); expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); expect(await fs.fileExists(resolve(PROJECT_ROOT, ".github/copilot/settings.json"))).toBe(true); }); - it("takes the name back when whoever held it is gone", async () => { + it("refuses force takeover of a claimed Copilot name without host source proof", async () => { const fs = new InMemoryFileAdapter(); await seedBuiltCatalog(fs); const manifestRepo = new InMemoryManifestRepository(); const registry = new InMemoryMarketplaceRegistry(); - // The name is held, and the tool reports its source no longer resolves: nobody - // alive is behind it, so taking it back breaks nothing. + // A missing source does not by itself prove who owns the host registration. const activator = new FakeNativePluginActivator({ available: true, conflictOnAdd: true, @@ -147,15 +185,50 @@ describe("install copilot plugin via Mode A (integration)", () => { new DeterministicHasher(), new CapturingLogger(), new Map([["copilot", activator]]), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + new Map(), + () => "", + undefined, + undefined, + undefined, + readableHostPlugins(), + ownedMachineCatalog() ); - await useCase.execute({ projectRoot: PROJECT_ROOT }); + const result = await useCase.execute({ projectRoot: PROJECT_ROOT }); - expect(activator.removedMarketplaces).toEqual([MARKETPLACE_NAME]); - // Forced, because a marketplace with plugins installed refuses a plain removal. - expect(activator.forcedRemovals).toEqual([true]); - expect(activator.addedMarketplaces).toEqual(["/built/copilot"]); - expect(activator.enabledPlugins).toEqual([`aidd-context@${MARKETPLACE_NAME}`]); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(result.warnings.join("\n")).toContain("host source unproven"); + }); + + it("refuses force takeover of a dead name without a canonical owner", async () => { + const fs = new InMemoryFileAdapter(); + await seedBuiltCatalog(fs); + const manifestRepo = new InMemoryManifestRepository(); + const registry = new InMemoryMarketplaceRegistry(); + await seedCopilotPlugin(manifestRepo, registry); + const activator = new FakeNativePluginActivator({ + available: true, + conflictOnAdd: true, + registrationState: "dead", + }); + const result = await new MarketplaceSyncSettingsUseCase( + fs, + manifestRepo, + registry, + new DeterministicHasher(), + new CapturingLogger(), + new Map([["copilot", activator]]), + fakeEnsureBuiltMarketplace() + ).execute({ projectRoot: PROJECT_ROOT }); + + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.forcedRemovals).toEqual([]); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); + expect(result.warnings.join("\n")).toContain("host source unproven"); }); it("leaves a name alone while it still resolves, whoever holds it", async () => { @@ -184,7 +257,9 @@ describe("install copilot plugin via Mode A (integration)", () => { ).execute({ projectRoot: PROJECT_ROOT }); expect(activator.removedMarketplaces).toEqual([]); - expect(logger.warnMessages.some((m) => m.includes("register marketplace"))).toBe(true); + expect(logger.warnMessages.some((m) => m.includes("host source unproven"))).toBe(true); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); }); it("says nothing about taking a name back when it cannot tell who holds it", async () => { @@ -215,7 +290,8 @@ describe("install copilot plugin via Mode A (integration)", () => { expect(activator.removedMarketplaces).toEqual([]); expect(logger.warnMessages.some((m) => m.includes("no longer exists"))).toBe(false); - // The failure itself is still surfaced, not swallowed. - expect(logger.warnMessages.some((m) => m.includes("register marketplace"))).toBe(true); + expect(logger.warnMessages.some((m) => m.includes("host source unproven"))).toBe(true); + expect(activator.addedMarketplaces).toEqual([]); + expect(activator.enabledPlugins).toEqual([]); }); }); diff --git a/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts index 5df09df4c..f5723c31d 100644 --- a/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/mode-b-flat-materialization-adapter.unit.test.ts @@ -182,24 +182,28 @@ describe("ModeBFlatMaterializationTranslator", () => { }); } - it("drops the servers a previous version contributed even when the new version contributes none", async () => { + it("refuses to unmerge an old server whose recorded digest cannot prove its current bytes", async () => { const { adapter, fs } = buildAdapter(); const configPath = join(PROJECT_ROOT, "opencode.json"); - fs.setFile(configPath, JSON.stringify({ mcp: { "old-tool": { type: "local" } } })); + const userContent = JSON.stringify({ mcp: { "old-tool": { type: "local" } } }); + fs.setFile(configPath, userContent); const manifest = Manifest.create(); manifest.addTool("opencode", "test", []); - await adapter.addPlugin( - mcpDist({}), - "opencode", - { kind: "local", path: "/plugin-source" }, - PROJECT_ROOT, - manifest, - undefined, - new Map([["old-tool", "digest-of-old-tool"]]) - ); + await expect( + adapter.addPlugin( + mcpDist({}), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + new Map([["old-tool", "digest-of-old-tool"]]) + ) + ).rejects.toThrow(/edited after install/); - expect(JSON.parse(fs.getFile(configPath) ?? "null")).toStrictEqual({ mcp: {} }); + expect(fs.getFile(configPath)).toBe(userContent); + expect(manifest.getPlugins("opencode")).toEqual([]); }); it("propagates a failure to read the MCP config other than the file being absent", async () => { diff --git a/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts b/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts index 6f972cc29..7fd864ed5 100644 --- a/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/project-hooks-materializer.unit.test.ts @@ -1,6 +1,6 @@ import "../../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { ProjectHooksMaterializer, withoutHooks, @@ -53,6 +53,119 @@ function distWithHooks(hooks: readonly PluginComponentFile[]): PluginDistributio } describe("ProjectHooksMaterializer", () => { + it("refuses a preexisting user-owned script without changing the project's hooks", async () => { + const scriptPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "extra.js"); + const fs = new InMemoryFileAdapter(); + const materializer = new ProjectHooksMaterializer(fs); + const first = await materializer.materializeWithProvenance( + distWithHooks([hooksManifest("PreToolUse"), SCRIPT]), + "cursor", + PROJECT_ROOT + ); + const originalHooks = fs.getFile(HOOKS_PATH); + fs.setFile(scriptPath, "user script"); + + await expect( + materializer.materializeWithProvenance( + distWithHooks([ + hooksManifest("PreToolUse"), + SCRIPT, + { relativePath: "hooks/extra.js", content: "new owned script" }, + ]), + "cursor", + PROJECT_ROOT, + first.projectHooks + ) + ).rejects.toThrow(/user-owned.*install refused/); + + expect(fs.getFile(scriptPath)).toBe("user script"); + expect(fs.getFile(HOOKS_PATH)).toBe(originalHooks); + }); + + it.each(["present", "already missing"] as const)( + "reinstall removes only the obsolete owned script when it is %s", + async (state) => { + const fs = new InMemoryFileAdapter(); + const materializer = new ProjectHooksMaterializer(fs); + const oldPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "old.js"); + const scriptPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "pre.js"); + const userPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "notes.txt"); + const otherPath = join(PROJECT_ROOT, ".cursor/hooks/other-plugin/check.js"); + const first = await materializer.materializeWithProvenance( + distWithHooks([ + hooksManifest("PreToolUse"), + SCRIPT, + { relativePath: "hooks/old.js", content: "old script" }, + ]), + "cursor", + PROJECT_ROOT + ); + fs.setFile(userPath, "user notes"); + fs.setFile(otherPath, "other plugin script"); + const previousHooks = fs.getFile(HOOKS_PATH); + if (state === "already missing") await fs.deleteFile(oldPath); + + const second = await materializer.materializeWithProvenance( + distWithHooks([ + hooksManifest("PreToolUse"), + { ...SCRIPT, content: "updated owned script" }, + ]), + "cursor", + PROJECT_ROOT, + first.projectHooks + ); + + expect(fs.getFile(oldPath)).toBeUndefined(); + expect(fs.getFile(scriptPath)).toBe("updated owned script"); + expect(fs.getFile(userPath)).toBe("user notes"); + expect(fs.getFile(otherPath)).toBe("other plugin script"); + expect(fs.getFile(HOOKS_PATH)).toBe(previousHooks); + expect(second.skipped).toStrictEqual([]); + expect(second.projectHooks?.scripts).toStrictEqual( + new Map([ + [`.cursor/hooks/${PLUGIN_NAME}/pre.js`, (await fs.readFileHash(scriptPath)).value], + ]) + ); + } + ); + + it("preserves an obsolete script edited after preflight instead of deleting it during reinstall", async () => { + const fs = new InMemoryFileAdapter(); + const materializer = new ProjectHooksMaterializer(fs); + const oldPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "old.js"); + const scriptPath = join(PROJECT_ROOT, ".cursor/hooks", PLUGIN_NAME, "pre.js"); + const first = await materializer.materializeWithProvenance( + distWithHooks([ + hooksManifest("PreToolUse"), + SCRIPT, + { relativePath: "hooks/old.js", content: "old script" }, + ]), + "cursor", + PROJECT_ROOT + ); + const writeFile = fs.writeFile.bind(fs); + vi.spyOn(fs, "writeFile").mockImplementation(async (path, content) => { + if (path === scriptPath && content === "updated owned script") + fs.setFile(oldPath, "concurrent user edit"); + return writeFile(path, content); + }); + + await expect( + materializer.materializeWithProvenance( + distWithHooks([ + hooksManifest("PreToolUse"), + { ...SCRIPT, content: "updated owned script" }, + ]), + "cursor", + PROJECT_ROOT, + first.projectHooks + ) + ).rejects.toThrow(/edited during reinstall.*removal refused/); + + expect(fs.getFile(oldPath)).toBe("concurrent user edit"); + expect(first.projectHooks?.scripts.has(`.cursor/hooks/${PLUGIN_NAME}/old.js`)).toBe(true); + }); + it("merges the plugin's hooks manifest whichever position it holds among the hook files", async () => { const fs = new InMemoryFileAdapter(); diff --git a/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts index f1ce48bcd..530b95367 100644 --- a/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-cursor-hooks-mcp.integration.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "vitest"; import { ModeBFlatMaterializationTranslator } from "../../../../../../src/contexts/framework/application/framework/translator/mode-b-flat-materialization-translator.js"; import { PluginRemoveUseCase } from "../../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; import { Manifest } from "../../../../../../src/contexts/framework/domain/manifest.js"; +import type { ProjectHooksProvenance } from "../../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistribution } from "../../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { CLIOutput } from "../../../../../../src/presentation/output.js"; import { DeterministicHasher } from "../../../../../helpers/ports/deterministic-hasher.js"; @@ -65,7 +66,28 @@ function buildDist(name: string): PluginDistribution { }); } -async function installPlugin(fs: InMemoryFileAdapter, manifest: Manifest, name: string) { +function buildWithoutHooks(name: string): PluginDistribution { + return new PluginDistribution({ + manifest: { name, version: "1.1.0" }, + format: "claude", + files: [{ relativePath: ".mcp.json", content: MCP_CONTENT }], + components: { + commands: [], + agents: [], + rules: [], + skills: [], + hooks: [], + mcp: [{ relativePath: ".mcp.json", content: MCP_CONTENT }], + }, + }); +} + +async function installPlugin( + fs: InMemoryFileAdapter, + manifest: Manifest, + name: string, + previous?: ProjectHooksProvenance +) { const adapter = new ModeBFlatMaterializationTranslator( fs, new DeterministicHasher(), @@ -77,7 +99,10 @@ async function installPlugin(fs: InMemoryFileAdapter, manifest: Manifest, name: { kind: "local", path: "/plugin-source" }, PROJECT_ROOT, manifest, - undefined + undefined, + new Map(), + false, + previous ); } @@ -102,6 +127,10 @@ describe("Cursor plugin.files tracking enables uninstall of mcp.json; hooks.json } // hooks.json was still written - just not tracked in Plugin.files, and not here expect(fs.has(HOOKS_PATH)).toBe(true); + expect(installed?.projectHooks?.entries).toHaveLength(1); + expect([...(installed?.projectHooks?.scripts ?? new Map()).keys()]).toEqual([ + `.cursor/hooks/${PLUGIN_NAME}/pre.js`, + ]); }); }); @@ -146,12 +175,112 @@ describe("plugin remove unmerges Cursor project hooks (Phase 7, Task 3)", () => const manifest = Manifest.create(); manifest.addTool("cursor", "test", []); await installPlugin(fs, manifest, PLUGIN_NAME); + const previous = manifest.getPlugins("cursor")[0].projectHooks; manifest.removePlugin("cursor", PLUGIN_NAME); - await installPlugin(fs, manifest, PLUGIN_NAME); + await installPlugin(fs, manifest, PLUGIN_NAME, previous); const parsed = JSON.parse(await fs.readFile(HOOKS_PATH)) as { hooks: Record>; }; expect(parsed.hooks.preToolUse).toHaveLength(1); }); + + it("refuses reinstall if the previous hook script was edited", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + const previous = manifest.getPlugins("cursor")[0].projectHooks; + fs.setFile(SCRIPT_PATH, "edited by user"); + manifest.removePlugin("cursor", PLUGIN_NAME); + await expect(installPlugin(fs, manifest, PLUGIN_NAME, previous)).rejects.toThrow(/edited/); + expect(fs.getFile(SCRIPT_PATH)).toBe("edited by user"); + }); + + it("removes verified prior project hooks when a replacement no longer carries hooks", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + const previous = manifest.getPlugins("cursor")[0].projectHooks; + manifest.removePlugin("cursor", PLUGIN_NAME); + const translator = new ModeBFlatMaterializationTranslator( + fs, + new DeterministicHasher(), + () => STUB_HOME + ); + await translator.addPlugin( + buildWithoutHooks(PLUGIN_NAME), + "cursor", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + new Map(), + false, + previous + ); + expect(fs.has(HOOKS_PATH)).toBe(false); + expect(fs.has(SCRIPT_PATH)).toBe(false); + expect(manifest.getPlugins("cursor")[0].projectHooks).toBeUndefined(); + }); + + it("refuses to remove an edited project hook script without detaching the plugin record", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + fs.setFile(SCRIPT_PATH, "user-edited script"); + const repo = new InMemoryManifestRepository(manifest); + const remove = new PluginRemoveUseCase(fs, repo, new CLIOutput(false), new Map()); + await expect( + remove.execute({ pluginName: PLUGIN_NAME, toolIds: ["cursor"], projectRoot: PROJECT_ROOT }) + ).rejects.toThrow(/edited.*pre.js|pre.js.*edited/); + expect(fs.getFile(SCRIPT_PATH)).toBe("user-edited script"); + expect( + repo + .getCurrent() + ?.getPlugins("cursor") + .some((p) => p.name === PLUGIN_NAME) + ).toBe(true); + }); + + it("refuses to strip an edited hook command entry and preserves other plugins", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + await installPlugin(fs, manifest, OTHER_PLUGIN_NAME); + const hooks = JSON.parse(fs.getFile(HOOKS_PATH) ?? "null") as { + hooks: { preToolUse: Array<{ command: string }> }; + }; + hooks.hooks.preToolUse[0].command += " --user-flag"; + fs.setFile(HOOKS_PATH, JSON.stringify(hooks)); + const repo = new InMemoryManifestRepository(manifest); + const remove = new PluginRemoveUseCase(fs, repo, new CLIOutput(false), new Map()); + await expect( + remove.execute({ pluginName: PLUGIN_NAME, toolIds: ["cursor"], projectRoot: PROJECT_ROOT }) + ).rejects.toThrow(/edited.*hooks|hooks.*edited/); + expect(fs.getFile(HOOKS_PATH)).toContain("--user-flag"); + expect(fs.has(OTHER_SCRIPT_PATH)).toBe(true); + expect(repo.getCurrent()?.getPlugins("cursor")).toHaveLength(2); + }); + + it("does not delete an untracked user script just because it shares a plugin directory", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("cursor", "test", []); + await installPlugin(fs, manifest, PLUGIN_NAME); + const userScript = join(PROJECT_ROOT, ".cursor", "hooks", PLUGIN_NAME, "user.js"); + fs.setFile(userScript, "user owned"); + const repo = new InMemoryManifestRepository(manifest); + const remove = new PluginRemoveUseCase(fs, repo, new CLIOutput(false), new Map()); + await remove.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + }); + expect(fs.has(SCRIPT_PATH)).toBe(false); + expect(fs.getFile(userScript)).toBe("user owned"); + }); }); diff --git a/cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts index da426f411..296e0a39d 100644 --- a/cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts +++ b/cli/tests/contexts/framework/application/framework/translator/remove-plugin-opencode-mcp.integration.test.ts @@ -148,4 +148,44 @@ describe("remove opencode plugin: unmerge MCP entries (Phase 5)", () => { }) ).resolves.not.toThrow(); }); + + it("refuses reinstall when a previously contributed MCP server was user-edited", async () => { + const fs = new InMemoryFileAdapter(); + const translator = new ModeBFlatMaterializationTranslator( + fs, + new DeterministicHasher(), + () => STUB_HOME + ); + const manifest = Manifest.create(); + manifest.addTool("opencode", "test", []); + await translator.addPlugin( + buildDist(), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined + ); + const prior = manifest.getPlugins("opencode")[0].mcpEntries; + const config = JSON.parse(fs.getFile(OPENCODE_JSON) ?? "null") as { + mcp: Record; + }; + const name = [...prior.keys()][0]; + config.mcp[name] = USER_SERVER; + const edited = JSON.stringify(config); + fs.setFile(OPENCODE_JSON, edited); + manifest.removePlugin("opencode", PLUGIN_NAME); + await expect( + translator.addPlugin( + buildDist(), + "opencode", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + undefined, + prior + ) + ).rejects.toThrow(/edited.*MCP|MCP.*edited/); + expect(fs.getFile(OPENCODE_JSON)).toBe(edited); + }); }); diff --git a/cli/tests/contexts/framework/application/init-use-case.unit.test.ts b/cli/tests/contexts/framework/application/init-use-case.unit.test.ts index 90237cb9c..cff050b3c 100644 --- a/cli/tests/contexts/framework/application/init-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/init-use-case.unit.test.ts @@ -23,6 +23,8 @@ describe("init", () => { const manifest = await deps.manifestRepo.load(); expect(manifest).not.toBeNull(); + expect(result.manifest.toJSON()).toEqual(manifest?.toJSON()); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".gitignore"))).toContain(".aidd/cache/"); const tools = manifest?.getInstalledToolIds() ?? []; expect(tools).toHaveLength(0); }); @@ -133,6 +135,19 @@ describe("init", () => { }); describe("--force", () => { + it("does not create or alter gitignore while reusing an existing manifest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + await deps.fs.writeFile(join(PROJECT_ROOT, ".gitignore"), "# User-owned\n"); + const before = (await deps.manifestRepo.load())?.toJSON(); + const result = await new InitUseCase(deps.fs, deps.manifestRepo).execute({ + projectRoot: PROJECT_ROOT, + force: true, + }); + expect(result.manifest.toJSON()).toEqual(before); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".gitignore"))).toBe("# User-owned\n"); + }); + it("preserves existing manifest tools when --force reinitializes", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initProject(deps, PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts index e7f661ad4..1a5f35aa2 100644 --- a/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/install/install-runtime-config-use-case.unit.test.ts @@ -5,6 +5,7 @@ import { Manifest } from "../../../../../src/contexts/framework/domain/manifest. import { SettingsCapability } from "../../../../../src/contexts/tools/domain/capabilities/settings-capability.js"; import { cursor } from "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import { registerTool } from "../../../../../src/contexts/tools/domain/registry.js"; +import { InstallationFile } from "../../../../../src/kernel/file.js"; import { extractMergeEntries } from "../../../../../src/kernel/merge.js"; import type { AssetProvider } from "../../../../../src/kernel/ports/asset-provider.js"; import { @@ -30,6 +31,37 @@ function buildUseCase( } describe("InstallRuntimeConfigUseCase", () => { + it("writes a text config asset verbatim and records its content hash without warnings", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + const content = '{ "custom": true }\n'; + const assets = new StubAssetProvider({ "claude/settings.json": content }, deps.assetProvider); + + const result = await buildUseCase(deps, assets).execute({ + toolId: "claude", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + expect(deps.fs.getFile(join(PROJECT_ROOT, ".claude/settings.json"))).toBe(content); + expect(result.files).toStrictEqual([ + new InstallationFile({ + relativePath: ".claude/settings.json", + content, + hash: deps.hasher.hash(content), + }), + ]); + expect(result.fileCount).toBe(1); + expect(result.skipped).toBe(false); + expect(result.warnings).toStrictEqual([]); + expect(manifest.getToolFiles("claude")).toStrictEqual([ + { relativePath: ".claude/settings.json", hash: deps.hasher.hash(content) }, + ]); + }); + it("writes config on fresh install", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initProject(deps, PROJECT_ROOT); @@ -246,6 +278,38 @@ describe("InstallRuntimeConfigUseCase", () => { registerTool(cursor); }); + it("merges a single declared settings capability and records its keys", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + registerTool({ ...cursor, capabilities: { ...cursor.capabilities, settings: inline } }); + const manifest = (await deps.manifestRepo.load()) ?? Manifest.create(); + deps.fs.setFile(join(PROJECT_ROOT, ".cursor/aidd-static.json"), '{"user.setting": 7}'); + + const result = await buildUseCase(deps).execute({ + toolId: "cursor", + projectRoot: PROJECT_ROOT, + manifest, + force: false, + version: "1.0.0", + }); + + expect( + JSON.parse(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/aidd-static.json")) ?? "null") + ).toStrictEqual({ "user.setting": 7, static: true }); + expect(result.files.map((file) => file.relativePath)).toStrictEqual([ + ".cursor/settings.json", + ".cursor/aidd-static.json", + ]); + expect(result.warnings).toStrictEqual([]); + expect(manifest.getMergeFiles("cursor")).toStrictEqual([ + { + relativePath: ".cursor/aidd-static.json", + sectionKey: null, + entries: extractMergeEntries('{"user.setting":7,"static":true}', null, deps.hasher), + }, + ]); + }); + it("writes inline content and passes over a capability that only consumes", async () => { registerCursorWith([inline, consumesOnly]); const deps = await buildUnitDeps(PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/ownership/native-host-registration-gate.unit.test.ts b/cli/tests/contexts/framework/application/ownership/native-host-registration-gate.unit.test.ts new file mode 100644 index 000000000..288f3e076 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/native-host-registration-gate.unit.test.ts @@ -0,0 +1,301 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { describe, expect, it } from "vitest"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import type { NativeRegistrations } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSourceReader } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import type { NativePluginActivator } from "../../../../../src/contexts/tools/domain/ports/native-plugin-activator.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; + +const REF = "test-plugin@real-catalog"; +const HOST = "real-catalog"; +const claim = { ref: REF, dependents: [] }; +const registrations: NativeRegistrations = { + binary: "copilot", + marketplaces: [{ alias: "alias", hostName: HOST }], + pluginRefs: [REF], + pluginClaims: [claim], +}; + +describe("native host registration gate", () => { + it.each([undefined, { ...registrations, marketplaces: [{ alias: "other", hostName: "other" }] }])( + "requires a canonical catalogue matching the exact target ref", + async (recorded) => { + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/registry", + refs: new Map([[REF, { enabled: true }]]), + }), + }, + ], + ]) + ); + await expect(gate.requireTargetedUpdate("copilot", claim, "/A", recorded)).rejects.toThrow( + `copilot: ref '${REF}' has no canonical catalogue source proof; update refused.` + ); + expect(activator.updatedPlugins).toEqual([]); + } + ); + + it.each(["update", "removal"])( + "refuses %s when the canonical catalogue is absent on the host", + async (operation) => { + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/registry", + refs: new Map([[REF, { enabled: true }]]), + }), + }, + ], + ]), + new Map([ + ["copilot", { read: async () => ({ location: "/catalogue", entries: new Map() }) }], + ]) + ); + const promise = + operation === "update" + ? gate.requireTargetedUpdate("copilot", claim, "/A", registrations) + : gate.planMarketplaceRemoval( + "copilot", + registrations, + registrations.marketplaces[0], + "/A" + ); + await expect(promise).rejects.toThrow("copilot: catalogue source unproven."); + expect(activator.updatedPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + } + ); + + it("plans removal of an owned empty catalogue without inventing plugin claims", async () => { + const registration = { + alias: "alias", + hostName: HOST, + provenance: { kind: "registry" as const, source: "/owned/source" }, + }; + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([["copilot", { read: async () => ({ location: "/registry", refs: new Map() }) }]]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/catalogue", + entries: new Map([[HOST, registration.provenance]]), + }), + }, + ], + ]) + ); + const plan = await gate.planMarketplaceRemoval( + "copilot", + { binary: "copilot", marketplaces: [registration], pluginRefs: [] }, + registration, + "/A" + ); + expect(plan.refs).toEqual([]); + expect(plan.scopes).toEqual(new Map()); + expect(plan.activator).toBe(activator); + }); + + const provenCodex = { + binary: "codex", + marketplaces: [ + { + alias: "alias", + hostName: HOST, + provenance: { + kind: "effective-list" as const, + root: "/home/.codex/plugins/marketplaces/real-catalog", + sourceType: "local", + source: "/project-a/.aidd/cache/built", + }, + }, + ], + pluginRefs: [REF], + pluginClaims: [claim], + } satisfies NativeRegistrations; + const hostSource = (source: string): NativeMarketplaceSourceReader => ({ + read: async () => ({ + location: "codex plugin marketplace list --json", + entries: new Map([[HOST, { ...provenCodex.marketplaces[0].provenance, source }]]), + }), + }); + const reader = (refs?: ReadonlyMap) => + ({ + read: async () => ({ location: "/host/registry", refs }), + }) satisfies HostPluginRegistryReader; + const enabled = () => new Map([[REF, { enabled: true, scope: "user" as const }]]); + + it("refuses a Codex removal when a stale canonical claim points at a foreign current source", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", reader(enabled())]]), + new Map([["codex", hostSource("/foreign/catalog")]]) + ); + + await expect( + gate.planMarketplaceRemoval("codex", provenCodex, provenCodex.marketplaces[0], "/project-b") + ).rejects.toThrow(/current host source differs.*reconcile manually/); + expect(activator.removedMarketplaces).toEqual([]); + expect(activator.uninstalledPlugins).toEqual([]); + }); + + it("refuses a legacy Codex claim without exact source even if refs are enabled", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", reader(enabled())]]), + new Map([["codex", hostSource(provenCodex.marketplaces[0].provenance.source)]]) + ); + + await expect( + gate.planMarketplaceRemoval( + "codex", + { ...provenCodex, marketplaces: [{ alias: "alias", hostName: HOST }] }, + { alias: "alias", hostName: HOST }, + "/project-b" + ) + ).rejects.toThrow(/legacy claim has no source proof/); + expect(activator.removedMarketplaces).toEqual([]); + }); + + it("requires the verified target-update verb before contacting a Codex host", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", reader(enabled())]]) + ); + await expect(gate.requireTargetedUpdate("codex", claim, "/A")).rejects.toThrow( + /does not support targeted native plugin update/ + ); + expect(activator.updatedPlugins).toEqual([]); + }); + + it("requires each independent Copilot update capability and keeps the claim on refusal", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const withoutUpdate: NativePluginActivator = { + isAvailable: () => true, + addMarketplace: (source, scope) => activator.addMarketplace(source, scope), + enablesPlugins: () => activator.enablesPlugins(), + removeMarketplace: (name, scope) => activator.removeMarketplace(name, scope), + registrationState: () => activator.registrationState(), + upgradeMarketplaces: () => activator.upgradeMarketplaces(), + enablePlugin: (ref, scope) => activator.enablePlugin(ref, scope), + uninstallPlugin: (ref, scope) => activator.uninstallPlugin(ref, scope), + }; + for (const gate of [ + new NativeHostRegistrationGate(new Map(), new Map([["copilot", reader(enabled())]])), + new NativeHostRegistrationGate( + new Map([["copilot", new FakeNativePluginActivator({ available: false })]]), + new Map([["copilot", reader(enabled())]]) + ), + new NativeHostRegistrationGate( + new Map([["copilot", withoutUpdate]]), + new Map([["copilot", reader(enabled())]]) + ), + new NativeHostRegistrationGate(new Map([["copilot", activator]]), new Map()), + ]) { + await expect(gate.requireTargetedUpdate("copilot", claim, "/A")).rejects.toThrow( + /without its CLI and readable host registry/ + ); + } + for (const registry of [ + reader(), + reader(new Map()), + reader(new Map([[REF, { enabled: false }]])), + ]) { + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([["copilot", registry]]) + ); + await expect(gate.requireTargetedUpdate("copilot", claim, "/A")).rejects.toThrow( + /not provably enabled/ + ); + } + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([["copilot", reader(enabled())]]) + ); + await expect(gate.requireTargetedUpdate("copilot", claim, "/A", registrations)).rejects.toThrow( + /legacy claim has no source proof|source reader unavailable/ + ); + expect(activator.updatedPlugins).toEqual([]); + }); + + it("refuses catalogue removal unless CLI, registry, and every owned ref are proven", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const registration = registrations.marketplaces[0]; + for (const gate of [ + new NativeHostRegistrationGate(new Map(), new Map([["copilot", reader(enabled())]])), + new NativeHostRegistrationGate( + new Map([["copilot", new FakeNativePluginActivator({ available: false })]]), + new Map([["copilot", reader(enabled())]]) + ), + new NativeHostRegistrationGate(new Map([["copilot", activator]]), new Map()), + ]) { + await expect( + gate.planMarketplaceRemoval("copilot", registrations, registration, "/A") + ).rejects.toThrow(/CLI or host registry unavailable/); + } + for (const registry of [ + reader(), + reader(new Map()), + reader(new Map([[REF, { enabled: false }]])), + ]) { + const gate = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", registry]]), + new Map([["codex", hostSource(provenCodex.marketplaces[0].provenance.source)]]) + ); + await expect( + gate.planMarketplaceRemoval("codex", provenCodex, provenCodex.marketplaces[0], "/A") + ).rejects.toThrow(/host plugin registry|still enabled/); + } + }); + + it("does not adopt a foreign same-catalogue ref, and returns only exact owned host scopes", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const host = new Map(enabled()); + host.set("foreign@real-catalog", { enabled: true, scope: "project" }); + const gate = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", reader(host)]]), + new Map([["codex", hostSource(provenCodex.marketplaces[0].provenance.source)]]) + ); + await expect( + gate.planMarketplaceRemoval("codex", provenCodex, provenCodex.marketplaces[0], "/A") + ).rejects.toThrow(/foreign host ref 'foreign@real-catalog'/); + const safe = new NativeHostRegistrationGate( + new Map([["codex", activator]]), + new Map([["codex", reader(enabled())]]), + new Map([["codex", hostSource(provenCodex.marketplaces[0].provenance.source)]]) + ); + expect( + await safe.planMarketplaceRemoval("codex", provenCodex, provenCodex.marketplaces[0], "/A") + ).toMatchObject({ activator, refs: [claim] }); + const plan = await safe.planMarketplaceRemoval( + "codex", + provenCodex, + provenCodex.marketplaces[0], + "/A" + ); + expect(plan.scopes.get(REF)).toBe("user"); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts b/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts new file mode 100644 index 000000000..64e82c173 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/native-marketplace-source-proof.unit.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vitest"; +import { + assertNoForeignNativeRefs, + inspectNativeMarketplaceSource, +} from "../../../../../src/contexts/framework/application/ownership/native-marketplace-source-proof.js"; +import type { NativeMarketplaceRegistration } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, +} from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; + +const SOURCE: NativeMarketplaceSource = { + kind: "effective-list", + root: "/home/.codex/plugins/marketplaces/real-catalog", + sourceType: "local", + source: "/project-a/.aidd/cache/built", +}; +const claim: NativeMarketplaceRegistration = { + alias: "local-alias", + hostName: "real-catalog", + provenance: SOURCE, +}; + +function reader( + entries?: ReadonlyMap +): NativeMarketplaceSourceReader { + return { read: async () => ({ location: "codex marketplace list --json", entries }) }; +} + +describe("current native marketplace source proof", () => { + it.each([new Error("source permission denied"), "source permission denied"])( + "reports host source read failures as unproven without throwing", + async (error) => { + const sourceReader: NativeMarketplaceSourceReader = { + read: async () => { + throw error; + }, + }; + expect(await inspectNativeMarketplaceSource(sourceReader, "/project-b", claim)).toEqual({ + status: "unproven", + reason: + "Catalogue 'real-catalog': host source read failed (source permission denied); reconcile manually.", + }); + } + ); + + it.each([ + { location: "/host/catalogue", unreadable: "permission denied", reason: "permission denied" }, + { location: "/host/catalogue", reason: "host source unreadable at /host/catalogue" }, + ])( + "reports the exact unreadable host source diagnostic", + async ({ location, unreadable, reason }) => { + expect( + await inspectNativeMarketplaceSource( + { read: async () => ({ location, unreadable }) }, + "/project-b", + claim + ) + ).toEqual({ + status: "unproven", + reason: `Catalogue 'real-catalog': ${reason}; reconcile manually.`, + }); + } + ); + + it("refuses foreign same-catalogue refs even when a canonical owned ref is enabled", async () => { + let requestedRoot = ""; + const host: HostPluginRegistryReader = { + read: async (projectRoot) => { + requestedRoot = projectRoot; + return { + location: "host plugins", + refs: new Map([ + ["mine@real-catalog", { enabled: true }], + ["foreign@real-catalog", { enabled: true }], + ["other@other-catalog", { enabled: true }], + ]), + }; + }, + }; + await expect( + assertNoForeignNativeRefs(host, "real-catalog", new Set(["mine@real-catalog"]), "/project-b") + ).rejects.toThrow(/foreign host ref 'foreign@real-catalog'/); + expect(requestedRoot).toBe("/project-b"); + }); + + it("fails closed on unreadable or missing host plugin readers, but accepts explicit absence", async () => { + await expect( + assertNoForeignNativeRefs(undefined, "real-catalog", new Set(), "/project-b") + ).rejects.toThrow(/reader unavailable/); + await expect( + assertNoForeignNativeRefs( + { read: async () => ({ location: "host plugins", unreadable: "EACCES" }) }, + "real-catalog", + new Set(), + "/project-b" + ) + ).rejects.toThrow(/EACCES/); + await expect( + assertNoForeignNativeRefs( + { + read: async () => { + throw new Error("EACCES"); + }, + }, + "real-catalog", + new Set(), + "/project-b" + ) + ).rejects.toThrow(/real-catalog.*host plugin registry read failed.*EACCES/); + expect( + await assertNoForeignNativeRefs( + { read: async () => ({ location: "host plugins", absent: true }) }, + "real-catalog", + new Set(), + "/project-b" + ) + ).toEqual(new Map()); + }); + + it("returns exact host ref states when all same-catalogue refs are AIDD-owned", async () => { + const refs = new Map([ + ["mine@real-catalog", { enabled: true, scope: "user" as const }], + ["other@other-catalog", { enabled: true }], + ]); + expect( + await assertNoForeignNativeRefs( + { read: async () => ({ location: "host plugins", refs }) }, + "real-catalog", + new Set(["mine@real-catalog"]), + "/project-b" + ) + ).toEqual(refs); + }); + it("treats a readable missing name as fresh absence, not ownership", async () => { + const result = await inspectNativeMarketplaceSource(reader(new Map()), "/project-b", claim); + + expect(result.status).toBe("absent"); + }); + + it("refuses a named foreign source despite an old canonical same-name claim", async () => { + for (const changed of [ + { ...SOURCE, source: "/foreign/catalog" }, + { ...SOURCE, root: "/foreign/cache" }, + { ...SOURCE, sourceType: "git" }, + ]) { + const result = await inspectNativeMarketplaceSource( + reader(new Map([[claim.hostName, changed]])), + "/project-b", + claim + ); + + expect(result.status).toBe("unproven"); + expect(result.reason).toContain("real-catalog"); + expect(result.reason).toContain("current host source differs"); + } + }); + + it("refuses legacy claims, unknown named sources, and unreadable hosts", async () => { + const legacy = { alias: claim.alias, hostName: claim.hostName }; + for (const [sourceReader, registration] of [ + [reader(new Map([[claim.hostName, SOURCE]])), legacy], + [reader(new Map([[claim.hostName, null]])), claim], + [reader(), claim], + [undefined, claim], + ] as const) { + const result = await inspectNativeMarketplaceSource(sourceReader, "/project-b", registration); + + expect(result.status).toBe("unproven"); + expect(result.reason).toContain("real-catalog"); + } + }); + + it("owns only an exact source measured for the requesting project", async () => { + const result = await inspectNativeMarketplaceSource( + reader(new Map([[claim.hostName, SOURCE]])), + "/project-b", + claim + ); + + expect(result).toMatchObject({ status: "owned", current: SOURCE }); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/native-plugin-ownership.integration.test.ts b/cli/tests/contexts/framework/application/ownership/native-plugin-ownership.integration.test.ts new file mode 100644 index 000000000..ac0f9018a --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/native-plugin-ownership.integration.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it } from "vitest"; +import { + detachNativePluginRefs, + isMachineOwnedNativeRef, +} from "../../../../../src/contexts/framework/application/ownership/native-plugin-ownership.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +const TARGET = "sample-plugin@real-catalog"; +const OTHER = "sample-plugin@other-catalog"; + +function fixture() { + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { alias: "real-catalog", hostName: "real-catalog" }, + { alias: "other-catalog", hostName: "other-catalog" }, + ], + pluginRefs: [TARGET, OTHER], + pluginClaims: [ + { ref: TARGET, dependents: ["/A", "/B"] }, + { ref: OTHER, dependents: ["/A", "/C"] }, + ], + }); + return { repo: new InMemoryManifestRepository(machine), fs: new InMemoryFileAdapter() }; +} + +describe("one project's exact native host ref claim", () => { + it("recognizes only canonical machine claims, not another catalogue or missing manifests", async () => { + const f = fixture(); + expect(await isMachineOwnedNativeRef(f.repo, "codex", TARGET)).toBe(true); + expect(await isMachineOwnedNativeRef(f.repo, "codex", "foreign@real-catalog")).toBe(false); + expect(await isMachineOwnedNativeRef(f.repo, "copilot", TARGET)).toBe(false); + expect(await isMachineOwnedNativeRef(new InMemoryManifestRepository(), "codex", TARGET)).toBe( + false + ); + expect(await isMachineOwnedNativeRef(undefined, "codex", TARGET)).toBe(false); + }); + + it("detaches A's realpath only from the exact ref, preserving B and other catalogue claims", async () => { + const f = fixture(); + f.fs.setSymlink("/link-to-A", "/A"); + await detachNativePluginRefs(f.repo, f.fs, "/link-to-A", new Map([["codex", [TARGET]]])); + expect(f.repo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: TARGET, dependents: ["/B"] }, + { ref: OTHER, dependents: ["/A", "/C"] }, + ]); + expect(f.repo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + TARGET, + OTHER, + ]); + expect(f.repo.saveCount).toBe(1); + }); + + it("does not invent a claim or save for an unrelated project or foreign ref", async () => { + for (const [root, ref] of [ + ["/C", TARGET], + ["/A", "foreign@real-catalog"], + ] as const) { + const f = fixture(); + await detachNativePluginRefs(f.repo, f.fs, root, new Map([["codex", [ref]]])); + expect(f.repo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: TARGET, dependents: ["/A", "/B"] }, + { ref: OTHER, dependents: ["/A", "/C"] }, + ]); + expect(f.repo.saveCount).toBe(0); + } + }); + + it("does not act with no repo, no refs, no manifest, or no host claim record", async () => { + const fs = new InMemoryFileAdapter(); + await detachNativePluginRefs(undefined, fs, "/A", new Map([["codex", [TARGET]]])); + const f = fixture(); + await detachNativePluginRefs(f.repo, fs, "/A", new Map()); + await detachNativePluginRefs( + new InMemoryManifestRepository(), + fs, + "/A", + new Map([["codex", [TARGET]]]) + ); + await detachNativePluginRefs(f.repo, fs, "/A", new Map([["copilot", [TARGET]]])); + expect(f.repo.saveCount).toBe(0); + }); + + it("holds exclusive access while loading the latest machine claims", async () => { + const f = fixture(); + let entered = false; + const repo = { + path: f.repo.path, + load: () => { + expect(entered).toBe(true); + return f.repo.load(); + }, + save: (manifest: Manifest) => f.repo.save(manifest), + delete: () => f.repo.delete(), + withExclusiveAccess: async (action: () => Promise): Promise => { + entered = true; + try { + return await action(); + } finally { + entered = false; + } + }, + }; + await detachNativePluginRefs(repo, f.fs, "/A", new Map([["codex", [TARGET]]])); + expect( + f.repo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims?.[0]?.dependents + ).toEqual(["/B"]); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/project-path-boundary.unit.test.ts b/cli/tests/contexts/framework/application/ownership/project-path-boundary.unit.test.ts new file mode 100644 index 000000000..78be76c19 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/project-path-boundary.unit.test.ts @@ -0,0 +1,78 @@ +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { assertProjectPathWithinRoot } from "../../../../../src/contexts/framework/application/ownership/project-path-boundary.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; + +const ROOT = resolve("/project"); + +describe("project-local mutation boundary", () => { + it.each([ROOT, "/project/../foreign/file", "/project-sibling/file", "/foreign/file"])( + "refuses a path outside the strict project subtree: %s", + async (path) => { + await expect( + assertProjectPathWithinRoot(new FaultingFileAdapter(), ROOT, path) + ).rejects.toThrow(/escapes project root/); + } + ); + + it("allows a new nested file only after resolving its nearest existing project parent", async () => { + const fs = new FaultingFileAdapter(); + for (const path of ["/project/new/deep/file", "/project/new/deep", "/project/new"]) + fs.failOn("realpath", path, errnoError("ENOENT")); + fs.setFile("/project/marker", "project bytes"); + + await expect( + assertProjectPathWithinRoot(fs, ROOT, "/project/new/deep/file") + ).resolves.toBeUndefined(); + expect(fs.getFile("/project/marker")).toBe("project bytes"); + }); + + it("refuses a missing file below a parent resolving outside the project", async () => { + const fs = new FaultingFileAdapter(); + fs.setSymlink("/project/link", "/foreign"); + fs.setFile("/foreign/new/marker", "foreign bytes"); + fs.failOn("realpath", "/project/link/new/file", errnoError("ENOENT")); + + await expect(assertProjectPathWithinRoot(fs, ROOT, "/project/link/new/file")).rejects.toThrow( + /resolves outside project root/ + ); + expect(fs.getFile("/foreign/new/marker")).toBe("foreign bytes"); + }); + + it("propagates an unreadable path instead of treating it as a missing child", async () => { + const fs = new FaultingFileAdapter(); + const error = errnoError("EACCES"); + fs.failOn("realpath", "/project/private/file", error); + + await expect(assertProjectPathWithinRoot(fs, ROOT, "/project/private/file")).rejects.toBe( + error + ); + }); + + it("refuses a project root that disappears while a missing child is being resolved", async () => { + const fs = new FaultingFileAdapter(); + let rootRead = false; + vi.spyOn(fs, "realpath").mockImplementation(async (path) => { + if (path === ROOT && !rootRead) { + rootRead = true; + return ROOT; + } + throw errnoError("ENOENT"); + }); + + await expect(assertProjectPathWithinRoot(fs, ROOT, "/project/new/file")).rejects.toThrow( + /Project root.*disappeared/ + ); + }); + + it("allows a child of a project whose own root is a symlink", async () => { + const fs = new FaultingFileAdapter(); + fs.setSymlink(ROOT, "/canonical-project"); + fs.setFile("/canonical-project/file", "project bytes"); + + await expect(assertProjectPathWithinRoot(fs, ROOT, "/project/file")).resolves.toBeUndefined(); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-marketplace-remove.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-marketplace-remove.integration.test.ts new file mode 100644 index 000000000..8ffe7500e --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-marketplace-remove.integration.test.ts @@ -0,0 +1,620 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { + FRAMEWORK_MARKETPLACE_NAME, + Marketplace, +} from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import type { MarketplaceRegistry } from "../../../../../src/contexts/distribution/domain/ports/marketplace-registry.js"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import { UserMarketplaceRemoveUseCase } from "../../../../../src/contexts/framework/application/ownership/user-marketplace-remove-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, +} from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const ALIAS = "project-alias"; +const HOST = "real-catalog"; +const REF = `test-plugin@${HOST}`; +const OPTIONS = { name: ALIAS, projectRoot: "/A", autoConfirm: true, scope: "user" as const }; + +function proofFor(): NativeMarketplaceSource { + return { + kind: "effective-list", + root: "/exact/host/root", + sourceType: "github", + source: "ai-driven-dev/framework", + }; +} + +function sourceReader(): NativeMarketplaceSourceReader { + return { + read: async () => ({ + location: "/host/catalogues", + entries: new Map([[HOST, proofFor()]]), + }), + }; +} + +for (const toolId of ["codex"] as const) { + describe(`${toolId} targeted user marketplace removal`, () => { + async function fixture( + refs: ReadonlyMap, + options: { + dependents?: readonly string[]; + available?: boolean; + failOnUninstall?: boolean; + throwOnRemove?: boolean; + } = {} + ) { + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [{ alias: ALIAS, hostName: HOST, provenance: proofFor() }], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [...(options.dependents ?? [])] }], + }); + const repo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: ALIAS, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "user", + addedAt: "2026-09-01T00:00:00.000Z", + }) + ); + const activator = new FakeNativePluginActivator({ + available: options.available ?? true, + failOnUninstall: options.failOnUninstall ? [REF] : [], + throwOnRemove: options.throwOnRemove ?? false, + }); + const reader: HostPluginRegistryReader = { + read: async () => ({ location: "/host/registry", refs }), + }; + const useCase = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + repo, + registry, + new NativeHostRegistrationGate( + new Map([[toolId, activator]]), + new Map([[toolId, reader]]), + new Map([[toolId, sourceReader()]]) + ) + ); + return { repo, registry, activator, useCase }; + } + + const enabled = () => new Map([[REF, { enabled: true, scope: "user" as const }]]); + + it("refuses the shared framework catalogue before any registry or host access", async () => { + const f = await fixture(enabled()); + const list = vi.spyOn(f.registry, "list"); + await expect( + f.useCase.execute({ ...OPTIONS, name: FRAMEWORK_MARKETPLACE_NAME }) + ).rejects.toThrow(/shared by every project.*aidd clean --scope user/); + expect(list).not.toHaveBeenCalled(); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.repo.saveCount).toBe(0); + }); + + it("selects the requested user catalogue rather than the first unrelated catalogue", async () => { + const f = await fixture(enabled()); + const target = (await f.registry.list("/A"))[0]; + if (target === undefined) throw new Error("fixture lacks target catalogue"); + await f.registry.delete("/A", ALIAS, "user"); + const other = Marketplace.create({ + name: "other-alias", + source: { kind: "github", repo: "other/catalogue" }, + scope: "user", + addedAt: "2026-09-02T00:00:00.000Z", + }); + await f.registry.save("/A", other); + await f.registry.save("/A", target); + expect((await f.registry.list("/A"))[0]).toEqual(other); + const deletion = vi.spyOn(f.registry, "delete"); + const hostRemoval = vi.spyOn(f.activator, "removeMarketplace"); + + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ + marketplace: { name: ALIAS, scope: "user" }, + removedPluginCount: 1, + }); + expect(deletion).toHaveBeenCalledExactlyOnceWith("/A", ALIAS, "user"); + expect(hostRemoval).toHaveBeenCalledExactlyOnceWith(HOST, "user"); + expect(await f.registry.list("/A")).toEqual([other]); + expect(f.repo.saveCount).toBe(1); + }); + + it("defaults native uninstall to user only when the proven host ref has no scope", async () => { + const f = await fixture(new Map([[REF, { enabled: true }]])); + await f.useCase.execute(OPTIONS); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toEqual(["user"]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + }); + + it("removes a proven empty catalogue without inventing missing plugin claims", async () => { + const f = await fixture(new Map()); + f.repo.getCurrent()?.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [{ alias: ALIAS, hostName: HOST, provenance: proofFor() }], + pluginRefs: [], + }); + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ removedPluginCount: 0 }); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([HOST]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)).toMatchObject({ + marketplaces: [], + pluginRefs: [], + pluginClaims: [], + }); + expect(await f.registry.list("/A")).toEqual([]); + }); + + it("refuses catalogue deletion when the canonical user ledger is missing", async () => { + const f = await fixture(enabled()); + const before = await f.registry.list("/A"); + await f.repo.delete(); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/no user manifest/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(await f.registry.list("/A")).toEqual(before); + expect(f.repo.saveCount).toBe(0); + }); + + it("refuses B's live claim, including with autoConfirm", async () => { + const f = await fixture(enabled(), { dependents: ["/B"] }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow( + /user-scope marketplace 'project-alias'.*active projects.*\/B/ + ); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect( + f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims?.[0]?.dependents + ).toEqual(["/B"]); + }); + + it("never treats a project-scope catalogue with the same alias as a user-scope target", async () => { + const f = await fixture(enabled()); + await f.registry.save( + "/A", + Marketplace.create({ + name: ALIAS, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "project", + addedAt: "2026-09-02T00:00:00.000Z", + }) + ); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/Marketplace.*not registered/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("rechecks the user catalogue source after taking the machine lock", async () => { + const f = await fixture(enabled()); + const lockedRepo = { + path: f.repo.path, + load: () => f.repo.load(), + save: (manifest: Manifest) => f.repo.save(manifest), + delete: () => f.repo.delete(), + withExclusiveAccess: async (action: () => Promise): Promise => { + await f.registry.save( + "/A", + Marketplace.create({ + name: ALIAS, + source: { kind: "github", repo: "foreign/changed" }, + scope: "user", + addedAt: "2026-09-02T00:00:00.000Z", + }) + ); + return action(); + }, + }; + const useCase = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + lockedRepo, + f.registry, + new NativeHostRegistrationGate(new Map([[toolId, f.activator]]), new Map()) + ); + await expect(useCase.execute(OPTIONS)).rejects.toThrow( + /changed while waiting for the machine lock/ + ); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("requires a canonical user manifest before uninstalling any host ref", async () => { + const f = await fixture(enabled()); + const absent = new InMemoryManifestRepository(); + const useCase = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + absent, + f.registry, + new NativeHostRegistrationGate(new Map([[toolId, f.activator]]), new Map()) + ); + await expect(useCase.execute(OPTIONS)).rejects.toThrow(/no user manifest/); + expect(f.activator.uninstalledPlugins).toEqual([]); + }); + + it("refuses a foreign ref in the same host catalogue without mutating either ref", async () => { + const refs = new Map([ + ...enabled(), + [`foreign@${HOST}`, { enabled: true, scope: "user" as const }], + ]); + const f = await fixture(refs); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow( + /foreign host ref.*foreign@real-catalog/ + ); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.activator.removedMarketplaces).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("refuses an unreadable host registry before any host mutation", async () => { + const f = await fixture(enabled()); + const unreadable = new NativeHostRegistrationGate( + new Map([[toolId, f.activator]]), + new Map([ + [ + toolId, + { read: async () => ({ location: "/host/registry", unreadable: "permission denied" }) }, + ], + ]), + new Map([[toolId, sourceReader()]]) + ); + const useCase = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + f.repo, + f.registry, + unreadable + ); + await expect(useCase.execute(OPTIONS)).rejects.toThrow(/host plugin registry unreadable/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("refuses an unavailable host CLI", async () => { + const f = await fixture(enabled(), { available: false }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/CLI or host registry unavailable/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("refuses a missing or disabled owned ref", async () => { + for (const refs of [ + new Map(), + new Map([[REF, { enabled: false, scope: "user" as const }]]), + ]) { + const f = await fixture(refs); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/still enabled/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + } + }); + + it("keeps the canonical claim and registry if the host refuses unregister", async () => { + const f = await fixture(enabled(), { failOnUninstall: true }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/not installed/); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeDefined(); + expect(f.activator.removedMarketplaces).toEqual([]); + }); + + it("retains the claim and catalogue record if catalogue removal fails after ref uninstall", async () => { + const f = await fixture(enabled(), { throwOnRemove: true }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/marketplace remove.*failed/); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeDefined(); + }); + + it("retains the machine claim if the local catalogue registry refuses deletion", async () => { + const f = await fixture(enabled()); + const registry: MarketplaceRegistry = { + list: (root) => f.registry.list(root), + save: (root, marketplace) => f.registry.save(root, marketplace), + delete: async () => { + throw new Error("registry delete failed"); + }, + updateLastFetched: (root, name, scope, when) => + f.registry.updateLastFetched(root, name, scope, when), + updateVersion: (root, name, scope, version) => + f.registry.updateVersion(root, name, scope, version), + }; + const useCase = new UserMarketplaceRemoveUseCase( + new InMemoryFileAdapter(), + { + path: f.repo.path, + load: async () => { + const current = await f.repo.load(); + return current === null ? null : Manifest.fromJSON(current.toJSON()); + }, + save: (manifest) => f.repo.save(manifest), + delete: () => f.repo.delete(), + }, + registry, + new NativeHostRegistrationGate( + new Map([[toolId, f.activator]]), + new Map([ + [toolId, { read: async () => ({ location: "/host/registry", refs: enabled() }) }], + ]), + new Map([[toolId, sourceReader()]]) + ) + ); + await expect(useCase.execute(OPTIONS)).rejects.toThrow(/registry delete failed/); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeDefined(); + }); + + it("unregisters the exact ref and real host catalogue after B detaches", async () => { + const f = await fixture(enabled()); + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ removedPluginCount: 1 }); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toEqual(["user"]); + expect(f.activator.removedMarketplaces).toEqual([HOST]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeUndefined(); + }); + + it("uses the host's recorded ref scope rather than guessing user for native uninstall", async () => { + const f = await fixture(new Map([[REF, { enabled: true, scope: "project" as const }]])); + await f.useCase.execute(OPTIONS); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toEqual(["project"]); + expect(f.activator.removedMarketplaces).toEqual([HOST]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + }); + + it("preserves a different canonical host catalogue and its dependent ref", async () => { + const f = await fixture(enabled()); + const current = f.repo.getCurrent(); + const registration = current?.getNativeRegistrations(toolId); + expect(registration).toBeDefined(); + if (current === null || registration === undefined) throw new Error("fixture lacks registry"); + current.setNativeRegistrations(toolId, { + ...registration, + marketplaces: [ + ...registration.marketplaces, + { alias: "other-alias", hostName: "other-catalog" }, + ], + pluginRefs: [...registration.pluginRefs, "other-plugin@other-catalog"], + pluginClaims: [ + ...(registration.pluginClaims ?? []), + { ref: "other-plugin@other-catalog", dependents: ["/B"] }, + ], + }); + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ removedPluginCount: 1 }); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.removedMarketplaces).toEqual([HOST]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: "other-plugin@other-catalog", dependents: ["/B"] }, + ]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([ + "other-plugin@other-catalog", + ]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.marketplaces).toEqual([ + { alias: "other-alias", hostName: "other-catalog" }, + ]); + }); + }); +} + +describe("Copilot 1.0.83 targeted user marketplace removal", () => { + it("refuses an otherwise claimed and enabled catalogue without a verified current source, preserving settings and claims", async () => { + const machine = Manifest.create(); + machine.addTool("copilot", "1.0.83", []); + machine.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [ + { + alias: ALIAS, + hostName: HOST, + provenance: { kind: "registry", source: "/previous/aidd/source" }, + }, + ], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [] }], + }); + const repo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + const marketplace = Marketplace.create({ + name: ALIAS, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "user", + addedAt: "2026-09-01T00:00:00.000Z", + }); + await registry.save("/A", marketplace); + const fs = new InMemoryFileAdapter(); + fs.setFile("/A/copilot-user-note.md", "user bytes"); + const activator = new FakeNativePluginActivator({ available: true }); + const gate = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/host/registry", + refs: new Map([[REF, { enabled: true, scope: "user" as const }]]), + }), + }, + ], + ]) + ); + const useCase = new UserMarketplaceRemoveUseCase(fs, repo, registry, gate); + + await expect(useCase.execute(OPTIONS)).rejects.toThrow(/host source reader unavailable/); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(activator.removedMarketplaces).toEqual([]); + expect(repo.saveCount).toBe(0); + expect(repo.getCurrent()?.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + expect(repo.getCurrent()?.getNativeRegistrations("copilot")?.marketplaces).toEqual([ + { + alias: ALIAS, + hostName: HOST, + provenance: { kind: "registry", source: "/previous/aidd/source" }, + }, + ]); + expect(await registry.list("/A")).toEqual([marketplace]); + expect(fs.getFile("/A/copilot-user-note.md")).toBe("user bytes"); + }); +}); + +describe("Cursor targeted user marketplace removal", () => { + async function fixture( + options: { claim?: boolean; dependents?: readonly string[]; escape?: boolean } = {} + ) { + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + const path = "sample-plugin/skills/demo/SKILL.md"; + if (options.claim !== false) { + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: { [path]: createHash("md5").update("bytes").digest("hex") }, + scope: "user", + marketplace: ALIAS, + dependents: [...(options.dependents ?? [])], + }) + ); + } + const repo = new InMemoryManifestRepository(machine); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + "/A", + Marketplace.create({ + name: ALIAS, + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "user", + addedAt: "2026-09-01T00:00:00.000Z", + }) + ); + const fs = new InMemoryFileAdapter(); + const pluginDir = join(homedir(), ".cursor/plugins/local/sample-plugin"); + const foreign = "/foreign/sample-plugin"; + fs.setFile(join(options.escape ? foreign : pluginDir, "skills/demo/SKILL.md"), "bytes"); + if (options.escape) fs.setSymlink(pluginDir, foreign); + const useCase = new UserMarketplaceRemoveUseCase( + fs, + repo, + registry, + new NativeHostRegistrationGate(new Map(), new Map()) + ); + return { repo, registry, fs, useCase, path, pluginDir, foreign }; + } + + it("refuses a foreign user catalogue with no canonical plugin claim", async () => { + const f = await fixture({ claim: false }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/Cannot prove AIDD ownership/); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeDefined(); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toEqual([]); + }); + + it("refuses B's live file claim before deleting any bytes", async () => { + const f = await fixture({ dependents: ["/B"] }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/active projects.*\/B/); + expect(f.fs.getFile(join(f.pluginDir, "skills/demo/SKILL.md"))).toBe("bytes"); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + }); + + it("refuses a symlinked plugin directory outside the user boundary", async () => { + const f = await fixture({ escape: true }); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/tracked file outside its boundary/); + expect(f.fs.getFile(join(f.foreign, "skills/demo/SKILL.md"))).toBe("bytes"); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + }); + + it("does not sweep another catalogue or a project-scoped record from the machine manifest", async () => { + const f = await fixture(); + f.repo.getCurrent()?.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "other-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + marketplace: "other-alias", + dependents: [], + }) + ); + f.repo.getCurrent()?.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "project-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "project", + marketplace: ALIAS, + dependents: [], + }) + ); + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ removedPluginCount: 1 }); + expect( + f.repo + .getCurrent() + ?.getPlugins("cursor") + .map((plugin) => plugin.name) + ).toEqual(["other-plugin", "project-plugin"]); + }); + + it("deletes exactly the owned user files and canonical record", async () => { + const f = await fixture(); + expect(await f.useCase.execute(OPTIONS)).toMatchObject({ removedPluginCount: 1 }); + expect(f.fs.getFile(join(f.pluginDir, "skills/demo/SKILL.md"))).toBeUndefined(); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toEqual([]); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeUndefined(); + }); + + it("refuses to remove a user-edited script and leaves marketplace and machine claim retryable", async () => { + const f = await fixture(); + const path = join(f.pluginDir, "skills/demo/SKILL.md"); + f.fs.setFile(path, "user-edited bytes"); + await expect(f.useCase.execute(OPTIONS)).rejects.toThrow(/edited.*SKILL.md|SKILL.md.*edited/); + expect(f.fs.getFile(path)).toBe("user-edited bytes"); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + expect((await f.registry.list("/A")).find((entry) => entry.name === ALIAS)).toBeDefined(); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts new file mode 100644 index 000000000..909d1664a --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-file-updater.unit.test.ts @@ -0,0 +1,90 @@ +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; + +function fixture(installedVersion = "0.0.1") { + const fs = new InMemoryFileAdapter(); + const hasher = new DeterministicHasher(); + const logger = new CapturingLogger(); + const content = "# Updated skill"; + const skill = { relativePath: "skills/demo/SKILL.md", content }; + const dist = new PluginDistribution({ + manifest: { name: "sample-plugin", version: "1.0.0" }, + format: "claude", + files: [skill], + components: { commands: [], agents: [], rules: [], skills: [skill], hooks: [], mcp: [] }, + }); + const updater = new UserPluginFileUpdater( + fs, + new UserPluginDistributionLoader(new FixturePluginFetcher(), { read: async () => dist }), + hasher + ); + const plugin = InstalledPlugin.fromMetadata( + "sample-plugin", + installedVersion, + { kind: "local", path: "/source" }, + false, + "user" + ).withDependents(["/A", "/B"]); + const base = join(homedir(), ".cursor/plugins/local"); + return { fs, hasher, logger, updater, plugin, base, content }; +} + +describe("UserPluginFileUpdater public update contract", () => { + it.each(["1.0.0", "2.0.0"])( + "leaves version %s unchanged when no upgrade exists", + async (version) => { + const f = fixture(version); + expect(await f.updater.update(f.plugin, "cursor", "/A", f.logger)).toBeNull(); + expect(await f.fs.fileExists(join(f.base, "sample-plugin/skills/demo/SKILL.md"))).toBe(false); + expect(f.plugin.version).toBe(version); + expect(f.plugin.dependents).toEqual(["/A", "/B"]); + } + ); + + it("applies a planned upgrade through the public convenience method and preserves dependents", async () => { + const f = fixture(); + const result = await f.updater.update(f.plugin, "cursor", "/A", f.logger); + expect(result?.version).toBe("1.0.0"); + expect(result?.scope).toBe("user"); + expect(result?.dependents).toEqual(["/A", "/B"]); + expect(await f.fs.readFile(join(f.base, "sample-plugin/skills/demo/SKILL.md"))).toBe(f.content); + expect(result?.files.get("sample-plugin/skills/demo/SKILL.md")).toBe( + f.hasher.hash(f.content).value + ); + }); + + it("refuses unsafe recorded files with an actionable diagnostic before writes", async () => { + const f = fixture(); + const unsafe = f.plugin.withFiles( + new Map([["../foreign.txt", f.hasher.hash("foreign").value]]) + ); + await f.fs.writeFile(join(f.base, "../foreign.txt"), "foreign"); + await expect(f.updater.update(unsafe, "cursor", "/A", f.logger)).rejects.toThrow( + "cursor: 'sample-plugin' has unsafe recorded files; update refused without dropping its machine claim." + ); + expect(await f.fs.readFile(join(f.base, "../foreign.txt"))).toBe("foreign"); + expect(await f.fs.fileExists(join(f.base, "sample-plugin/skills/demo/SKILL.md"))).toBe(false); + }); + + it("rechecks planned new-file containment when a parent becomes a symlink", async () => { + const f = fixture(); + const plan = await f.updater.planUpdate(f.plugin, "cursor", "/A", f.logger); + if (plan === null) throw new Error("Expected upgrade plan"); + await f.fs.writeFile("/foreign/skills/demo/SKILL.md", "foreign"); + f.fs.setSymlink(join(f.base, "sample-plugin"), "/foreign"); + await expect(f.updater.applyUpdate(plan, f.logger)).rejects.toThrow( + `cursor: 'sample-plugin' directory is not safely inside ${f.base}; update refused.` + ); + expect(await f.fs.readFile("/foreign/skills/demo/SKILL.md")).toBe("foreign"); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-ownership.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-ownership.integration.test.ts new file mode 100644 index 000000000..675c31069 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-ownership.integration.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import { detachUserPlugin } from "../../../../../src/contexts/framework/application/ownership/user-plugin-ownership.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +function fixture(scope: "project" | "user" = "user", dependents: readonly string[] = ["/A", "/B"]) { + const manifest = Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + manifest.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope, + dependents: [...dependents], + }) + ); + return { repo: new InMemoryManifestRepository(manifest), fs: new InMemoryFileAdapter() }; +} + +describe("one project's canonical user plugin claim", () => { + it("detaches only A's realpath, retaining B and the owned plugin bytes/record", async () => { + const f = fixture(); + f.fs.setSymlink("/link-to-A", "/A"); + await detachUserPlugin(f.repo, f.fs, "cursor", "sample-plugin", "/link-to-A"); + expect(f.repo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/B"]); + expect(f.repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + expect(f.repo.saveCount).toBe(1); + }); + + it("does not drop B or save for an unrelated project", async () => { + const f = fixture(); + await detachUserPlugin(f.repo, f.fs, "cursor", "sample-plugin", "/C"); + expect(f.repo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/A", "/B"]); + expect(f.repo.saveCount).toBe(0); + }); + + it("does not rewrite a machine record of another name or project scope", async () => { + for (const [scope, name] of [ + ["user", "other-plugin"], + ["project", "sample-plugin"], + ] as const) { + const f = fixture(scope); + await detachUserPlugin(f.repo, f.fs, "cursor", name, "/A"); + expect(f.repo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/A", "/B"]); + expect(f.repo.saveCount).toBe(0); + } + }); + + it("ignores a missing repo or manifest without inventing ownership", async () => { + const fs = new InMemoryFileAdapter(); + await detachUserPlugin(undefined, fs, "cursor", "sample-plugin", "/A"); + const repo = new InMemoryManifestRepository(); + await detachUserPlugin(repo, fs, "cursor", "sample-plugin", "/A"); + expect(repo.getCurrent()).toBeNull(); + expect(repo.saveCount).toBe(0); + }); + + it("loads the latest machine record under exclusive access before detaching", async () => { + const f = fixture(); + let entered = false; + const locked = { + path: f.repo.path, + load: () => { + expect(entered).toBe(true); + return f.repo.load(); + }, + save: (manifest: Manifest) => f.repo.save(manifest), + delete: () => f.repo.delete(), + withExclusiveAccess: async (action: () => Promise): Promise => { + entered = true; + try { + return await action(); + } finally { + entered = false; + } + }, + }; + await detachUserPlugin(locked, f.fs, "cursor", "sample-plugin", "/A"); + expect(f.repo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/B"]); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-remove.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-remove.integration.test.ts new file mode 100644 index 000000000..77b6cedf3 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-remove.integration.test.ts @@ -0,0 +1,417 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; +import { userScopeFilesSafeToDelete } from "../../../../../src/contexts/framework/application/shared/user-scope-plugin-files.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import type { NativeMarketplaceSource } from "../../../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import { resolveHomeDir } from "../../../../../src/kernel/reading/home-dir.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; + +const REF = "test-plugin@real-catalog"; +const OTHER = "test-plugin@other-catalog"; + +function sourceFor(toolId: "codex" | "copilot", catalogue: string): NativeMarketplaceSource { + return toolId === "codex" + ? { + kind: "effective-list", + root: `/host/${catalogue}`, + sourceType: "github", + source: `ai-driven-dev/${catalogue}`, + } + : { kind: "registry", source: `https://github.com/ai-driven-dev/${catalogue}.git` }; +} + +for (const toolId of ["codex", "copilot"] as const) { + describe(`${toolId} targeted user plugin removal`, () => { + function fixture( + options: { + refs?: readonly string[]; + dependents?: readonly string[]; + hostRefs?: ReadonlyMap; + available?: boolean; + failOnUninstall?: boolean; + sourceMismatch?: boolean; + sourceUnavailable?: boolean; + legacy?: boolean; + hostRegistryUnavailable?: boolean; + hostRegistryUnreadable?: boolean; + } = {} + ) { + const refs = options.refs ?? [REF]; + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: refs.map((ref) => ({ + alias: ref.split("@")[1], + hostName: ref.split("@")[1], + ...(options.legacy ? {} : { provenance: sourceFor(toolId, ref.split("@")[1]) }), + })), + pluginRefs: [...refs], + pluginClaims: refs.map((ref) => ({ ref, dependents: [...(options.dependents ?? [])] })), + }); + const repo = new InMemoryManifestRepository(machine); + const fs = new InMemoryFileAdapter(); + const activator = new FakeNativePluginActivator({ + available: options.available ?? true, + failOnUninstall: options.failOnUninstall ? [REF] : [], + }); + const hostRefs = + options.hostRefs ?? + new Map(refs.map((ref) => [ref, { enabled: true, scope: "user" as const }])); + const reader: HostPluginRegistryReader = { + read: async () => + options.hostRegistryUnreadable + ? { location: "/host/registry", unreadable: "invalid host registry" } + : { location: "/host/registry", refs: hostRefs }, + }; + const sources = { + read: async () => ({ + location: "/host/catalogues", + entries: new Map( + refs.map((ref) => { + const catalogue = ref.split("@")[1]; + return [ + catalogue, + options.sourceMismatch && ref === REF + ? sourceFor(toolId, "foreign-source") + : sourceFor(toolId, catalogue), + ] as const; + }) + ), + }), + }; + const remove = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new Map([[toolId, activator]]), + options.hostRegistryUnavailable ? new Map() : new Map([[toolId, reader]]), + undefined, + new InMemoryMarketplaceRegistry(), + repo, + options.sourceUnavailable ? new Map() : new Map([[toolId, sources]]) + ); + return { machine, repo, fs, activator, remove }; + } + + const execute = (remove: PluginRemoveUseCase, pluginName: string) => + remove.execute({ pluginName, toolIds: [toolId], projectRoot: "/A", scope: "user" }); + + it.each([{ hostRegistryUnavailable: true }, { hostRegistryUnreadable: true }])( + "retains all ownership data when the host registry cannot be measured: %j", + async (options) => { + const f = fixture(options); + const before = f.machine.toJSON(); + + await expect(execute(f.remove, REF)).rejects.toThrow(/unavailable|still enabled/); + + expect(f.activator.uninstalledPlugins).toStrictEqual([]); + expect(f.repo.saveCount).toBe(0); + expect(f.machine.toJSON()).toStrictEqual(before); + } + ); + + it.each(["missing", "duplicate"] as const)( + "refuses a %s exact catalogue proof before unregistering", + async (mode) => { + const f = fixture(); + f.machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: + mode === "missing" + ? [] + : [ + { + alias: "first", + hostName: "real-catalog", + provenance: sourceFor(toolId, "real-catalog"), + }, + { + alias: "second", + hostName: "real-catalog", + provenance: sourceFor(toolId, "real-catalog"), + }, + ], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [] }], + }); + const before = f.machine.toJSON(); + + await expect(execute(f.remove, REF)).rejects.toThrow( + /exact canonical catalogue source proof/ + ); + + expect(f.activator.uninstalledPlugins).toStrictEqual([]); + expect(f.repo.saveCount).toBe(0); + expect(f.machine.toJSON()).toStrictEqual(before); + } + ); + + it("reads dependents inside the repository's exclusive-access boundary", async () => { + const f = fixture(); + let acquired = false; + Object.assign(f.repo, { + withExclusiveAccess: async (operation: () => Promise) => { + acquired = true; + f.machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: [ + { + alias: "real-catalog", + hostName: "real-catalog", + provenance: sourceFor(toolId, "real-catalog"), + }, + ], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: ["/B"] }], + }); + return operation(); + }, + }); + + await expect(execute(f.remove, REF)).rejects.toThrow(/active projects.*\/B/); + + expect(acquired).toBe(true); + expect(f.activator.uninstalledPlugins).toStrictEqual([]); + expect(f.repo.saveCount).toBe(0); + expect(f.machine.getNativeRegistrations(toolId)?.pluginClaims).toStrictEqual([ + { ref: REF, dependents: ["/B"] }, + ]); + }); + + it("uses user scope when the enabled host ref has no scope label", async () => { + const f = fixture({ hostRefs: new Map([[REF, { enabled: true }]]) }); + + await execute(f.remove, REF); + + expect(f.activator.uninstalledPlugins).toStrictEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toStrictEqual(["user"]); + expect(f.machine.getNativeRegistrations(toolId)?.pluginClaims).toStrictEqual([]); + expect(f.repo.saveCount).toBe(1); + }); + + it("refuses B's live exact claim before contacting the host", async () => { + const f = fixture({ dependents: ["/B"] }); + await expect(execute(f.remove, REF)).rejects.toThrow(/active projects.*\/B/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect( + f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims?.[0]?.dependents + ).toEqual(["/B"]); + }); + + it("requires an exact ref when names collide across catalogues", async () => { + const f = fixture({ refs: [REF, OTHER] }); + await expect(execute(f.remove, "test-plugin")).rejects.toThrow(/multiple catalogues.*exact/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toHaveLength(2); + }); + + it("resolves a bare native plugin name when exactly one owned catalogue matches", async () => { + const f = fixture(); + await execute(f.remove, "test-plugin"); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([]); + }); + + it("never adopts a foreign preexisting host ref without a canonical claim", async () => { + const f = fixture({ + refs: [], + hostRefs: new Map([[REF, { enabled: true, scope: "user" as const }]]), + }); + await expect(execute(f.remove, REF)).rejects.toThrow(/not installed/i); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + }); + + it("refuses an unavailable host CLI or disabled host ref and retains the claim", async () => { + for (const options of [ + { available: false }, + { hostRefs: new Map([[REF, { enabled: false, scope: "user" as const }]]) }, + { hostRefs: new Map([[OTHER, { enabled: true, scope: "user" as const }]]) }, + ]) { + const f = fixture(options); + await expect(execute(f.remove, REF)).rejects.toThrow(/unavailable|still enabled/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + } + }); + + it("retains the machine claim if the host refuses unregister", async () => { + const f = fixture({ failOnUninstall: true }); + await expect(execute(f.remove, REF)).rejects.toThrow(/not installed/); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("refuses to uninstall an enabled native ref when its host catalogue source changed", async () => { + const f = fixture({ sourceMismatch: true }); + await expect(execute(f.remove, REF)).rejects.toThrow(/source differs|source.*changed/); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("refuses no source reader and legacy unproven source instead of trusting an enabled ref", async () => { + for (const options of [{ sourceUnavailable: true }, { legacy: true }]) { + const f = fixture(options); + await expect(execute(f.remove, REF)).rejects.toThrow( + /source reader unavailable|legacy claim/ + ); + expect(f.activator.uninstalledPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + } + }); + + it("unregisters only the exact host ref and removes only its machine projection", async () => { + const f = fixture({ refs: [REF, OTHER] }); + await execute(f.remove, REF); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toEqual(["user"]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([ + { ref: OTHER, dependents: [] }, + ]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginRefs).toEqual([OTHER]); + }); + + it("passes the host's actual project-labelled ref scope to uninstall", async () => { + const f = fixture({ + hostRefs: new Map([[REF, { enabled: true, scope: "project" as const }]]), + }); + await execute(f.remove, REF); + expect(f.activator.uninstalledPlugins).toEqual([REF]); + expect(f.activator.uninstalledPluginScopes).toEqual(["project"]); + expect(f.repo.getCurrent()?.getNativeRegistrations(toolId)?.pluginClaims).toEqual([]); + }); + }); +} + +describe("Cursor user plugin boundary", () => { + it("removes exactly an AIDD-owned file and its canonical user record", async () => { + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: { + "sample-plugin/plugin.json": createHash("md5").update("owned bytes").digest("hex"), + }, + scope: "user", + dependents: [], + }) + ); + const repo = new InMemoryManifestRepository(machine); + const fs = new InMemoryFileAdapter(); + const path = join(homedir(), ".cursor/plugins/local/sample-plugin/plugin.json"); + fs.setFile(path, "owned bytes"); + const remove = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new Map(), + new Map(), + undefined, + new InMemoryMarketplaceRegistry(), + repo + ); + fs.setFile(path, "user-edited plugin.json"); + await expect( + remove.execute({ + pluginName: "sample-plugin", + toolIds: ["cursor"], + projectRoot: "/A", + scope: "user", + }) + ).rejects.toThrow(/edited.*plugin.json|plugin.json.*edited/); + expect(fs.getFile(path)).toBe("user-edited plugin.json"); + expect(repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + fs.setFile(path, "owned bytes"); + await remove.execute({ + pluginName: "sample-plugin", + toolIds: ["cursor"], + projectRoot: "/A", + scope: "user", + }); + expect(fs.getFile(path)).toBeUndefined(); + expect(repo.getCurrent()?.getPlugins("cursor")).toEqual([]); + }); + + it("keeps its canonical record and foreign bytes if a tracked file resolves outside the plugin directory", async () => { + const relativePath = "sample-plugin/skills/demo/SKILL.md"; + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: { [relativePath]: "abc" }, + scope: "user", + dependents: [], + }) + ); + const repo = new InMemoryManifestRepository(machine); + const fs = new InMemoryFileAdapter(); + const pluginDir = join(homedir(), ".cursor/plugins/local/sample-plugin"); + const foreignPath = "/foreign/sample-plugin"; + fs.setFile(join(foreignPath, "skills/demo/SKILL.md"), "foreign bytes"); + fs.setSymlink(pluginDir, foreignPath); + const owned = machine.getPlugins("cursor")[0]; + expect(owned.files.size).toBe(1); + expect( + ( + await userScopeFilesSafeToDelete( + fs, + new CapturingLogger(), + owned, + "cursor", + resolveHomeDir() + ) + ).size + ).toBe(0); + const remove = new PluginRemoveUseCase( + fs, + new InMemoryManifestRepository(Manifest.create()), + new CapturingLogger(), + new Map(), + new Map(), + undefined, + new InMemoryMarketplaceRegistry(), + repo + ); + await expect( + remove.execute({ + pluginName: "sample-plugin", + toolIds: ["cursor"], + projectRoot: "/A", + scope: "user", + }) + ).rejects.toThrow(/tracked file escaped its boundary/); + expect(fs.getFile(join(foreignPath, "skills/demo/SKILL.md"))).toBe("foreign bytes"); + expect(repo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + }); +}); diff --git a/cli/tests/contexts/framework/application/ownership/user-plugin-update.integration.test.ts b/cli/tests/contexts/framework/application/ownership/user-plugin-update.integration.test.ts new file mode 100644 index 000000000..335fb97e3 --- /dev/null +++ b/cli/tests/contexts/framework/application/ownership/user-plugin-update.integration.test.ts @@ -0,0 +1,213 @@ +import "../../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; +import { describe, expect, it } from "vitest"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { UserPluginUpdateUseCase } from "../../../../../src/contexts/framework/application/ownership/user-plugin-update-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; +import type { HostPluginRegistryReader } from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; +import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { DeterministicHasher } from "../../../../helpers/ports/deterministic-hasher.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; +import { FixturePluginFetcher } from "../../../../helpers/ports/fixture-plugin-fetcher.js"; +import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; + +const REF = "test-plugin@real-catalog"; +const OTHER = "test-plugin@other-catalog"; + +function fixture( + toolId: "codex" | "copilot", + options: { + refs?: readonly string[]; + dependents?: readonly string[]; + available?: boolean; + hostRefs?: ReadonlyMap; + failOnUpdate?: boolean; + } = {} +) { + const refs = options.refs ?? [REF]; + const machine = Manifest.create(); + machine.addTool(toolId, "1.0.0", []); + machine.setNativeRegistrations(toolId, { + binary: toolId, + marketplaces: refs.map((ref) => ({ + alias: ref.split("@")[1], + hostName: ref.split("@")[1], + provenance: { kind: "registry" as const, source: "/previous/aidd/source" }, + })), + pluginRefs: [...refs], + pluginClaims: refs.map((ref) => ({ ref, dependents: [...(options.dependents ?? [])] })), + }); + const repo = new InMemoryManifestRepository(machine); + const fs = new InMemoryFileAdapter(); + const activator = new FakeNativePluginActivator({ + available: options.available ?? true, + failOnUpdate: options.failOnUpdate ? [REF] : [], + }); + const hostRefs = + options.hostRefs ?? + new Map(refs.map((ref) => [ref, { enabled: true, scope: "user" as const }])); + const reader: HostPluginRegistryReader = { + read: async () => ({ location: "/host/registry", refs: hostRefs }), + }; + const logger = new CapturingLogger(); + const update = new UserPluginUpdateUseCase( + repo, + new UserPluginFileUpdater( + fs, + new UserPluginDistributionLoader( + new FixturePluginFetcher(), + new PluginDistributionReaderAdapter(fs) + ), + new DeterministicHasher() + ), + logger, + new NativeHostRegistrationGate(new Map([[toolId, activator]]), new Map([[toolId, reader]])) + ); + const execute = (pluginName: string | readonly string[]) => + update.execute({ + pluginNames: typeof pluginName === "string" ? [pluginName] : [...pluginName], + toolIds: [toolId], + projectRoot: "/A", + scope: "user", + }); + const executeAll = () => update.execute({ toolIds: [toolId], projectRoot: "/A", scope: "user" }); + return { repo, activator, logger, execute, executeAll }; +} + +describe("targeted machine native plugin update", () => { + it("Copilot retains exact AIDD claims when its installed binary cannot prove the source", async () => { + const f = fixture("copilot", { refs: [REF, OTHER], dependents: ["/A", "/B"] }); + await expect(f.execute(REF)).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + { ref: OTHER, dependents: ["/A", "/B"] }, + ]); + expect(f.repo.saveCount).toBe(0); + }); + + it("refuses ambiguous catalogue names before updating any ref", async () => { + const f = fixture("copilot", { refs: [REF, OTHER] }); + await expect(f.execute("test-plugin")).rejects.toThrow(/Multiple native catalogues.*exact/); + expect(f.activator.updatedPlugins).toEqual([]); + }); + + it("resolves a bare native plugin name but refuses update without current source proof", async () => { + const f = fixture("copilot", { refs: [REF], dependents: ["/B"] }); + await expect(f.execute("test-plugin")).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/B"] }, + ]); + }); + + it("does not adopt a foreign enabled host ref without a machine claim", async () => { + const f = fixture("copilot", { refs: [], hostRefs: new Map([[REF, { enabled: true }]]) }); + await expect(f.execute(REF)).rejects.toThrow(/not installed/); + expect(f.activator.updatedPlugins).toEqual([]); + }); + + it("all-target update is a no-op when this machine owns no plugins or native refs", async () => { + const f = fixture("copilot", { refs: [] }); + expect(await f.executeAll()).toEqual([]); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.saveCount).toBe(0); + }); + + it("refuses an unavailable CLI or disabled host ref, retaining canonical claims", async () => { + for (const options of [ + { available: false }, + { hostRefs: new Map([[REF, { enabled: false }]]) }, + ]) { + const f = fixture("copilot", options); + await expect(f.execute(REF)).rejects.toThrow( + /CLI and readable host registry|not provably enabled/ + ); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + } + }); + + it("Codex refuses targeted update because its verified binary has no update verb", async () => { + const f = fixture("codex"); + await expect(f.execute(REF)).rejects.toThrow(/does not support targeted native plugin update/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); + + it("does not touch a project-scope record when selected Copilot source is unproved", async () => { + const f = fixture("copilot", { refs: [REF, OTHER] }); + const machine = f.repo.getCurrent(); + if (machine === null) throw new Error("fixture missing machine manifest"); + machine.addPlugin( + "copilot", + InstalledPlugin.fromMetadata( + "project-plugin", + "0.0.1", + { kind: "local", path: "/fixture" }, + false, + "project" + ) + ); + await expect(f.execute(REF)).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(machine.getPlugins("copilot").map((plugin) => plugin.name)).toEqual(["project-plugin"]); + }); + + it("refuses two selected Copilot refs before updating either without current source proof", async () => { + const f = fixture("copilot", { refs: [REF, OTHER] }); + await expect(f.execute([REF, OTHER])).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + }); + + it("preflights every selected host ref before the first update", async () => { + const f = fixture("copilot", { + refs: [REF, OTHER], + hostRefs: new Map([ + [REF, { enabled: true, scope: "user" }], + [OTHER, { enabled: false, scope: "user" }], + ]), + }); + await expect(f.execute([REF, OTHER])).rejects.toThrow(/not provably enabled/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.saveCount).toBe(0); + }); + + it("all-target Copilot update refuses an unproved catalogue and preserves project records", async () => { + const f = fixture("copilot"); + const machine = f.repo.getCurrent(); + if (machine === null) throw new Error("fixture missing machine manifest"); + machine.addPlugin( + "copilot", + InstalledPlugin.fromMetadata( + "project-plugin", + "0.0.1", + { kind: "local", path: "/fixture" }, + false, + "project" + ).withFiles(new Map([["skills/project/SKILL.md", "hash"]])) + ); + await expect(f.executeAll()).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(machine.getPlugins("copilot").map((plugin) => plugin.name)).toEqual(["project-plugin"]); + }); + + it("does not reach Copilot's update verb when its current source cannot be proven", async () => { + const f = fixture("copilot", { failOnUpdate: true }); + await expect(f.execute(REF)).rejects.toThrow(/host source reader unavailable/); + expect(f.activator.updatedPlugins).toEqual([]); + expect(f.repo.saveCount).toBe(0); + expect(f.repo.getCurrent()?.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: REF, dependents: [] }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts index 0f5b9a766..0481635ec 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-mcp.unit.test.ts @@ -1,9 +1,14 @@ import { join } from "node:path"; import { describe, expect, it } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import type { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; -import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; +import { + buildUnitDeps, + initAndInstall, + installTool, +} from "../../../../helpers/ports/build-unit-deps.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; @@ -43,7 +48,8 @@ async function buildOpencodeProject(): Promise<{ deps.hasher, logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); return { deps, logger, useCase }; } @@ -66,6 +72,253 @@ function installedMcpPlugin(deps: Deps): InstalledPlugin | undefined { } describe("PluginAddUseCase and opencode MCP servers", () => { + it("keeps user servers and other config while recording only successfully installed MCP servers", async () => { + const { deps, logger, useCase } = await buildOpencodeProject(); + const owned = { type: "local", command: ["user-server"], enabled: false }; + deps.fs.setFile( + OPENCODE_JSON, + JSON.stringify({ model: "user/model", mcp: { "local-tool": owned } }) + ); + deps.fs.setFile( + join(MCP_PLUGIN_DIR, ".mcp.json"), + JSON.stringify({ + mcpServers: { + "local-tool": { command: "node", args: ["./server.js"] }, + "new-tool": { command: "node", args: ["./new.js"], env: { MODE: "plugin" } }, + }, + }) + ); + + await addLocal(useCase, MCP_PLUGIN_DIR); + + const config = JSON.parse(deps.fs.getFile(OPENCODE_JSON) ?? "null"); + expect(config.model).toBe("user/model"); + expect(config.mcp["local-tool"]).toStrictEqual(owned); + expect(config.mcp["new-tool"]).toStrictEqual({ + type: "local", + command: ["node", "./new.js"], + environment: { MODE: "plugin" }, + enabled: true, + }); + expect([...(installedMcpPlugin(deps)?.mcpEntries.keys() ?? [])]).toStrictEqual(["new-tool"]); + expect(logger.warnMessages).toStrictEqual([ + 'Plugin "mcp-plugin": mcp skipped for opencode — local-tool: server already exists in opencode.json (user-owned); plugin entry skipped', + ]); + }); + + it("uses the selected Cursor plugin's hook provenance when another hook plugin was installed first", async () => { + const { deps, useCase } = await buildOpencodeProject(); + await installTool(deps, PROJECT_ROOT, "cursor"); + const otherDir = "/plugins/other-hook-plugin"; + for (const [name, dir] of [ + ["other-hook-plugin", otherDir], + ["mcp-plugin", MCP_PLUGIN_DIR], + ]) { + deps.fs.setFile( + join(dir, ".claude-plugin/plugin.json"), + JSON.stringify({ name, version: "1.0.0" }) + ); + deps.fs.setFile(join(dir, "skills/demo/SKILL.md"), "# Demo"); + deps.fs.setFile(join(dir, "hooks/pre.js"), `${name} original script`); + deps.fs.setFile( + join(dir, "hooks/hooks.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { hooks: [{ type: "command", command: `node \${CLAUDE_PLUGIN_ROOT}/hooks/pre.js` }] }, + ], + }, + }) + ); + await useCase.execute({ + source: { kind: "local", path: dir }, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + } + const otherBefore = deps.manifestRepo + .getCurrent() + ?.getPlugins("cursor") + .find((p) => p.name === "other-hook-plugin") + ?.toJSON(); + deps.fs.setFile(join(MCP_PLUGIN_DIR, "hooks/pre.js"), "mcp-plugin updated script"); + + await useCase.execute({ + source: { kind: "local", path: MCP_PLUGIN_DIR }, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + interactive: false, + replace: true, + }); + + const hooks = JSON.parse(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks.json")) ?? "null"); + expect(hooks.hooks.preToolUse.map((hook: { command: string }) => hook.command)).toStrictEqual([ + "node ./.cursor/hooks/other-hook-plugin/pre.js", + "node ./.cursor/hooks/mcp-plugin/pre.js", + ]); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks/mcp-plugin/pre.js"))).toBe( + "mcp-plugin updated script" + ); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks/other-hook-plugin/pre.js"))).toBe( + "other-hook-plugin original script" + ); + expect( + deps.manifestRepo + .getCurrent() + ?.getPlugins("cursor") + .find((p) => p.name === "other-hook-plugin") + ?.toJSON() + ).toStrictEqual(otherBefore); + expect( + deps.manifestRepo + .getCurrent() + ?.getPlugins("cursor") + .find((p) => p.name === "mcp-plugin")?.projectHooks?.scripts.size + ).toBe(1); + }); + + it.each([undefined, "local-marketplace"])( + "explains hook trust after a Codex install via %s", + async (marketplace) => { + const { deps, logger, useCase } = await buildOpencodeProject(); + await installTool(deps, PROJECT_ROOT, "codex"); + deps.fs.setFile(join(MCP_PLUGIN_DIR, "hooks/pre.js"), "console.log('hook');"); + deps.fs.setFile( + join(MCP_PLUGIN_DIR, "hooks/hooks.json"), + JSON.stringify({ + hooks: { + PreToolUse: [ + { hooks: [{ type: "command", command: `node \${CLAUDE_PLUGIN_ROOT}/hooks/pre.js` }] }, + ], + }, + }) + ); + + await useCase.execute({ + source: { kind: "local", path: MCP_PLUGIN_DIR }, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + interactive: false, + marketplace, + }); + + expect(logger.infoMessages).toStrictEqual([ + 'Plugin "mcp-plugin" (codex): Codex will not run this plugin\'s hooks until each one is trusted — approve the prompt once in an interactive session, or pass --dangerously-bypass-hook-trust to codex exec for a headless run. Until then, a session leaves no run journal and nothing says why.', + ]); + expect(logger.warnMessages).toStrictEqual([]); + const installed = deps.manifestRepo + .getCurrent() + ?.getPlugins("codex") + .find((p) => p.name === "mcp-plugin"); + expect(installed).toBeDefined(); + expect(installed?.marketplace).toBe(marketplace); + if (marketplace === undefined) { + expect(deps.fs.getFile(join(PROJECT_ROOT, ".codex/plugins/mcp-plugin/hooks/pre.js"))).toBe( + "console.log('hook');" + ); + expect(installed?.files.size).toBeGreaterThan(0); + } else { + expect(installed?.files.size).toBe(0); + } + } + ); + + it.each(["local", "github marketplace"] as const)( + "refuses %s replacement of an edited MCP contribution before saving or rewriting files", + async (route) => { + const { deps, useCase } = await buildOpencodeProject(); + await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); + await addLocal(useCase, EXTRA_PLUGIN_FIXTURE); + await addLocal(useCase, MCP_PLUGIN_DIR); + const installed = installedMcpPlugin(deps); + if (installed === undefined) throw new Error("fixture missing MCP plugin"); + const filesBefore = new Map( + [...installed.files.keys()].map((path) => [path, deps.fs.getFile(join(PROJECT_ROOT, path))]) + ); + const manifestBefore = deps.manifestRepo.getCurrent()?.toJSON(); + const savesBefore = deps.manifestRepo.saveCount; + const userConfig = JSON.stringify({ + mcp: { "local-tool": { type: "local", command: ["user-custom-command"] } }, + }); + deps.fs.setFile(OPENCODE_JSON, userConfig); + if (route === "github marketplace") + await deps.marketplaceRegistry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "aidd-framework", + source: { kind: "github", repo: "ai-driven-dev/framework" }, + scope: "project", + addedAt: "2026-05-01T00:00:00.000Z", + }) + ); + + await expect( + useCase.execute({ + source: { kind: "local", path: MCP_PLUGIN_DIR }, + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + interactive: false, + replace: true, + ...(route === "github marketplace" + ? { + marketplace: "aidd-framework", + pluginMetadata: { name: "mcp-plugin", version: "1.0.0", strict: false }, + } + : {}), + }) + ).rejects.toThrow(/edited/); + + expect(deps.fs.getFile(OPENCODE_JSON)).toBe(userConfig); + expect(deps.manifestRepo.saveCount).toBe(savesBefore); + expect(deps.manifestRepo.getCurrent()?.toJSON()).toEqual(manifestBefore); + for (const [path, content] of filesBefore) + expect(deps.fs.getFile(join(PROJECT_ROOT, path))).toBe(content); + } + ); + + it("replaces only the selected plugin while leaving another plugin's user-edited MCP entry untouched", async () => { + const { deps, useCase } = await buildOpencodeProject(); + await addLocal(useCase, MCP_PLUGIN_DIR); + const otherDir = "/plugins/other-mcp-plugin"; + deps.fs.setFile( + join(otherDir, ".claude-plugin/plugin.json"), + JSON.stringify({ + name: "other-mcp-plugin", + version: "1.0.0", + }) + ); + deps.fs.setFile(join(otherDir, "skills/other/SKILL.md"), "# Other skill"); + deps.fs.setFile( + join(otherDir, ".mcp.json"), + JSON.stringify({ + mcpServers: { "other-tool": { command: "node", args: ["./other.js"] } }, + }) + ); + await addLocal(useCase, otherDir); + const current = JSON.parse(deps.fs.getFile(OPENCODE_JSON) ?? "null"); + const edited = { type: "local", command: ["user-other-command"] }; + current.mcp["other-tool"] = edited; + deps.fs.setFile(OPENCODE_JSON, JSON.stringify(current)); + const otherBefore = deps.manifestRepo + .getCurrent() + ?.getPlugins("opencode") + .find((plugin) => plugin.name === "other-mcp-plugin"); + + await addLocal(useCase, MCP_PLUGIN_DIR, true); + + const after = JSON.parse(deps.fs.getFile(OPENCODE_JSON) ?? "null"); + expect(after.mcp["other-tool"]).toEqual(edited); + expect(after.mcp["local-tool"]).toEqual(current.mcp["local-tool"]); + expect( + deps.manifestRepo + .getCurrent() + ?.getPlugins("opencode") + .find((plugin) => plugin.name === "other-mcp-plugin") + ).toEqual(otherBefore); + expect(installedMcpPlugin(deps)?.mcpEntries.size).toBe(1); + }); + it("keeps its own MCP server, without a collision, when re-added with replace", async () => { const { deps, logger, useCase } = await buildOpencodeProject(); await seedFromDirectory(deps.fs, EXTRA_PLUGIN_FIXTURE, { useAbsolutePaths: true }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts index 59afb8ba1..02b79fa4c 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-skip-warn.integration.test.ts @@ -31,7 +31,8 @@ describe("PluginAddUseCase skip warnings", () => { deps.hasher, capturingLogger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts index 5a89ee2e8..9a071f111 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-add-use-case.unit.test.ts @@ -3,6 +3,7 @@ import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import type { PluginDistributionReader } from "../../../../../src/contexts/framework/domain/ports/plugin-distribution-reader.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; @@ -15,6 +16,7 @@ import type { Logger } from "../../../../../src/kernel/ports/logger.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; @@ -52,7 +54,8 @@ function buildAddUseCase( deps.hasher, logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); } @@ -152,7 +155,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { @@ -195,7 +199,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await expect( useCase.execute({ @@ -235,7 +240,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, @@ -292,7 +298,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, await makeGithubRegistry(PROJECT_ROOT), - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -330,7 +337,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -367,7 +375,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -382,7 +391,13 @@ describe("PluginAddUseCase", () => { const plugins = manifest?.getPlugins("cursor") ?? []; const installed = plugins.find((p) => p.name === "sample-plugin"); expect(installed).toBeDefined(); - expect(installed?.files.size).toBeGreaterThan(0); + expect(installed?.files.size).toBe(0); + const machine = deps.userManifestRepo + .getCurrent() + ?.getPlugins("cursor") + .find((p) => p.name === "sample-plugin"); + expect(machine?.files.size).toBeGreaterThan(0); + expect(machine?.dependents).toEqual([PROJECT_ROOT]); }); const CURSOR_SKILL = "sample-plugin/skills/demo/SKILL.md"; @@ -416,6 +431,7 @@ describe("PluginAddUseCase", () => { const deps = await cursorDeps(); deps.fs.setFile(cursorSkillAt(), "# Their own skill"); const logger = new CapturingLogger(); + const machineWritesBefore = deps.userManifestRepo.saveCount; await addForCursor(deps, logger); @@ -423,6 +439,8 @@ describe("PluginAddUseCase", () => { const entry = deps.manifestRepo.getCurrent()?.getPlugins("cursor")[0]; expect(entry?.name).toBe("sample-plugin"); expect(entry?.files.size).toBe(0); + expect(deps.userManifestRepo.saveCount).toBe(machineWritesBefore); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor") ?? []).toEqual([]); expect(logger.warnMessages.join("\n")).toContain( join(homedir(), ".cursor/plugins/local", "sample-plugin") ); @@ -433,15 +451,25 @@ describe("PluginAddUseCase", () => { const manifest = await deps.manifestRepo.load(); manifest?.addPlugin( "cursor", - InstalledPlugin.fromMetadata("other-plugin", "1.0.0", GIT_SUBDIR_SOURCE, false, "user") + InstalledPlugin.fromMetadata( + "other-plugin", + "1.0.0", + GIT_SUBDIR_SOURCE, + false, + "user" + ).withFiles(new Map([["other-plugin/skills/demo.md", "recorded-hash"]])) ); if (manifest) await deps.manifestRepo.save(manifest); deps.fs.setFile(cursorSkillAt(), "# Their own skill"); + const machineWritesBefore = deps.userManifestRepo.saveCount; await addForCursor(deps, deps.logger); expect(deps.fs.getFile(cursorSkillAt())).toBe("# Their own skill"); expect(pluginNames(deps, "cursor")).toEqual(["other-plugin", "sample-plugin"]); + expect(deps.manifestRepo.getCurrent()?.getPlugins("cursor")[0]?.files.size).toBe(1); + expect(deps.userManifestRepo.saveCount).toBe(machineWritesBefore); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor") ?? []).toEqual([]); }); it("still delivers the plugin's project hooks when its user-scope dir is someone else's", async () => { @@ -470,15 +498,66 @@ describe("PluginAddUseCase", () => { expect(deps.manifestRepo.getCurrent()?.getPlugins("cursor")[0]?.files.size).toBe(0); }); - it("still overwrites and tracks its own plugin dir when re-installed with replace", async () => { + it("project replace refuses legacy hooks without an install digest and preserves shared files", async () => { const deps = await cursorDeps(); await addForCursor(deps, deps.logger); + const hooksBefore = deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks.json")); + const savesBefore = deps.manifestRepo.saveCount; + const machineSavesBefore = deps.userManifestRepo.saveCount; deps.fs.setFile(`/built/cursor/plugins/${CURSOR_SKILL}`, "# Demo skill v2"); - await addForCursor(deps, deps.logger, true); + await expect(addForCursor(deps, deps.logger, true)).rejects.toThrow( + /legacy install digest/ + ); - expect(deps.fs.getFile(cursorSkillAt())).toBe("# Demo skill v2"); + expect(deps.fs.getFile(cursorSkillAt())).toBe("# Demo skill"); + expect(deps.fs.getFile(join(PROJECT_ROOT, ".cursor/hooks.json"))).toBe(hooksBefore); + expect(deps.manifestRepo.saveCount).toBe(savesBefore); + expect(deps.userManifestRepo.saveCount).toBe(machineSavesBefore); expect(pluginNames(deps, "cursor")).toEqual(["sample-plugin"]); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual([ + PROJECT_ROOT, + ]); + }); + + it("attaches project B to A's canonical Cursor file without rewriting A's bytes", async () => { + const deps = await cursorDeps(); + const logger = new CapturingLogger(); + await addForCursor(deps, logger); + const warningsBeforeB = logger.warnMessages.length; + const ownedPath = cursorSkillAt(); + deps.fs.setFile(ownedPath, "A's own bytes"); + const projectB = Manifest.create(); + projectB.addTool("cursor", "1.0.0", []); + const projectBRepo = new InMemoryManifestRepository(projectB, "/B"); + const registryB = await makeGithubRegistry("/B"); + await new PluginAddUseCase( + deps.fs, + projectBRepo, + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs), + deps.hasher, + logger, + registryB, + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo + ).execute({ + source: GIT_SUBDIR_SOURCE, + toolIds: ["cursor"], + projectRoot: "/B", + marketplace: "aidd-framework", + interactive: false, + pluginMetadata: PLUGIN_METADATA, + }); + expect(deps.fs.getFile(ownedPath)).toBe("A's own bytes"); + expect(projectBRepo.getCurrent()?.getPlugins("cursor")[0]?.files.size).toBe(0); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual([ + PROJECT_ROOT, + "/B", + ]); + expect(logger.warnMessages.slice(warningsBeforeB).join("\n")).not.toContain( + "was already there and this project did not install it" + ); }); }); @@ -498,7 +577,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -530,7 +610,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -564,7 +645,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -595,7 +677,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -629,7 +712,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: GIT_SUBDIR_SOURCE, @@ -680,7 +764,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, @@ -732,7 +817,8 @@ describe("PluginAddUseCase", () => { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await useCase.execute({ source: { kind: "local", path: "/some-plugin" }, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts index 32371c22e..fcecbce57 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-install-from-marketplace-use-case.unit.test.ts @@ -54,7 +54,8 @@ async function buildUseCase(options: { logger?: Logger | null; prompter?: Prompt deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase(deps.pluginFetcher); const logger = options.logger === null ? undefined : (options.logger ?? deps.logger); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts index ff6ef7368..590175a54 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-cache.integration.test.ts @@ -12,6 +12,7 @@ import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/appli import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; +import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; @@ -56,26 +57,79 @@ async function seedManifest(): Promise { // `clean`'s own marketplace cache purge already addresses by hostName, never alias. manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: ALIAS, hostName: HOST_NAME }], + marketplaces: [ + { + alias: ALIAS, + hostName: HOST_NAME, + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], pluginRefs: [REF], }); return manifest; } +function verifiedClaudeRemoval( + fs: InMemoryFileAdapter, + repo: InMemoryManifestRepository, + logger: CapturingLogger, + activator: FakeNativePluginActivator +): PluginRemoveUseCase { + return new PluginRemoveUseCase( + fs, + repo, + logger, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/host/plugin-registry", + refs: new Map([ + [REF, { enabled: true, scope: "project" }], + [`other-plugin@${HOST_NAME}`, { enabled: true, scope: "project" }], + [`${PLUGIN_NAME}@../../../evil`, { enabled: true, scope: "project" }], + ]), + }), + ], + ]), + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/host/catalogue", + entries: new Map([ + [HOST_NAME, { kind: "registry" as const, source: "/plugin-source" }], + ["../../../evil", { kind: "registry" as const, source: "/plugin-source" }], + ]), + }), + }, + ], + ]) + ); +} + describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { it("purges cache/// once the host confirms it uninstalled, even with content still inside", async () => { const fs = new InMemoryFileAdapter(); const cacheEntry = join(CLAUDE_CACHE_ROOT, HOST_NAME, PLUGIN_NAME, "1.0.0", "plugin.json"); + const foreignCacheEntry = join( + CLAUDE_CACHE_ROOT, + HOST_NAME, + "other-plugin", + "1.0.0", + "plugin.json" + ); await fs.writeFile(cacheEntry, "{}"); + await fs.writeFile(foreignCacheEntry, "foreign bytes"); const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); - const removeUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - logger, - new Map([["claude", activator]]) - ); + const removeUseCase = verifiedClaudeRemoval(fs, manifestRepo, logger, activator); await removeUseCase.execute({ pluginName: PLUGIN_NAME, @@ -85,6 +139,7 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { expect(activator.uninstalledPlugins).toEqual([REF]); expect(await fs.fileExists(cacheEntry)).toBe(false); + expect(fs.getFile(foreignCacheEntry)).toBe("foreign bytes"); }); it("leaves the plugin's cache in place, and names it, when the host CLI is not on PATH", async () => { @@ -94,12 +149,7 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: false }); const logger = new CapturingLogger(); - const removeUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - logger, - new Map([["claude", activator]]) - ); + const removeUseCase = verifiedClaudeRemoval(fs, manifestRepo, logger, activator); await removeUseCase.execute({ pluginName: PLUGIN_NAME, @@ -120,12 +170,7 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: true, failOnUninstall: [REF] }); const logger = new CapturingLogger(); - const removeUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - logger, - new Map([["claude", activator]]) - ); + const removeUseCase = verifiedClaudeRemoval(fs, manifestRepo, logger, activator); await removeUseCase.execute({ pluginName: PLUGIN_NAME, @@ -153,18 +198,19 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { ); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: ALIAS, hostName: evilHostName }], + marketplaces: [ + { + alias: ALIAS, + hostName: evilHostName, + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], pluginRefs: [`${PLUGIN_NAME}@${evilHostName}`], }); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); - const removeUseCase = new PluginRemoveUseCase( - fs, - manifestRepo, - logger, - new Map([["claude", activator]]) - ); + const removeUseCase = verifiedClaudeRemoval(fs, manifestRepo, logger, activator); await removeUseCase.execute({ pluginName: PLUGIN_NAME, @@ -184,11 +230,11 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { ); const manifestRepo = new InMemoryManifestRepository(await seedManifest(), PROJECT_ROOT); const logger = new CapturingLogger(); - const removeUseCase = new PluginRemoveUseCase( + const removeUseCase = verifiedClaudeRemoval( fs, manifestRepo, logger, - new Map([["claude", new FakeNativePluginActivator({ available: true })]]) + new FakeNativePluginActivator({ available: true }) ); await removeUseCase.execute({ @@ -220,7 +266,7 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { expect(logger.allMessages).toStrictEqual([]); }); - it("purges nothing, silently, for a host that declares no plugin cache directory", async () => { + it("refuses Copilot removal without current source proof, preserving its manifest and files", async () => { const fs = new InMemoryFileAdapter(); const manifest = Manifest.create(); manifest.addTool("copilot", "test", []); @@ -234,7 +280,13 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { ); manifest.setNativeRegistrations("copilot", { binary: "copilot", - marketplaces: [{ alias: ALIAS, hostName: HOST_NAME }], + marketplaces: [ + { + alias: ALIAS, + hostName: HOST_NAME, + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], pluginRefs: [REF], }); const manifestRepo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); @@ -247,13 +299,16 @@ describe("PluginRemoveUseCase purges the plugin's own cache subtree", () => { new Map([["copilot", activator]]) ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["copilot"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["copilot"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/unclaimed machine-global host ref/); - expect(activator.uninstalledPlugins).toStrictEqual([REF]); - expect(logger.allMessages).toStrictEqual([]); + expect(activator.uninstalledPlugins).toStrictEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("copilot")).toHaveLength(1); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("copilot")?.pluginRefs).toEqual([REF]); }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts index f2b22abf7..15d4a900f 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-native-activation.integration.test.ts @@ -1,16 +1,25 @@ // `claude`, `codex` and `copilot` only load a plugin once their own CLI has registered it, so // removal must drive `uninstallPlugin` with the same `@` ref install used. import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; import { ModeAMarketplaceTranslator } from "../../../../../src/contexts/framework/application/framework/translator/mode-a-marketplace-translator.js"; import { PluginRemoveUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-remove-use-case.js"; import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import type { + HostPluginRegistryReader, + HostPluginRegistryReading, +} from "../../../../../src/contexts/tools/domain/ports/host-plugin-registry-reader.js"; import { PluginDistribution } from "../../../../../src/contexts/translate/domain/plugin-distribution.js"; +import { UnreadableUserSourceReferencesError } from "../../../../../src/kernel/errors.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { FakeHostPluginRegistryReader } from "../../../../helpers/ports/fake-host-plugin-registry-reader.js"; import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../../helpers/ports/in-memory-manifest-repository.js"; +import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; const PROJECT_ROOT = "/test-project"; const MARKETPLACE_NAME = "aidd-framework"; @@ -42,11 +51,35 @@ async function installViaModeA(manifest: Manifest): Promise { manifest, MARKETPLACE_NAME ); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: MARKETPLACE_NAME, + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], + pluginRefs: [REF], + }); +} + +function verifiedSourceReader() { + return { + read: async () => ({ + location: "/host/catalogue", + entries: new Map([ + [MARKETPLACE_NAME, { kind: "registry" as const, source: "/plugin-source" }], + ["upstream", { kind: "registry" as const, source: "/plugin-source" }], + ]), + }), + }; } function buildRemoveUseCase( activator: FakeNativePluginActivator, - logger: CapturingLogger + logger: CapturingLogger, + hostScope: "project" | "user" = "project" ): { removeUseCase: PluginRemoveUseCase; manifestRepo: InMemoryManifestRepository } { const fs = new InMemoryFileAdapter(); const manifestRepo = new InMemoryManifestRepository(); @@ -54,12 +87,475 @@ function buildRemoveUseCase( fs, manifestRepo, logger, - new Map([["claude", activator]]) + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/host/plugin-registry", + refs: new Map([ + [REF, { enabled: true, scope: hostScope }], + [`${PLUGIN_NAME}@upstream`, { enabled: true, scope: "project" }], + ]), + }), + ], + ]), + undefined, + undefined, + undefined, + new Map([["claude", verifiedSourceReader()]]) ); return { removeUseCase, manifestRepo }; } describe("PluginRemoveUseCase undoes native activation", () => { + it.each(["unrecorded activation", "absent catalogue"])( + "refuses %s without changing project state", + async (missing) => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + if (missing === "unrecorded activation") { + const registrations = manifest.getNativeRegistrations("claude"); + if (registrations === undefined) throw new Error("Expected native registration"); + manifest.setNativeRegistrations("claude", { ...registrations, pluginRefs: [] }); + } + const before = manifest.toJSON(); + const activator = new FakeNativePluginActivator({ available: true, enablesPlugins: false }); + const repo = new InMemoryManifestRepository(manifest); + const remove = new PluginRemoveUseCase( + new InMemoryFileAdapter(), + repo, + new CapturingLogger(), + new Map([["claude", activator]]), + new Map(), + undefined, + undefined, + undefined, + new Map([ + ["claude", { read: async () => ({ location: "/host/catalogue", entries: new Map() }) }], + ]) + ); + await expect( + remove.execute({ pluginName: PLUGIN_NAME, toolIds: ["claude"], projectRoot: PROJECT_ROOT }) + ).rejects.toThrow( + missing === "unrecorded activation" + ? `claude: '${REF}' is not a ref this project enabled; reconcile manually before removal.` + : `claude: catalogue '${MARKETPLACE_NAME}' has unproven host source; reconcile manually.` + ); + expect(manifest.toJSON()).toEqual(before); + expect(repo.saveCount).toBe(0); + expect(activator.uninstalledPlugins).toEqual([]); + } + ); + + it("preserves the native ref and cache when Claude disappears after preflight", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + const repo = new InMemoryManifestRepository(manifest); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const source = verifiedSourceReader(); + let reads = 0; + const cacheFile = join( + homedir(), + ".claude/plugins/cache", + MARKETPLACE_NAME, + PLUGIN_NAME, + "1.0.0/plugin.json" + ); + await fs.writeFile(cacheFile, "keep cache"); + const remove = new PluginRemoveUseCase( + fs, + repo, + logger, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/host/registry", + refs: new Map([[REF, { enabled: true, scope: "project" }]]), + }), + ], + ]), + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + { + read: async () => { + if (++reads === 2) activator.available = false; + return source.read(); + }, + }, + ], + ]) + ); + + await remove.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifest.getPlugins("claude")).toEqual([]); + expect(manifest.getNativeRegistrations("claude")?.pluginRefs).toEqual([REF]); + expect(await fs.readFile(cacheFile)).toBe("keep cache"); + expect(logger.warnMessages).toContain( + `claude CLI not found on PATH — '${REF}' was not uninstalled from claude's own plugin registry and may still be enabled there.` + ); + }); + + it.each([ + { name: MARKETPLACE_NAME, scope: "user" as const, readsLedger: true }, + { name: MARKETPLACE_NAME, scope: "project" as const, readsLedger: false }, + { name: "unrelated", scope: "user" as const, readsLedger: false }, + ])( + "checks the shared-source ledger only for $name/$scope without blocking project-scoped uninstall", + async ({ name, scope, readsLedger }) => { + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + const repo = new InMemoryManifestRepository(manifest); + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name, + scope, + source: { kind: "local", path: "/plugin-source" }, + addedAt: "2026-09-16T00:00:00Z", + }) + ); + const ledgerError = new UnreadableUserSourceReferencesError( + "/references.json", + "invalid JSON" + ); + const list = vi.fn(async (): Promise => { + throw ledgerError; + }); + const remove = new PluginRemoveUseCase( + new InMemoryFileAdapter(), + repo, + logger, + new Map([["claude", activator]]), + new Map([ + [ + "claude", + new FakeHostPluginRegistryReader({ + location: "/host/registry", + refs: new Map([[REF, { enabled: true, scope: "project" }]]), + }), + ], + ]), + { + addReference: async () => {}, + removeReference: async () => {}, + listAllReferencingProjects: list, + }, + registry, + undefined, + new Map([["claude", verifiedSourceReader()]]) + ); + + await remove.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(list).toHaveBeenCalledTimes(readsLedger ? 1 : 0); + expect(logger.warnMessages).toEqual(readsLedger ? [ledgerError.message] : []); + expect(activator.uninstalledPlugins).toEqual([REF]); + expect(activator.uninstalledPluginScopes).toEqual(["project"]); + expect(manifest.getNativeRegistrations("claude")?.pluginRefs).toEqual([]); + } + ); + + describe("host registry preflight preserves project state", () => { + const invalidReadings: { + name: string; + reading: HostPluginRegistryReading; + diagnostic: string; + }[] = [ + { + name: "missing registry", + reading: { location: "/host/registry", absent: true }, + diagnostic: "/host/registry", + }, + { + name: "unreadable registry", + reading: { location: "/host/registry", unreadable: "permission denied" }, + diagnostic: "permission denied", + }, + { + name: "empty registry", + reading: { location: "/host/registry", refs: new Map() }, + diagnostic: "/host/registry", + }, + { + name: "disabled activation", + reading: { + location: "/host/registry", + refs: new Map([[REF, { enabled: false, scope: "project" }]]), + }, + diagnostic: "/host/registry", + }, + { + name: "unscoped activation", + reading: { location: "/host/registry", refs: new Map([[REF, { enabled: true }]]) }, + diagnostic: "/host/registry", + }, + ]; + const registryFailures: { name: string; reader?: HostPluginRegistryReader; message: string }[] = + [ + { + name: "no reader", + message: `claude: '${REF}' has no readable host plugin registry; uninstall refused before project changes.`, + }, + ...[new Error("registry unavailable"), "registry unavailable"].map((error, index) => ({ + name: `reader throws ${index === 0 ? "Error" : "string"}`, + reader: { + read: async (): Promise => { + throw error; + }, + }, + message: `claude: '${REF}' host plugin registry read failed (registry unavailable); uninstall refused.`, + })), + ...invalidReadings.map(({ name, reading, diagnostic }) => ({ + name, + reader: new FakeHostPluginRegistryReader(reading), + message: `claude: '${REF}' is not provably enabled with an exact host scope (${diagnostic}); uninstall refused.`, + })), + ]; + + it.each(registryFailures)( + "refuses $name before native or local changes", + async ({ reader, message }) => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + const manifestRepo = new InMemoryManifestRepository(manifest); + const before = manifest.toJSON(); + const cacheFile = join( + homedir(), + ".claude/plugins/cache", + MARKETPLACE_NAME, + PLUGIN_NAME, + "1.0.0/plugin.json" + ); + await fs.writeFile(cacheFile, "cached plugin"); + const activator = new FakeNativePluginActivator({ available: true }); + const remove = new PluginRemoveUseCase( + fs, + manifestRepo, + new CapturingLogger(), + new Map([["claude", activator]]), + reader === undefined ? new Map() : new Map([["claude", reader]]), + undefined, + undefined, + undefined, + new Map([["claude", verifiedSourceReader()]]) + ); + + await expect( + remove.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(message); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.saveCount).toBe(0); + expect(manifest.toJSON()).toEqual(before); + expect(await fs.readFile(cacheFile)).toBe("cached plugin"); + } + ); + }); + + it("removes only the local projection when Claude CLI is unavailable and no native catalogue source was recorded", async () => { + const fs = new InMemoryFileAdapter(); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await new ModeAMarketplaceTranslator().addPlugin( + buildDist(), + "claude", + { kind: "local", path: "/plugin-source" }, + PROJECT_ROOT, + manifest, + MARKETPLACE_NAME + ); + const repo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: false }); + const logger = new CapturingLogger(); + const removeUseCase = new PluginRemoveUseCase( + fs, + repo, + logger, + new Map([["claude", activator]]) + ); + + await removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(repo.getCurrent()?.getPlugins("claude")).toEqual([]); + expect(logger.warnMessages.join(" ")).toMatch(/CLI not found|host ref may remain/); + }); + + it("does not uninstall or purge a same-name foreign host ref even without a machine-global claim", async () => { + const fs = new InMemoryFileAdapter(); + const cacheEntry = join( + homedir(), + ".claude/plugins/cache", + MARKETPLACE_NAME, + PLUGIN_NAME, + "1.0.0/plugin.json" + ); + fs.setFile(cacheEntry, "cache witness"); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: MARKETPLACE_NAME, + provenance: { kind: "registry", source: "/aidd/old-source" }, + }, + ], + pluginRefs: [REF], + }); + const repo = new InMemoryManifestRepository(manifest, PROJECT_ROOT); + const activator = new FakeNativePluginActivator({ available: true }); + const removeUseCase = new PluginRemoveUseCase( + fs, + repo, + new CapturingLogger(), + new Map([["claude", activator]]), + new Map(), + undefined, + undefined, + undefined, + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/host/catalogue", + entries: new Map([ + [MARKETPLACE_NAME, { kind: "registry" as const, source: "/foreign/source" }], + ]), + }), + }, + ], + ]) + ); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/source differs/); + expect(activator.uninstalledPlugins).toEqual([]); + expect(fs.getFile(cacheEntry)).toBe("cache witness"); + expect(repo.getCurrent()?.getPlugins("claude")).toHaveLength(1); + expect(repo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs).toEqual([REF]); + }); + + it("refuses an old AIDD catalogue claim when the current host source is foreign under the same name and ref", async () => { + const activator = new FakeNativePluginActivator({ available: true }); + const logger = new CapturingLogger(); + const fs = new InMemoryFileAdapter(); + const manifestRepo = new InMemoryManifestRepository(); + const machine = Manifest.create(); + machine.addTool("claude", "test", []); + machine.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: [PROJECT_ROOT] }], + }); + const userRepo = new InMemoryManifestRepository(machine); + const removeUseCase = new PluginRemoveUseCase( + fs, + manifestRepo, + logger, + new Map([["claude", activator]]), + new Map(), + undefined, + undefined, + userRepo, + new Map([ + [ + "claude", + { + read: async () => ({ + location: "/host/catalogue", + entries: new Map([ + [MARKETPLACE_NAME, { kind: "registry" as const, source: "/foreign/source" }], + ]), + }), + }, + ], + ]) + ); + const manifest = Manifest.create(); + manifest.addTool("claude", "test", []); + await installViaModeA(manifest); + manifest.setNativeRegistrations("claude", { + binary: "claude", + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: MARKETPLACE_NAME, + provenance: { kind: "registry", source: "/aidd/old-source" }, + }, + ], + pluginRefs: [REF], + }); + await manifestRepo.save(manifest); + const savesBefore = manifestRepo.saveCount; + const machineSavesBefore = userRepo.saveCount; + + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/source differs|reconcile manually/); + + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.saveCount).toBe(savesBefore); + expect(userRepo.saveCount).toBe(machineSavesBefore); + expect( + manifestRepo + .getCurrent() + ?.getPlugins("claude") + .map((plugin) => plugin.name) + ).toEqual([PLUGIN_NAME]); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginRefs).toEqual([REF]); + expect(userRepo.getCurrent()?.getNativeRegistrations("claude")?.pluginClaims).toEqual([ + { ref: REF, dependents: [PROJECT_ROOT] }, + ]); + }); + it("uninstalls via the host CLI using the same @ ref install used", async () => { const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); @@ -95,11 +591,12 @@ describe("PluginRemoveUseCase undoes native activation", () => { }); expect(activator.uninstalledPlugins).toEqual([]); - expect(logger.warnMessages).toHaveLength(1); - expect(logger.warnMessages[0]).toContain("claude"); - expect(logger.warnMessages[0]).toContain(REF); + expect( + logger.warnMessages.some((message) => message.includes("claude") && message.includes(REF)) + ).toBe(true); const loaded = await manifestRepo.load(); expect(loaded?.getPlugins("claude").some((p) => p.name === PLUGIN_NAME)).toBe(false); + expect(loaded?.getNativeRegistrations("claude")?.pluginRefs).toEqual([REF]); }); it("warns naming the host and message when the host CLI reports the plugin already absent", async () => { @@ -122,9 +619,9 @@ describe("PluginRemoveUseCase undoes native activation", () => { }) ).resolves.not.toThrow(); - expect(logger.warnMessages).toHaveLength(1); - expect(logger.warnMessages[0]).toContain("claude"); - expect(logger.warnMessages[0]).toContain(REF); + expect( + logger.warnMessages.some((message) => message.includes("claude") && message.includes(REF)) + ).toBe(true); }); it("never calls the host CLI for a plugin installed without a recorded marketplace", async () => { @@ -155,13 +652,13 @@ describe("PluginRemoveUseCase undoes native activation", () => { // A real `claude` binary registers at its own implicit `"user"` default whatever scope the // manifest records for the plugin's files, and refuses an uninstall aimed at another scope. - it("falls back to the other scope when the manifest's own scope does not match what was actually registered", async () => { + it("uses the host registry's exact user scope when the project manifest differs", async () => { const activator = new FakeNativePluginActivator({ available: true, installedAtScope: new Map([[REF, "user"]]), }); const logger = new CapturingLogger(); - const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); + const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger, "user"); const manifest = Manifest.create(); manifest.addTool("claude", "test", []); await installViaModeA(manifest); @@ -174,7 +671,7 @@ describe("PluginRemoveUseCase undoes native activation", () => { }); expect(activator.uninstalledPlugins).toEqual([REF]); - expect(activator.uninstalledPluginScopes).toEqual(["project", "user"]); + expect(activator.uninstalledPluginScopes).toEqual(["user"]); expect(logger.warnMessages).toEqual([]); }); @@ -196,7 +693,13 @@ describe("PluginRemoveUseCase undoes native activation", () => { ); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: "local", hostName: "upstream" }], + marketplaces: [ + { + alias: "local", + hostName: "upstream", + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], pluginRefs: [`${PLUGIN_NAME}@upstream`], }); await manifestRepo.save(manifest); @@ -232,7 +735,11 @@ describe("PluginRemoveUseCase undoes native activation", () => { refs: new Map([[hostRef, { enabled: true, scope: "user" }]]), }), ], - ]) + ]), + undefined, + undefined, + undefined, + new Map([["claude", verifiedSourceReader()]]) ); const manifest = Manifest.create(); manifest.addTool("claude", "test", []); @@ -246,7 +753,13 @@ describe("PluginRemoveUseCase undoes native activation", () => { ); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: "local", hostName: "upstream" }], + marketplaces: [ + { + alias: "local", + hostName: "upstream", + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], pluginRefs: [`${PLUGIN_NAME}@upstream`], }); await manifestRepo.save(manifest); @@ -261,7 +774,7 @@ describe("PluginRemoveUseCase undoes native activation", () => { expect(activator.uninstalledPluginScopes).toEqual(["user"]); }); - it("warns naming the alias when this tool's own native registrations exist but name no entry for it", async () => { + it("refuses a legacy alias with no recorded native source before changing the project", async () => { const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); @@ -275,19 +788,19 @@ describe("PluginRemoveUseCase undoes native activation", () => { }); await manifestRepo.save(manifest); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/no recorded native catalogue source/); - expect(activator.uninstalledPlugins).toEqual([REF]); - expect(logger.warnMessages.some((m) => m.includes(MARKETPLACE_NAME))).toBe(true); + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("claude")).toHaveLength(1); }); - // Two reads, not three: `removeNativeActivation` resolves the host name and the warn gate - // from one read, and `purgeCachedPlugin`'s own read is a separate decision made after it. - it("reads this tool's own native registrations once per decision, not twice for the warn gate", async () => { + it("reads an unproved native registration only for preflight, not for uninstall or cache", async () => { const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); const { removeUseCase, manifestRepo } = buildRemoveUseCase(activator, logger); @@ -304,13 +817,15 @@ describe("PluginRemoveUseCase undoes native activation", () => { if (stored === null) throw new Error("unreachable — just saved"); const spy = vi.spyOn(stored, "getNativeRegistrations"); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["claude"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["claude"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/no recorded native catalogue source/); - expect(spy).toHaveBeenCalledTimes(2); + expect(spy).toHaveBeenCalledTimes(1); }); it("uninstalls at the scope the host's own registry names directly, one attempt", async () => { @@ -334,7 +849,11 @@ describe("PluginRemoveUseCase undoes native activation", () => { refs: new Map([[REF, { enabled: true, scope: "user" }]]), }), ], - ]) + ]), + undefined, + undefined, + undefined, + new Map([["claude", verifiedSourceReader()]]) ); const manifest = Manifest.create(); manifest.addTool("claude", "test", []); @@ -360,7 +879,13 @@ describe("PluginRemoveUseCase undoes native activation", () => { await installViaModeA(manifest); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: MARKETPLACE_NAME, hostName: "upstream" }], + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: "upstream", + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], pluginRefs: [`${PLUGIN_NAME}@upstream`], }); await manifestRepo.save(manifest); @@ -393,9 +918,9 @@ describe("PluginRemoveUseCase undoes native activation", () => { projectRoot: PROJECT_ROOT, }); - expect(logger.warnMessages).toStrictEqual([ - `claude plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed — an entry for it may remain in claude's own plugin registry.`, - ]); + expect(logger.warnMessages).toContain( + `claude plugin uninstall '${REF}' failed: plugin \`${REF}\` is not installed — an entry for it may remain in claude's own plugin registry.` + ); }); it("propagates a failure that is not the host CLI refusing", async () => { @@ -427,7 +952,13 @@ describe("PluginRemoveUseCase undoes only the activation this project made", () await installViaModeA(manifest); manifest.setNativeRegistrations("claude", { binary: "claude", - marketplaces: [{ alias: MARKETPLACE_NAME, hostName: MARKETPLACE_NAME }], + marketplaces: [ + { + alias: MARKETPLACE_NAME, + hostName: MARKETPLACE_NAME, + provenance: { kind: "registry", source: "/plugin-source" }, + }, + ], pluginRefs, }); await manifestRepo.save(manifest); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts index 67d584575..cbdf8695c 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-shared-ref-guard.integration.test.ts @@ -23,7 +23,33 @@ const USER_CONFIG_DIR = "/fake-home/.config/aidd"; const PLUGIN_NAME = "aidd-vcs"; const REF = `${PLUGIN_NAME}@${FRAMEWORK_MARKETPLACE_NAME}`; -function seedManifest(marketplaceAlias: string = FRAMEWORK_MARKETPLACE_NAME): Manifest { +function proof() { + return { + kind: "effective-list" as const, + root: "/exact/codex/root", + sourceType: "local", + source: "/plugin-source", + }; +} + +function sourceReader() { + return { + read: async () => ({ + location: "/codex/plugin/marketplace/list", + entries: new Map( + [FRAMEWORK_MARKETPLACE_NAME, "upstream", "other-mkt", "gone-mkt"].map((name) => [ + name, + proof(), + ]) + ), + }), + }; +} + +function seedManifest( + marketplaceAlias: string = FRAMEWORK_MARKETPLACE_NAME, + recordNativeRegistration = true +): Manifest { const manifest = Manifest.create(); manifest.addTool("codex", "1.0.0", []); manifest.addPlugin( @@ -38,6 +64,12 @@ function seedManifest(marketplaceAlias: string = FRAMEWORK_MARKETPLACE_NAME): Ma marketplace: marketplaceAlias, }) ); + if (recordNativeRegistration) + manifest.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: marketplaceAlias, hostName: marketplaceAlias, provenance: proof() }], + pluginRefs: [`${PLUGIN_NAME}@${marketplaceAlias}`], + }); return manifest; } @@ -73,7 +105,8 @@ function buildUseCase( activator: FakeNativePluginActivator, logger: CapturingLogger, manifest: Manifest = seedManifest(), - marketplaceRegistry: InMemoryMarketplaceRegistry = seedSharedMarketplaceRegistry() + marketplaceRegistry: InMemoryMarketplaceRegistry = seedSharedMarketplaceRegistry(), + userManifestRepo?: InMemoryManifestRepository ): { removeUseCase: PluginRemoveUseCase; manifestRepo: InMemoryManifestRepository; @@ -87,38 +120,92 @@ function buildUseCase( new Map([["codex", activator]]), new Map(), userSourceReferences, - marketplaceRegistry + marketplaceRegistry, + userManifestRepo, + new Map([["codex", sourceReader()]]) ); return { removeUseCase, manifestRepo }; } describe("plugin remove guards a ref another project on this machine still needs", () => { - it("leaves codex's ref enabled and names the other project still referencing the shared source", async () => { + it("refuses a machine-global ref without a canonical machine claim even when source and references look AIDD-owned", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - seedReferences(fs, [OTHER_PROJECT]); + seedReferences(fs, []); + fs.setFile(`${PROJECT_ROOT}/user-note.md`, "user bytes"); const activator = new FakeNativePluginActivator({ available: true }); - const logger = new CapturingLogger(); - const { removeUseCase } = buildUseCase(fs, activator, logger); + const { removeUseCase, manifestRepo } = buildUseCase(fs, activator, new CapturingLogger()); + const savesBefore = manifestRepo.saveCount; - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/canonical machine claim|unclaimed machine-global/); - expect(activator.uninstalledPlugins).not.toContain(REF); - expect( - logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(OTHER_PROJECT)) - ).toBe(true); + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.saveCount).toBe(savesBefore); + expect(manifestRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([REF]); + expect(fs.getFile(`${PROJECT_ROOT}/user-note.md`)).toBe("user bytes"); }); - it("still uninstalls codex's ref when no other project references the shared source", async () => { - // `plugin remove` never drops its own claim, so a project's own root must be subtracted - // from `listAllReferencingProjects` or it reads itself back as another project. + it("detaches only A's canonical claim while B's machine-global host ref remains enabled", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); - seedReferences(fs, []); + seedReferences(fs, [OTHER_PROJECT]); + const siblingRef = `aidd-dev@${FRAMEWORK_MARKETPLACE_NAME}`; + const otherCatalogueRef = `${PLUGIN_NAME}@other-mkt`; + const untouchedRefs = [siblingRef, otherCatalogueRef]; + const project = seedManifest(); + project.addPlugin( + "codex", + InstalledPlugin.fromJSON({ + name: "aidd-dev", + source: { kind: "local", path: "/plugin-source" }, + version: "1.0.0", + strict: true, + files: {}, + scope: "project", + marketplace: FRAMEWORK_MARKETPLACE_NAME, + }) + ); + const marketplaces = [ + { alias: "other-mkt", hostName: "other-mkt", provenance: proof() }, + { + alias: FRAMEWORK_MARKETPLACE_NAME, + hostName: FRAMEWORK_MARKETPLACE_NAME, + provenance: proof(), + }, + ]; + project.setNativeRegistrations("codex", { + binary: "codex", + marketplaces, + pluginRefs: [REF, ...untouchedRefs], + }); + fs.setFile(`${OTHER_PROJECT}/plugin-content.md`, "B's plugin bytes"); + fs.setFile(`${PROJECT_ROOT}/user-note.md`, "user bytes"); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces, + pluginRefs: [REF, ...untouchedRefs], + pluginClaims: [REF, ...untouchedRefs].map((ref) => ({ + ref, + dependents: [PROJECT_ROOT, OTHER_PROJECT], + })), + }); + const userRepo = new InMemoryManifestRepository(machine); const activator = new FakeNativePluginActivator({ available: true }); - const { removeUseCase } = buildUseCase(fs, activator, new CapturingLogger()); + const { removeUseCase, manifestRepo } = buildUseCase( + fs, + activator, + new CapturingLogger(), + project, + seedSharedMarketplaceRegistry(), + userRepo + ); await removeUseCase.execute({ pluginName: PLUGIN_NAME, @@ -126,59 +213,94 @@ describe("plugin remove guards a ref another project on this machine still needs projectRoot: PROJECT_ROOT, }); - expect(activator.uninstalledPlugins).toContain(REF); + expect(activator.uninstalledPlugins).toEqual([]); + expect( + manifestRepo + .getCurrent() + ?.getPlugins("codex") + .map((plugin) => plugin.name) + ).toEqual(["aidd-dev"]); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual( + untouchedRefs + ); + expect(manifestRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual( + marketplaces + ); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: [OTHER_PROJECT] }, + ...untouchedRefs.map((ref) => ({ ref, dependents: [PROJECT_ROOT, OTHER_PROJECT] })), + ]); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + REF, + ...untouchedRefs, + ]); + expect(userRepo.getCurrent()?.getNativeRegistrations("codex")?.marketplaces).toEqual( + marketplaces + ); + expect(fs.getFile(`${OTHER_PROJECT}/plugin-content.md`)).toBe("B's plugin bytes"); + expect(fs.getFile(`${PROJECT_ROOT}/user-note.md`)).toBe("user bytes"); }); - // `refAnotherProjectStillNeeds` matches the ref's suffix against `sharedSourceHostName`, so - // a ref moved to `hostName` while that stays the alias silently stops guarding anything. - it("guards by the host's own ref when this project's alias diverges from the catalog's declared name", async () => { - // The alias is always the reserved `FRAMEWORK_MARKETPLACE_NAME`, which gates the guard; - // the divergence under test is between it and what the catalog declares as `hostName`. + it("refuses an unclaimed machine-global ref by hostName even when its alias is shared", async () => { const HOST_NAME = "upstream"; const HOST_REF = `${PLUGIN_NAME}@${HOST_NAME}`; const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); - const logger = new CapturingLogger(); const manifest = seedManifest(); manifest.setNativeRegistrations("codex", { binary: "codex", - marketplaces: [{ alias: FRAMEWORK_MARKETPLACE_NAME, hostName: HOST_NAME }], + marketplaces: [ + { alias: FRAMEWORK_MARKETPLACE_NAME, hostName: HOST_NAME, provenance: proof() }, + ], pluginRefs: [], }); - const { removeUseCase } = buildUseCase(fs, activator, logger, manifest); + const { removeUseCase, manifestRepo } = buildUseCase( + fs, + activator, + new CapturingLogger(), + manifest + ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(new RegExp(`${HOST_REF}.*unclaimed machine-global`)); - expect(activator.uninstalledPlugins).not.toContain(HOST_REF); - expect( - logger.warnMessages.some((m) => m.includes("left enabled") && m.includes(HOST_REF)) - ).toBe(true); + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); }); - it("uninstalls by the alias ref and logs no warning when this tool has no native registrations at all", async () => { + it("refuses legacy removal without native registrations before any host or local mutation", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, []); const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); - const { removeUseCase } = buildUseCase(fs, activator, logger); + const { removeUseCase, manifestRepo } = buildUseCase( + fs, + activator, + logger, + seedManifest(FRAMEWORK_MARKETPLACE_NAME, false) + ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/no recorded native catalogue source/); - expect(activator.uninstalledPlugins).toContain(REF); + expect(activator.uninstalledPlugins).toEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); expect(logger.warnMessages).toEqual([]); }); - describe("a ref outside the shared source", () => { - it("uninstalls a ref from a marketplace that is not the shared source, whatever other projects reference", async () => { + describe("a machine-global ref outside the shared source", () => { + it("refuses an unclaimed ref even when another marketplace is unrelated to the framework source", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); @@ -193,7 +315,7 @@ describe("plugin remove guards a ref another project on this machine still needs addedAt: "2026-01-01T00:00:00.000Z", }) ); - const { removeUseCase } = buildUseCase( + const { removeUseCase, manifestRepo } = buildUseCase( fs, activator, logger, @@ -201,36 +323,45 @@ describe("plugin remove guards a ref another project on this machine still needs registry ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/unclaimed machine-global/); - expect(activator.uninstalledPlugins).toStrictEqual([`${PLUGIN_NAME}@other-mkt`]); - expect(logger.warnMessages).toStrictEqual([]); + expect(activator.uninstalledPlugins).toStrictEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); }); - it("uninstalls a ref whose marketplace this project's registry no longer lists", async () => { + it("refuses an unclaimed ref whose marketplace the project's local registry no longer lists", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); const logger = new CapturingLogger(); - const { removeUseCase } = buildUseCase(fs, activator, logger, seedManifest("gone-mkt")); + const { removeUseCase, manifestRepo } = buildUseCase( + fs, + activator, + logger, + seedManifest("gone-mkt") + ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/unclaimed machine-global/); - expect(activator.uninstalledPlugins).toStrictEqual([`${PLUGIN_NAME}@gone-mkt`]); - expect(logger.warnMessages).toStrictEqual([]); + expect(activator.uninstalledPlugins).toStrictEqual([]); + expect(manifestRepo.getCurrent()?.getPlugins("codex")).toHaveLength(1); }); }); - describe("without one of the guard's two registries", () => { - it("skips the guard when no references registry is wired, even for the shared source", async () => { + describe("without one of the optional shared-source registries", () => { + it("still refuses unclaimed machine-global removal without references.json", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); @@ -241,19 +372,23 @@ describe("plugin remove guards a ref another project on this machine still needs new Map([["codex", activator]]), new Map(), undefined, - seedSharedMarketplaceRegistry() + seedSharedMarketplaceRegistry(), + undefined, + new Map([["codex", sourceReader()]]) ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/unclaimed machine-global/); - expect(activator.uninstalledPlugins).toStrictEqual([REF]); + expect(activator.uninstalledPlugins).toStrictEqual([]); }); - it("skips the guard when no marketplace registry is wired", async () => { + it("still refuses unclaimed machine-global removal without a local marketplace registry", async () => { const fs = new InMemoryFileAdapter({}, new DeterministicHasher()); seedReferences(fs, [OTHER_PROJECT]); const activator = new FakeNativePluginActivator({ available: true }); @@ -263,16 +398,21 @@ describe("plugin remove guards a ref another project on this machine still needs new CapturingLogger(), new Map([["codex", activator]]), new Map(), - new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR) + new UserSourceReferencesAdapter(fs, () => USER_CONFIG_DIR), + undefined, + undefined, + new Map([["codex", sourceReader()]]) ); - await removeUseCase.execute({ - pluginName: PLUGIN_NAME, - toolIds: ["codex"], - projectRoot: PROJECT_ROOT, - }); + await expect( + removeUseCase.execute({ + pluginName: PLUGIN_NAME, + toolIds: ["codex"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/unclaimed machine-global/); - expect(activator.uninstalledPlugins).toStrictEqual([REF]); + expect(activator.uninstalledPlugins).toStrictEqual([]); }); }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts index 3e3dc52f1..6dfe4ba3a 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-remove-use-case.unit.test.ts @@ -70,7 +70,8 @@ async function installPlugin( deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); await addUseCase.execute({ source: { kind: "local", path: fixture }, diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts index b43bbb6d8..6a000ab65 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-built-tree.unit.test.ts @@ -1,11 +1,19 @@ import { join } from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; +import "../../../../../src/contexts/tools/domain/profiles/copilot/profile.js"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; +import { NativeHostRegistrationGate } from "../../../../../src/contexts/framework/application/ownership/native-host-registration-gate.js"; +import { UserPluginDistributionLoader } from "../../../../../src/contexts/framework/application/ownership/user-plugin-distribution-loader.js"; +import { UserPluginFileUpdater } from "../../../../../src/contexts/framework/application/ownership/user-plugin-file-updater.js"; +import { UserPluginUpdateUseCase } from "../../../../../src/contexts/framework/application/ownership/user-plugin-update-use-case.js"; import { PluginAddUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; -import { PluginUpdateUseCase } from "../../../../../src/contexts/framework/application/plugin/plugin-update-use-case.js"; +import type { EnsureBuiltMarketplace } from "../../../../../src/contexts/framework/application/shared/ensure-built-marketplace-use-case.js"; +import { Manifest } from "../../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { PluginDistributionReaderAdapter } from "../../../../../src/contexts/framework/infrastructure/plugin-distribution-reader-adapter.js"; import { buildUnitDeps, initAndInstall } from "../../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { FakeNativePluginActivator } from "../../../../helpers/ports/fake-native-plugin-activator.js"; import { InMemoryMarketplaceRegistry } from "../../../../helpers/ports/in-memory-marketplace-registry.js"; import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory.js"; @@ -40,18 +48,29 @@ async function makeRegistry(): Promise { return registry; } -function makeUpdateUseCase(deps: Deps, registry: InMemoryMarketplaceRegistry): PluginUpdateUseCase { - return new PluginUpdateUseCase( - deps.fs, - deps.manifestRepo, - deps.pluginFetcher, - new PluginDistributionReaderAdapter(deps.fs), - deps.hasher, - { - ensureBuilt: fakeEnsureBuiltMarketplace(), - marketplaceRegistry: registry, - homedir: () => HOME, - } +function makeUpdateUseCase( + deps: Deps, + registry: InMemoryMarketplaceRegistry, + nativeHost: NativeHostRegistrationGate = new NativeHostRegistrationGate(new Map(), new Map()), + ensureBuilt: EnsureBuiltMarketplace = fakeEnsureBuiltMarketplace() +): UserPluginUpdateUseCase { + return new UserPluginUpdateUseCase( + deps.userManifestRepo, + new UserPluginFileUpdater( + deps.fs, + new UserPluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), + deps.hasher, + { + ensureBuilt, + marketplaceRegistry: registry, + homedir: () => HOME, + } + ), + deps.logger, + nativeHost ); } @@ -72,7 +91,8 @@ async function installStalePlugin( deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: GIT_SUBDIR_SOURCE, toolIds: ["cursor"], @@ -88,9 +108,362 @@ async function installStalePlugin( if (plugin === undefined) throw new Error("plugin not found"); manifest.updatePlugin("cursor", plugin.withVersion("0.0.1")); await deps.manifestRepo.save(manifest); + const machine = deps.userManifestRepo.getCurrent(); + if (machine === null) throw new Error("user manifest not found"); + const owned = machine.getPlugins("cursor").find((p) => p.name === "sample-plugin"); + if (owned === undefined) throw new Error("machine plugin not found"); + // The fake built-tree install records this file but its test adapter does not deliver it. + // Supply the recorded install bytes so the provenance guard has a real current file. + deps.fs.setFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"), "# Demo skill"); + machine.updatePlugin("cursor", owned.withVersion("0.0.1")); + await deps.userManifestRepo.save(machine); +} + +function attachFutureSupportedNativeClaim(deps: Deps) { + const machine = deps.userManifestRepo.getCurrent(); + if (machine === null) throw new Error("fixture missing machine manifest"); + machine.addTool("copilot", "future-supported", []); + machine.setNativeRegistrations("copilot", { + binary: "copilot", + marketplaces: [ + { + alias: "aidd-catalog", + hostName: "aidd-catalog", + provenance: { kind: "registry", source: "/aidd/catalog" }, + }, + ], + pluginRefs: ["native-plugin@aidd-catalog"], + pluginClaims: [{ ref: "native-plugin@aidd-catalog", dependents: [PROJECT_ROOT] }], + }); + const activator = new FakeNativePluginActivator({ available: true }); + const nativeHost = new NativeHostRegistrationGate( + new Map([["copilot", activator]]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/host/plugin-registry", + refs: new Map([ + ["native-plugin@aidd-catalog", { enabled: true, scope: "user" as const }], + ]), + }), + }, + ], + ]), + new Map([ + [ + "copilot", + { + read: async () => ({ + location: "/verified/future/host-source-port", + entries: new Map([ + ["aidd-catalog", { kind: "registry" as const, source: "/aidd/catalog" }], + ]), + }), + }, + ], + ]) + ); + return { machine, activator, nativeHost }; } describe("PluginUpdateUseCase — built-tree materialization", () => { + it("refuses an absent machine manifest before touching user files", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + const registry = await makeRegistry(); + await deps.userManifestRepo.delete(); + const path = join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"); + deps.fs.setFile(path, "user bytes"); + const savesBefore = deps.userManifestRepo.saveCount; + + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/No machine manifest.*ownership/); + + expect(deps.userManifestRepo.getCurrent()).toBeNull(); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(deps.fs.getFile(path)).toBe("user bytes"); + }); + + it("checks current user bytes inside the machine repository's exclusive-access boundary", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const path = join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"); + const before = deps.userManifestRepo.getCurrent()?.toJSON(); + const savesBefore = deps.userManifestRepo.saveCount; + let acquired = false; + Object.assign(deps.userManifestRepo, { + withExclusiveAccess: async (operation: () => Promise) => { + acquired = true; + deps.fs.setFile(path, "user edit at lock acquisition"); + return operation(); + }, + }); + + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/edited after install/); + + expect(acquired).toBe(true); + expect(deps.fs.getFile(path)).toBe("user edit at lock acquisition"); + expect(deps.userManifestRepo.getCurrent()?.toJSON()).toStrictEqual(before); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + }); + + it.each([{ dependents: [] }, { dependents: [PROJECT_ROOT, "/B"] }])( + "warns only for actual dependent projects when updating shared native and file plugins: $dependents", + async ({ dependents }) => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const { machine, activator, nativeHost } = attachFutureSupportedNativeClaim(deps); + const owned = machine.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin"); + const native = machine.getNativeRegistrations("copilot"); + if (owned === undefined || native === undefined) + throw new Error("fixture missing selected claims"); + machine.updatePlugin("cursor", owned.withDependents(dependents)); + machine.setNativeRegistrations("copilot", { + ...native, + pluginClaims: [{ ref: "native-plugin@aidd-catalog", dependents }], + }); + const warnings = vi.spyOn(deps.logger, "warn").mockImplementation(() => {}); + + expect( + await makeUpdateUseCase(deps, registry, nativeHost).execute({ + toolIds: ["copilot", "cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toStrictEqual(["native-plugin@aidd-catalog", "sample-plugin"]); + + expect(warnings.mock.calls).toStrictEqual( + dependents.length === 0 + ? [] + : [ + [ + `copilot: updating native ref 'native-plugin@aidd-catalog' affects ${PROJECT_ROOT}, /B; projects must refresh their local integrations afterward.`, + ], + [ + `cursor: updating user-scope 'sample-plugin' affects ${PROJECT_ROOT}, /B; each project must refresh its own integration afterward.`, + ], + ] + ); + expect(activator.updatedPlugins).toStrictEqual(["native-plugin@aidd-catalog"]); + const after = deps.userManifestRepo.getCurrent(); + expect(after?.getNativeRegistrations("copilot")?.pluginClaims).toStrictEqual([ + { ref: "native-plugin@aidd-catalog", dependents }, + ]); + expect( + after?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin")?.dependents + ).toStrictEqual(dependents); + expect( + after?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin")?.version + ).toBe("1.0.0"); + } + ); + + it("updates only the selected user plugin and exact native ref, preserving unrelated claims and project records", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const { machine, activator, nativeHost } = attachFutureSupportedNativeClaim(deps); + const native = machine.getNativeRegistrations("copilot"); + if (native === undefined) throw new Error("fixture missing native registrations"); + const otherRef = "native-plugin@other-catalog"; + machine.setNativeRegistrations("copilot", { + ...native, + pluginClaims: [...(native.pluginClaims ?? []), { ref: otherRef, dependents: ["/B"] }], + }); + const unselected = InstalledPlugin.fromMetadata( + "unselected-plugin", + "0.0.1", + { kind: "local", path: "/unselected/source" }, + false, + "user" + ).withDependents([PROJECT_ROOT, "/B"]); + const projectOnly = InstalledPlugin.fromMetadata( + "project-only", + "0.0.1", + { kind: "local", path: "/project/source" }, + false, + "project" + ); + machine.addPlugin("cursor", unselected); + machine.addPlugin("cursor", projectOnly); + const owned = machine.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin"); + if (owned === undefined) throw new Error("fixture missing user plugin"); + machine.updatePlugin("cursor", owned.withDependents([PROJECT_ROOT, "/B"])); + const claimsBefore = machine.getNativeRegistrations("copilot")?.pluginClaims; + deps.fs.setFile(BUILT_SKILL, "# Selected update"); + const otherPath = join(USER_PLUGINS_DIR, "unselected-plugin/skills/demo/SKILL.md"); + deps.fs.setFile(otherPath, "unselected user bytes"); + + expect( + await makeUpdateUseCase(deps, registry, nativeHost).execute({ + pluginNames: ["native-plugin@aidd-catalog", "sample-plugin"], + toolIds: ["copilot", "cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual(["native-plugin@aidd-catalog", "sample-plugin"]); + + expect(activator.updatedPlugins).toEqual(["native-plugin@aidd-catalog"]); + const after = deps.userManifestRepo.getCurrent(); + expect(after?.getNativeRegistrations("copilot")?.pluginClaims).toEqual(claimsBefore); + expect( + after?.getPlugins("cursor").find((plugin) => plugin.name === "unselected-plugin") + ).toEqual(unselected); + expect(after?.getPlugins("cursor").find((plugin) => plugin.name === "project-only")).toEqual( + projectOnly + ); + expect( + after?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin")?.dependents + ).toEqual([PROJECT_ROOT, "/B"]); + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"))).toBe( + "# Selected update" + ); + expect(deps.fs.getFile(otherPath)).toBe("unselected user bytes"); + }); + + it("refuses one ambiguous native name among several selected plugins before any file or host update", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const { machine, activator, nativeHost } = attachFutureSupportedNativeClaim(deps); + const native = machine.getNativeRegistrations("copilot"); + if (native === undefined) throw new Error("fixture missing native registrations"); + machine.setNativeRegistrations("copilot", { + ...native, + pluginClaims: [ + ...(native.pluginClaims ?? []), + { + ref: "native-plugin@other-catalog", + dependents: ["/B"], + }, + ], + }); + const before = machine.toJSON(); + const path = join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"); + const bytes = deps.fs.getFile(path); + const savesBefore = deps.userManifestRepo.saveCount; + deps.fs.setFile(BUILT_SKILL, "# Must not be delivered"); + + await expect( + makeUpdateUseCase(deps, registry, nativeHost).execute({ + pluginNames: ["sample-plugin", "native-plugin"], + toolIds: ["cursor", "copilot"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/Multiple native catalogues.*exact/); + + expect(activator.updatedPlugins).toEqual([]); + expect(deps.fs.getFile(path)).toBe(bytes); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(deps.userManifestRepo.getCurrent()?.toJSON()).toEqual(before); + }); + + it("excludes built Cursor hooks from the updated user plugin and leaves both projects' hooks unchanged", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + deps.fs.setFile(BUILT_SKILL, "# Updated skill"); + const builtHooks = JSON.stringify({ hooks: { preToolUse: [{ command: "node pre.js" }] } }); + deps.fs.setFile("/built/cursor/plugins/sample-plugin/hooks/hooks.json", builtHooks); + deps.fs.setFile("/built/cursor/plugins/sample-plugin/hooks/pre.js", "built hook script"); + const hookConfig = (timeout: number) => + JSON.stringify({ + version: 1, + hooks: { preToolUse: [{ command: "node ./.cursor/hooks/sample-plugin/pre.js", timeout }] }, + }); + const projectFiles = new Map([ + [join(PROJECT_ROOT, ".cursor/hooks.json"), hookConfig(10)], + [join(PROJECT_ROOT, ".cursor/hooks/sample-plugin/pre.js"), "A's adapted script"], + ["/B/.cursor/hooks.json", hookConfig(20)], + ["/B/.cursor/hooks/sample-plugin/pre.js", "B's adapted script"], + ]); + for (const [path, content] of projectFiles) deps.fs.setFile(path, content); + + expect( + await makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual(["sample-plugin"]); + + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"))).toBe( + "# Updated skill" + ); + expect( + deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/hooks/hooks.json")) + ).toBeUndefined(); + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/hooks/pre.js"))).toBeUndefined(); + expect( + [...(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.files.keys() ?? [])].some( + (path) => path.startsWith("sample-plugin/hooks/") + ) + ).toBe(false); + for (const [path, content] of projectFiles) expect(deps.fs.getFile(path)).toBe(content); + }); + + it("uses the plugin's named marketplace build rather than the first registered catalogue", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = new InMemoryMarketplaceRegistry(); + await registry.save( + PROJECT_ROOT, + Marketplace.create({ + name: "unrelated-first", + source: { kind: "local", path: "/unrelated" }, + scope: "project", + addedAt: "2026-05-01T00:00:00.000Z", + }) + ); + for (const marketplace of await (await makeRegistry()).list(PROJECT_ROOT)) + await registry.save(PROJECT_ROOT, marketplace); + await installStalePlugin(deps, registry); + deps.fs.setFile("/built/right/plugins/sample-plugin/skills/demo/SKILL.md", "# Right catalogue"); + deps.fs.setFile("/built/wrong/plugins/sample-plugin/skills/demo/SKILL.md", "# Wrong catalogue"); + const ensureBuilt: EnsureBuiltMarketplace = { + execute: async ({ marketplace }) => ({ + builtDir: marketplace.name === "aidd-framework" ? "/built/right" : "/built/wrong", + version: "test", + rebuilt: true, + }), + }; + + expect( + await makeUpdateUseCase(deps, registry, undefined, ensureBuilt).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual(["sample-plugin"]); + + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"))).toBe( + "# Right catalogue" + ); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.marketplace).toBe( + "aidd-framework" + ); + }); + it("re-materializes from the built tree for a marketplace plugin", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "cursor"); @@ -102,13 +475,17 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { const updated = await makeUpdateUseCase(deps, registry).execute({ toolIds: ["cursor"], projectRoot: PROJECT_ROOT, + scope: "user", }); expect(updated).toContain("sample-plugin"); - const manifest = await deps.manifestRepo.load(); + const manifest = deps.userManifestRepo.getCurrent(); const plugin = manifest?.getPlugins("cursor").find((p) => p.name === "sample-plugin"); expect(plugin?.version).toBe("1.0.0"); expect(plugin?.files.size).toBeGreaterThan(0); + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"))).toBe( + "# Demo skill v2" + ); }); it("writes the updated plugin under the user-scope base dir, not the project root", async () => { @@ -120,9 +497,10 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { await makeUpdateUseCase(deps, registry).execute({ toolIds: ["cursor"], projectRoot: PROJECT_ROOT, + scope: "user", }); - const manifest = await deps.manifestRepo.load(); + const manifest = deps.userManifestRepo.getCurrent(); const plugin = manifest?.getPlugins("cursor").find((p) => p.name === "sample-plugin"); const written = [...(plugin?.files.keys() ?? [])]; expect(written.length).toBeGreaterThan(0); @@ -131,4 +509,271 @@ describe("PluginUpdateUseCase — built-tree materialization", () => { expect(deps.fs.getFile(join(PROJECT_ROOT, relativePath))).toBeUndefined(); } }); + + it("does not rewrite or advance a plugin already at the fetched version", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const machine = deps.userManifestRepo.getCurrent(); + const stale = machine?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin"); + if (machine === null || stale === undefined) throw new Error("fixture missing plugin"); + machine.updatePlugin("cursor", stale.withVersion("1.0.0")); + const savesBefore = deps.userManifestRepo.saveCount; + expect( + await makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual([]); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(machine.getPlugins("cursor")[0]?.version).toBe("1.0.0"); + }); + + it("refuses an escaped old user file before writing a fetched build or changing its claim", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const foreign = "/foreign/sample-plugin"; + const dir = join(USER_PLUGINS_DIR, "sample-plugin"); + deps.fs.setSymlink(dir, foreign); + deps.fs.setFile(join(foreign, "skills/demo/SKILL.md"), "foreign bytes"); + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/unsafe recorded files/); + expect(deps.fs.getFile(join(foreign, "skills/demo/SKILL.md"))).toBe("foreign bytes"); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]?.version).toBe("0.0.1"); + }); + + it("refuses to overwrite a user-edited Cursor plugin file and retains its installed digest", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const path = join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md"); + const before = deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]; + if (before === undefined) throw new Error("fixture missing plugin claim"); + deps.fs.setFile(path, "# User edited skill\n"); + const savesBefore = deps.userManifestRepo.saveCount; + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/edited.*SKILL.md|SKILL.md.*edited/); + expect(deps.fs.getFile(path)).toBe("# User edited skill\n"); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0].files).toEqual(before.files); + }); + + it("refuses a new built file that the user created after v1 without changing bytes or claims", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const newRelativePath = "sample-plugin/skills/demo/new.md"; + const destination = join(USER_PLUGINS_DIR, newRelativePath); + const machineBefore = deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]; + if (machineBefore === undefined) throw new Error("fixture missing plugin claim"); + const savesBefore = deps.userManifestRepo.saveCount; + deps.fs.setFile(join("/built/cursor/plugins", newRelativePath), "# Built v2\n"); + deps.fs.setFile(destination, "# User-created\n"); + + await expect( + makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/new.md.*untracked|untracked.*new.md/); + + expect(deps.fs.getFile(destination)).toBe("# User-created\n"); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]).toEqual(machineBefore); + }); + + it("preflights a Cursor user-file collision before any selected native host update", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const { machine, activator, nativeHost } = attachFutureSupportedNativeClaim(deps); + const newRelativePath = "sample-plugin/skills/demo/new.md"; + const destination = join(USER_PLUGINS_DIR, newRelativePath); + deps.fs.setFile(join("/built/cursor/plugins", newRelativePath), "# Built v2\n"); + deps.fs.setFile(destination, "# User-created\n"); + const claimBefore = machine.getNativeRegistrations("copilot")?.pluginClaims; + const savesBefore = deps.userManifestRepo.saveCount; + + await expect( + makeUpdateUseCase(deps, registry, nativeHost).execute({ + toolIds: ["copilot", "cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).rejects.toThrow(/new.md.*untracked|untracked.*new.md/); + + expect(activator.updatedPlugins).toEqual([]); + expect(deps.fs.getFile(destination)).toBe("# User-created\n"); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(machine.getNativeRegistrations("copilot")?.pluginClaims).toEqual(claimBefore); + }); + + it("refuses a tracked old path redirected outside the user boundary between plan and apply", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const machine = deps.userManifestRepo.getCurrent(); + const stale = machine?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin"); + if (machine === null || stale === undefined) throw new Error("fixture missing plugin"); + const oldRelativePath = "sample-plugin/skills/obsolete/SKILL.md"; + deps.fs.setFile(join(USER_PLUGINS_DIR, oldRelativePath), "old owned bytes"); + machine.updatePlugin( + "cursor", + stale.withFiles( + new Map([...stale.files, [oldRelativePath, deps.hasher.hash("old owned bytes").value]]) + ) + ); + const updater = new UserPluginFileUpdater( + deps.fs, + new UserPluginDistributionLoader( + deps.pluginFetcher, + new PluginDistributionReaderAdapter(deps.fs) + ), + deps.hasher, + { + ensureBuilt: fakeEnsureBuiltMarketplace(), + marketplaceRegistry: registry, + homedir: () => HOME, + } + ); + const plan = await updater.planUpdate( + machine.getPlugins("cursor")[0], + "cursor", + PROJECT_ROOT, + deps.logger + ); + if (plan === null) throw new Error("fixture did not plan a newer build"); + const foreign = "/foreign/obsolete"; + deps.fs.setSymlink(join(USER_PLUGINS_DIR, "sample-plugin/skills/obsolete"), foreign); + deps.fs.setFile(join(foreign, "SKILL.md"), "outside user bytes"); + const writeSpy = vi.spyOn(deps.fs, "writeFile"); + + await expect(updater.applyUpdate(plan, deps.logger)).rejects.toThrow(/unsafe recorded files/); + + expect(writeSpy).not.toHaveBeenCalled(); + expect(deps.fs.getFile(join(foreign, "SKILL.md"))).toBe("outside user bytes"); + expect(machine.getPlugins("cursor")[0]?.version).toBe("0.0.1"); + }); + + it("reports manual reconciliation when user-file I/O fails after an irreversible native update", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const { machine, activator, nativeHost } = attachFutureSupportedNativeClaim(deps); + const newRelativePath = "sample-plugin/skills/demo/new.md"; + const destination = join(USER_PLUGINS_DIR, newRelativePath); + deps.fs.setFile(join("/built/cursor/plugins", newRelativePath), "# Built v2\n"); + const writeFile = deps.fs.writeFile.bind(deps.fs); + const writeFailure = new Error("EACCES: injected destination write failure"); + vi.spyOn(deps.fs, "writeFile").mockImplementation(async (path, content) => { + if (path === destination) throw writeFailure; + return writeFile(path, content); + }); + const savesBefore = deps.userManifestRepo.saveCount; + + const operation = makeUpdateUseCase(deps, registry, nativeHost).execute({ + toolIds: ["copilot", "cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }); + await expect(operation).rejects.toThrow( + /native ref may already have been updated.*reconcile manually/ + ); + await expect(operation).rejects.toMatchObject({ cause: writeFailure }); + + expect(activator.updatedPlugins).toEqual(["native-plugin@aidd-catalog"]); + expect(deps.userManifestRepo.saveCount).toBe(savesBefore); + expect(machine.getPlugins("cursor")[0]?.version).toBe("0.0.1"); + expect(machine.getNativeRegistrations("copilot")?.pluginClaims).toEqual([ + { ref: "native-plugin@aidd-catalog", dependents: [PROJECT_ROOT] }, + ]); + }); + + it("prunes only old owned paths and keeps dependent roots when replacing the user files", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await installStalePlugin(deps, registry); + const machine = deps.userManifestRepo.getCurrent(); + const stale = machine?.getPlugins("cursor").find((plugin) => plugin.name === "sample-plugin"); + if (machine === null || stale === undefined) throw new Error("fixture missing plugin"); + const oldPath = "sample-plugin/skills/obsolete/SKILL.md"; + const oldAbsolute = join(USER_PLUGINS_DIR, oldPath); + deps.fs.setFile(oldAbsolute, "old owned bytes"); + machine.updatePlugin( + "cursor", + stale + .withFiles(new Map([...stale.files, [oldPath, deps.hasher.hash("old owned bytes").value]])) + .withDependents([PROJECT_ROOT, "/B"]) + ); + expect( + await makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual(["sample-plugin"]); + expect(deps.fs.getFile(oldAbsolute)).toBeUndefined(); + const owned = deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]; + expect(owned?.dependents).toEqual([PROJECT_ROOT, "/B"]); + expect(owned?.files.has(oldPath)).toBe(false); + }); + + it("translates a local user plugin without taking an unrelated marketplace's built tree", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const registry = await makeRegistry(); + await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "sample-plugin", + "0.0.1", + { kind: "local", path: PLUGIN_FIXTURE }, + false, + "user" + ).withDependents([PROJECT_ROOT]) + ); + await deps.userManifestRepo.save(machine); + expect( + await makeUpdateUseCase(deps, registry).execute({ + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + scope: "user", + }) + ).toEqual(["sample-plugin"]); + const owned = deps.userManifestRepo.getCurrent()?.getPlugins("cursor")[0]; + expect(owned?.version).toBe("1.0.0"); + expect(owned?.dependents).toEqual([PROJECT_ROOT]); + expect(owned?.files.size).toBeGreaterThan(0); + expect([...(owned?.files.keys() ?? [])].some((path) => path.includes("skills/hello"))).toBe( + true + ); + expect([...(owned?.files.keys() ?? [])].some((path) => path.includes("hooks/"))).toBe(false); + expect( + deps.fs.getFile(join(USER_PLUGINS_DIR, "sample-plugin/skills/demo/SKILL.md")) + ).toBeUndefined(); + }); }); diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts index ddf998fce..c55d9e66b 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-mode-a-marketplace.unit.test.ts @@ -74,7 +74,8 @@ async function installStaleGithubPlugin( deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: GIT_SUBDIR_SOURCE, toolIds: [toolId], diff --git a/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts index a6750db11..6312d5143 100644 --- a/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/plugin/plugin-update-use-case.unit.test.ts @@ -48,7 +48,8 @@ async function setup( deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); const updateUseCase = new PluginUpdateUseCase( deps.fs, @@ -87,6 +88,42 @@ function installed(deps: Deps, name: string) { } describe("PluginUpdateUseCase", () => { + it("skips machine-owned Cursor plugins in an unnamed project sweep", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const { addUseCase, updateUseCase } = await setup(deps); + await addUseCase.execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + const machineBefore = deps.userManifestRepo.getCurrent()?.toJSON(); + expect(await updateUseCase.execute({ toolIds: ["cursor"], projectRoot: PROJECT_ROOT })).toEqual( + [] + ); + expect(deps.userManifestRepo.getCurrent()?.toJSON()).toEqual(machineBefore); + }); + + it("refuses an explicitly named Cursor plugin at project scope", async () => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initAndInstall(deps, PROJECT_ROOT, "cursor"); + const { addUseCase, updateUseCase } = await setup(deps); + await addUseCase.execute({ + source: { kind: "local", path: PLUGIN_FIXTURE }, + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + interactive: false, + }); + await expect( + updateUseCase.execute({ + pluginNames: ["sample-plugin"], + toolIds: ["cursor"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/does not support scope 'project'/); + }); + describe("same version", () => { it("does not re-write files when version is equal", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts index 8baf4011e..b7633554f 100644 --- a/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-all-use-case.unit.test.ts @@ -1,3 +1,4 @@ +import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { RestoreAllUseCase } from "../../../../src/contexts/framework/application/global/restore-all-use-case.js"; @@ -46,7 +47,8 @@ async function installPlugin( deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, toolIds: [toolId], @@ -188,32 +190,27 @@ describe("RestoreAllUseCase — plugin materialization", () => { expect(count()).toBe(1); }); - it("restores a corrupted plugin file with exactly one materialization call (cursor — installScope:user tool)", async () => { - // A local-source install never reaches restoreViaBuiltTree — that path requires - // plugin.marketplace — so this exercises restoreViaTranslate for an installScope:"user" tool. + it("leaves a corrupted shared user plugin to explicit user-scope repair (cursor)", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "cursor"); await seedFromDirectory(deps.fs, PLUGIN_FIXTURE, { useAbsolutePaths: true }); await installPlugin(deps, "cursor", new PluginDistributionReaderAdapter(deps.fs)); - const manifestAfterInstall = await deps.manifestRepo.load(); + const manifestAfterInstall = deps.userManifestRepo.getCurrent(); const plugin = manifestAfterInstall ?.getPlugins("cursor") .find((p) => p.name === "sample-plugin"); const trackedRelativePath = [...(plugin?.files.keys() ?? [])][0]; expect(trackedRelativePath).toBeDefined(); - // plugin.files keys are relativePath (see restoreViaTranslate); actual fs storage is - // keyed by the absolute path the file was written to. - const pluginFile = join(PROJECT_ROOT, trackedRelativePath as string); + const pluginFile = join(homedir(), ".cursor/plugins/local", trackedRelativePath as string); await deps.fs.writeFile(pluginFile, "CORRUPTED CONTENT"); - // Counting reader wired only from here — installPlugin's own read() must not count. const { reader, count } = countingReader(deps.fs); const useCase = makeRestoreAllUseCase(deps, reader, new OverwritePrompter(), true); await useCase.execute(PROJECT_ROOT, false, false); - expect(deps.fs.getFile(pluginFile)).not.toBe("CORRUPTED CONTENT"); - expect(count()).toBe(1); + expect(deps.fs.getFile(pluginFile)).toBe("CORRUPTED CONTENT"); + expect(count()).toBe(0); }); it("result.pluginNamesRestored lists the restored plugin exactly once", async () => { @@ -297,7 +294,8 @@ describe("RestoreAllUseCase — plugin materialization", () => { deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, toolIds: ["codex"], diff --git a/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts index 2580bc7af..72c8f48cb 100644 --- a/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/restore-use-case.unit.test.ts @@ -38,7 +38,8 @@ async function installPlugin( deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, toolIds: [toolId], diff --git a/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts b/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts index bd38645eb..125197399 100644 --- a/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/apply-plugin-files-built-tree.unit.test.ts @@ -1,3 +1,4 @@ +import { homedir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import { Marketplace } from "../../../../../src/contexts/distribution/domain/marketplace.js"; @@ -11,9 +12,7 @@ import { seedFromDirectory } from "../../../../helpers/ports/seed-from-directory const PLUGIN_FIXTURE = join(process.cwd(), "tests/fixtures/plugins/claude-format/sample-plugin"); const PROJECT_ROOT = "/test-project"; -const HOME = "/home/u"; -/** Where cursor's PluginsCapability resolves user-scope plugin writes to. */ -const USER_PLUGINS_DIR = join(HOME, ".cursor/plugins/local"); +const USER_PLUGINS_DIR = join(homedir(), ".cursor/plugins/local"); const BUILT_SKILL = "/built/cursor/plugins/sample-plugin/skills/demo/SKILL.md"; const GIT_SUBDIR_SOURCE = { @@ -52,7 +51,7 @@ function makeRestoreUseCase( { ensureBuilt: fakeEnsureBuiltMarketplace(), marketplaceRegistry: registry, - homedir: () => HOME, + homedir, } ); } @@ -74,7 +73,8 @@ async function installMarketplacePlugin( deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: GIT_SUBDIR_SOURCE, toolIds: ["cursor"], @@ -86,13 +86,13 @@ async function installMarketplacePlugin( } describe("RestoreAllPluginsUseCase — built-tree materialization", () => { - it("re-materializes a marketplace plugin's files from the built tree at the user-scope dir", async () => { + it("does not re-materialize a shared user plugin during project restore", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initAndInstall(deps, PROJECT_ROOT, "cursor"); const registry = await makeRegistry(); await installMarketplacePlugin(deps, registry); - const manifestBefore = await deps.manifestRepo.load(); + const manifestBefore = deps.userManifestRepo.getCurrent(); if (manifestBefore === null) throw new Error("manifest not found"); const installedRelPaths = [ ...(manifestBefore @@ -113,17 +113,17 @@ describe("RestoreAllPluginsUseCase — built-tree materialization", () => { fileFilter: null, }); - expect(result.pluginNames).toContain("sample-plugin"); - expect(result.totalFiles).toBeGreaterThan(0); + expect(result.pluginNames).not.toContain("sample-plugin"); + expect(result.totalFiles).toBe(0); for (const relativePath of installedRelPaths) { - expect(deps.fs.getFile(join(USER_PLUGINS_DIR, relativePath))).toBeDefined(); + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, relativePath))).toBeUndefined(); expect(deps.fs.getFile(join(PROJECT_ROOT, relativePath))).toBeUndefined(); } const plugins = manifest.getPlugins("cursor").filter((p) => p.name === "sample-plugin"); expect(plugins).toHaveLength(1); - expect([...plugins[0].files.keys()].sort()).toEqual([...installedRelPaths].sort()); + expect(plugins[0].files.size).toBe(0); }); it("reports zero files restored when a built-tree restore finds nothing drifted", async () => { @@ -135,16 +135,15 @@ describe("RestoreAllPluginsUseCase — built-tree materialization", () => { const manifest = await deps.manifestRepo.load(); if (manifest === null) throw new Error("manifest not found"); const installedRelPaths = [ - ...(manifest - .getPlugins("cursor") + ...(deps.userManifestRepo + .getCurrent() + ?.getPlugins("cursor") .find((p) => p.name === "sample-plugin") ?.files.keys() ?? []), ]; expect(installedRelPaths.length).toBeGreaterThan(0); const builtContent = deps.fs.getFile(BUILT_SKILL); if (builtContent === undefined) throw new Error("built fixture missing"); - // Seed the user-scope plugin dir with exactly what the built tree already - // materializes, so this restore has nothing left to change. for (const relativePath of installedRelPaths) { await deps.fs.writeFile(join(USER_PLUGINS_DIR, relativePath), builtContent); } @@ -156,6 +155,9 @@ describe("RestoreAllPluginsUseCase — built-tree materialization", () => { }); expect(result.totalFiles).toBe(0); + for (const relativePath of installedRelPaths) { + expect(deps.fs.getFile(join(USER_PLUGINS_DIR, relativePath))).toBe(builtContent); + } }); it("keeps the manifest's single entry for the plugin after restore (no duplicate registration)", async () => { diff --git a/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts b/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts index a75c05162..85ea9d9b8 100644 --- a/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/apply-plugin-files-mode-a-marketplace.unit.test.ts @@ -73,7 +73,8 @@ async function installGithubPlugin( deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: GIT_SUBDIR_SOURCE, toolIds: ["claude"], diff --git a/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts b/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts index 673bf4101..d54f8d597 100644 --- a/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/purge-native-marketplace-cache.unit.test.ts @@ -48,6 +48,77 @@ describe("purgeAllNativeCaches", () => { }); describe("purgeNativeMarketplaceCache", () => { + it("keeps the cache and names the registry that still owns its tenant after a confirmed removal", async () => { + const path = join(CANDIDATE, "plugin.json"); + const fs = new InMemoryFileAdapter({ [path]: "still registered" }); + const logger = new CapturingLogger(); + const location = "/host/known_marketplaces.json"; + const reader = new FakeHostMarketplaceRegistryReader({ + location, + entries: new Map([[HOST_NAME, "/another-project/marketplace"]]), + }); + + await purgeNativeMarketplaceCache(fs, logger, reader, CACHE_ROOT, "claude", HOST_NAME, true); + + expect(fs.getFile(path)).toBe("still registered"); + expect(reader.reads).toBe(1); + expect(logger.infoMessages).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([ + `claude: cache for '${HOST_NAME}' left in place, ${location} still names it: ${CANDIDATE}`, + ]); + }); + + it("purges only its tenant once a readable registry no longer names it", async () => { + const neighborPath = join(CACHE_ROOT, "another-marketplace", "plugin.json"); + const fs = new InMemoryFileAdapter({ + [join(CANDIDATE, "plugin.json")]: "removed tenant", + [neighborPath]: "other tenant", + }); + const logger = new CapturingLogger(); + const reader = new FakeHostMarketplaceRegistryReader({ + location: "/host/known_marketplaces.json", + entries: new Map([["another-marketplace", "/other-project/marketplace"]]), + }); + + await purgeNativeMarketplaceCache(fs, logger, reader, CACHE_ROOT, "claude", HOST_NAME, true); + + expect(fs.listAll()).toStrictEqual([neighborPath.replaceAll("\\", "/")]); + expect(fs.getFile(neighborPath)).toBe("other tenant"); + expect(reader.reads).toBe(1); + expect(logger.warnMessages).toStrictEqual([]); + expect(logger.infoMessages).toStrictEqual([ + `claude: cache for '${HOST_NAME}' purged: ${CANDIDATE}`, + ]); + }); + + it("keeps its cache and reports the unreadable registry instead of treating it as empty", async () => { + const path = join(CANDIDATE, "plugin.json"); + const fs = new InMemoryFileAdapter({ [path]: "unproven ownership" }); + const logger = new CapturingLogger(); + const location = "/host/known_marketplaces.json"; + const reader = new FakeHostMarketplaceRegistryReader({ location, unreadable: "EACCES" }); + + await purgeNativeMarketplaceCache(fs, logger, reader, CACHE_ROOT, "claude", HOST_NAME, true); + + expect(fs.getFile(path)).toBe("unproven ownership"); + expect(reader.reads).toBe(1); + expect(logger.infoMessages).toStrictEqual([]); + expect(logger.warnMessages).toStrictEqual([ + `claude: plugin cache left in place, its registry could not be read: ${location}`, + ]); + }); + + it("keeps a machine-global catalogue's bytes when project clean did not unregister it, even if a registry reader says absent", async () => { + const path = join(CANDIDATE, "plugin.json"); + const fs = new InMemoryFileAdapter({ [path]: "B still needs these bytes" }); + const logger = new CapturingLogger(); + const reader = new FakeHostMarketplaceRegistryReader({ + location: "/host/registry", + absent: true, + }); + await purgeNativeMarketplaceCache(fs, logger, reader, CACHE_ROOT, "claude", HOST_NAME, false); + expect(fs.getFile(path)).toBe("B still needs these bytes"); + }); it("names the cache path when its real location escapes the cache root", async () => { const fs = new InMemoryFileAdapter(); fs.setSymlink(CANDIDATE, "/elsewhere"); diff --git a/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts b/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts index 458a093e3..7c73ef9a0 100644 --- a/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/read-marketplace-catalog-identity.unit.test.ts @@ -1,4 +1,5 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; +import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -6,6 +7,10 @@ import { marketplaceCatalogProbePath, readMarketplaceCatalogIdentity, } from "../../../../../src/contexts/framework/application/shared/read-marketplace-catalog-identity.js"; +import { + errnoError, + FaultingFileAdapter, +} from "../../../../helpers/ports/faulting-file-adapter.js"; import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-adapter.js"; const DIR = "/marketplace"; @@ -16,6 +21,44 @@ function fsWithCatalog(catalog: unknown): InMemoryFileAdapter { } describe("readMarketplaceCatalogIdentity", () => { + it("answers no identity when the declared catalog does not exist", async () => { + expect( + await readMarketplaceCatalogIdentity(new InMemoryFileAdapter(), "claude", DIR) + ).toBeUndefined(); + }); + + it("answers no identity when the declared catalog cannot be read", async () => { + const fs = new FaultingFileAdapter({ + [CATALOG_PATH]: JSON.stringify({ name: "aidd-framework" }), + }); + fs.failOn("readFile", CATALOG_PATH, errnoError("EACCES")); + + expect(await readMarketplaceCatalogIdentity(fs, "claude", DIR)).toBeUndefined(); + }); + + it.each(["not JSON", "null", '{"name":'])( + "answers no identity for an unreadable catalog document: %s", + async (content) => { + const fs = new InMemoryFileAdapter({ [CATALOG_PATH]: content }); + + expect(await readMarketplaceCatalogIdentity(fs, "claude", DIR)).toBeUndefined(); + } + ); + + it("reads OpenCode's declared catalog path even when a Claude catalog is also present", async () => { + const fs = fsWithCatalog({ name: "other-catalog", plugins: [{ name: "other-plugin" }] }); + fs.setFile( + join(DIR, "opencode.json"), + JSON.stringify({ name: "opencode-catalog", plugins: [{ name: "opencode-plugin" }] }) + ); + + expect(marketplaceCatalogProbePath("opencode", DIR)).toBe(join(DIR, "opencode.json")); + expect(await readMarketplaceCatalogIdentity(fs, "opencode", DIR)).toStrictEqual({ + name: "opencode-catalog", + pluginNames: ["opencode-plugin"], + }); + }); + it("reads the name and plugin names the tool's own catalog file declares", async () => { const fs = fsWithCatalog({ name: "aidd-framework", plugins: [{ name: "a" }, { name: "b" }] }); diff --git a/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts b/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts index b19a2d229..49cd25d41 100644 --- a/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/remove-project-hooks.unit.test.ts @@ -1,8 +1,14 @@ import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; +import { createHash } from "node:crypto"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { removeProjectHooks } from "../../../../../src/contexts/framework/application/shared/remove-project-hooks.js"; +import { describe, expect, it, vi } from "vitest"; +import { + assertProjectHooksRemovable, + assertProjectHooksUnchanged, + removeProjectHooks, +} from "../../../../../src/contexts/framework/application/shared/remove-project-hooks.js"; +import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { errnoError, FaultingFileAdapter, @@ -23,11 +29,247 @@ function hooksFile(...plugins: string[]): string { return JSON.stringify({ version: 1, hooks: { preToolUse: plugins.map(entry) } }); } +function plugin(options: { entry?: boolean; script?: string } = {}): InstalledPlugin { + const command = entry(PLUGIN).command; + return InstalledPlugin.fromJSON({ + name: PLUGIN, + source: { kind: "local", path: "/src" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + projectHooks: { + entries: options.entry + ? [ + { + event: "preToolUse", + command, + digest: createHash("md5") + .update(JSON.stringify(entry(PLUGIN))) + .digest("hex"), + }, + ] + : [], + scripts: + options.script === undefined + ? {} + : { + [`.cursor/hooks/${PLUGIN}/pre.js`]: createHash("md5") + .update(options.script) + .digest("hex"), + }, + }, + }); +} + describe("removeProjectHooks", () => { + it.each(["entries", "scripts"] as const)( + "refuses a legacy install with only %s present before changing any files", + async (present) => { + const content = hooksFile(PLUGIN); + const fs = new InMemoryFileAdapter( + present === "entries" ? { [HOOKS_PATH]: content } : { [SCRIPT_PATH]: "legacy" } + ); + const legacy = InstalledPlugin.fromJSON({ ...plugin().toJSON(), projectHooks: undefined }); + + await expect(removeProjectHooks(fs, legacy, "cursor", PROJECT_ROOT)).rejects.toThrow( + `Cursor project hooks for '${PLUGIN}' have an unproven legacy install digest; detach refused.` + ); + + expect(fs.getFile(HOOKS_PATH)).toBe(present === "entries" ? content : undefined); + expect(fs.getFile(SCRIPT_PATH)).toBe(present === "scripts" ? "legacy" : undefined); + } + ); + + it.each(["not-a-digest", `x${"a".repeat(32)}`, `${"a".repeat(32)}x`])( + "refuses an unproven script digest %s before unmerging verified entries", + async (digest) => { + const script = "installed script"; + const content = hooksFile(PLUGIN, OTHER); + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content, [SCRIPT_PATH]: script }); + const installed = plugin({ entry: true }).toJSON(); + const invalid = InstalledPlugin.fromJSON({ + ...installed, + projectHooks: { + entries: installed.projectHooks?.entries ?? [], + scripts: { [`.cursor/hooks/${PLUGIN}/pre.js`]: digest }, + }, + }); + + await expect(removeProjectHooks(fs, invalid, "cursor", PROJECT_ROOT)).rejects.toThrow( + `Cursor hook script '.cursor/hooks/${PLUGIN}/pre.js' has an unproven install digest; detach refused.` + ); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + expect(fs.getFile(SCRIPT_PATH)).toBe(script); + } + ); + + it.each([ + ".cursor/hooks/aidd-dev/pre.js", + "/foreign/pre.js", + ".cursor/hooks/aidd-context/../aidd-dev/pre.js", + ".cursor/hooks/aidd-context/./pre.js", + ".cursor/hooks/aidd-context/nested\\pre.js", + ])("refuses an unproven recorded script path %s before unmerging hooks", async (path) => { + const content = hooksFile(PLUGIN, OTHER); + const script = "installed script"; + const fs = new InMemoryFileAdapter({ + [HOOKS_PATH]: content, + [join(PROJECT_ROOT, path)]: script, + }); + const installed = plugin({ entry: true }).toJSON(); + const invalid = InstalledPlugin.fromJSON({ + ...installed, + projectHooks: { + entries: installed.projectHooks?.entries ?? [], + scripts: { [path]: createHash("md5").update(script).digest("hex") }, + }, + }); + + await expect(removeProjectHooks(fs, invalid, "cursor", PROJECT_ROOT)).rejects.toThrow( + `Cursor hook script path '${path}' has unproven provenance; detach refused.` + ); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + expect(fs.getFile(join(PROJECT_ROOT, path))).toBe(script); + }); + + it.each([ + '{ "version": 1, "hooks": {} }', + '{ "version": 1 }', + JSON.stringify({ hooks: { preToolUse: [{ command: 42 }, entry(OTHER)] } }), + ])("preserves a hooks file with no contribution byte-for-byte: %s", async (content) => { + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content }); + + expect(await removeProjectHooks(fs, plugin(), "cursor", PROJECT_ROOT)).toBe(false); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + }); + + it("preflights reordered recorded entries without changing hooks or scripts", async () => { + const first = { ...entry(PLUGIN), timeout: 25 }; + const second = { command: `node ./.cursor/hooks/${PLUGIN}/post.js` }; + const content = JSON.stringify({ + hooks: { postToolUse: [second], preToolUse: [entry(OTHER), first] }, + }); + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content, [SCRIPT_PATH]: "script" }); + const installed = InstalledPlugin.fromJSON({ + ...plugin().toJSON(), + projectHooks: { + entries: [ + { + event: "preToolUse", + command: first.command, + digest: createHash("md5").update(JSON.stringify(first)).digest("hex"), + }, + { + event: "postToolUse", + command: second.command, + digest: createHash("md5").update(JSON.stringify(second)).digest("hex"), + }, + ], + scripts: {}, + }, + }); + + await expect( + assertProjectHooksRemovable(fs, installed, "cursor", PROJECT_ROOT) + ).resolves.toBeUndefined(); + await expect( + assertProjectHooksUnchanged(fs, PLUGIN, installed.projectHooks, "cursor", PROJECT_ROOT) + ).resolves.toStrictEqual({ existing: content }); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + expect(fs.getFile(SCRIPT_PATH)).toBe("script"); + expect(await removeProjectHooks(fs, installed, "cursor", PROJECT_ROOT)).toBe(true); + expect(JSON.parse(fs.getFile(HOOKS_PATH) ?? "null").hooks).toStrictEqual({ + preToolUse: [entry(OTHER)], + }); + }); + + it("refuses a removed recorded entry before removing its script", async () => { + const content = hooksFile(OTHER); + const script = "installed script"; + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content, [SCRIPT_PATH]: script }); + + await expect( + assertProjectHooksRemovable(fs, plugin({ entry: true, script }), "cursor", PROJECT_ROOT) + ).rejects.toThrow(/edited after install/); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + expect(fs.getFile(SCRIPT_PATH)).toBe(script); + }); + + it("does not recreate a tracked script already removed by the user", async () => { + const content = hooksFile(OTHER); + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content }); + + expect( + await removeProjectHooks(fs, plugin({ script: "installed script" }), "cursor", PROJECT_ROOT) + ).toBe(false); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + expect(fs.getFile(SCRIPT_PATH)).toBeUndefined(); + }); + + it("allows a legacy install with no project contribution and returns the existing hooks during preflight", async () => { + const content = hooksFile(OTHER); + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content }); + + await expect( + assertProjectHooksUnchanged(fs, PLUGIN, undefined, "cursor", PROJECT_ROOT) + ).resolves.toStrictEqual({ existing: content }); + await expect( + assertProjectHooksUnchanged(fs, PLUGIN, undefined, "opencode", PROJECT_ROOT) + ).resolves.toStrictEqual({ existing: null }); + + expect(fs.getFile(HOOKS_PATH)).toBe(content); + }); + it("refuses a hooks.json symlink outside the project even when its entries match the install digest", async () => { + const content = hooksFile(PLUGIN, OTHER); + const foreignPath = "/foreign/hooks.json"; + const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: content, [foreignPath]: content }); + fs.setSymlink(HOOKS_PATH, foreignPath); + const write = vi.spyOn(fs, "writeFile"); + const remove = vi.spyOn(fs, "deleteFile"); + + await expect( + removeProjectHooks(fs, plugin({ entry: true }), "cursor", PROJECT_ROOT) + ).rejects.toThrow(/outside.*project|escapes.*project/); + + expect(write).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect(fs.getFile(foreignPath)).toBe(content); + }); + + it("refuses a hook script parent symlink outside the project despite matching bytes and digest", async () => { + const script = "module.exports = () => {};"; + const foreignDir = "/foreign/script-dir"; + const foreignPath = join(foreignDir, "pre.js"); + const fs = new InMemoryFileAdapter({ + [SCRIPT_PATH]: script, + [foreignPath]: script, + }); + fs.setSymlink(join(PROJECT_ROOT, ".cursor", "hooks", PLUGIN), foreignDir); + const write = vi.spyOn(fs, "writeFile"); + const remove = vi.spyOn(fs, "deleteFile"); + + await expect( + removeProjectHooks(fs, plugin({ script }), "cursor", PROJECT_ROOT) + ).rejects.toThrow(/outside.*project|escapes.*project/); + + expect(write).not.toHaveBeenCalled(); + expect(remove).not.toHaveBeenCalled(); + expect(fs.getFile(foreignPath)).toBe(script); + }); + it("leaves a tool that keeps hooks in its plugin directory untouched and reports nothing undone", async () => { const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: hooksFile(PLUGIN) }); - expect(await removeProjectHooks(fs, PLUGIN, "opencode", PROJECT_ROOT)).toBe(false); + expect(await removeProjectHooks(fs, plugin({ entry: true }), "opencode", PROJECT_ROOT)).toBe( + false + ); expect(fs.getFile(HOOKS_PATH)).toBe(hooksFile(PLUGIN)); }); @@ -35,25 +277,69 @@ describe("removeProjectHooks", () => { const fs = new FaultingFileAdapter(); fs.failOn("deleteDirectory", `${PROJECT_ROOT}/.cursor/hooks/${PLUGIN}/`, errnoError("EPERM")); - expect(await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT)).toBe(false); + expect(await removeProjectHooks(fs, plugin(), "cursor", PROJECT_ROOT)).toBe(false); }); - it("unmerges only this plugin's entries and reports something undone when just the hooks file exists", async () => { - const fs = new InMemoryFileAdapter({ [HOOKS_PATH]: hooksFile(PLUGIN, OTHER) }); + it("removes only recorded entries and scripts while preserving untracked files in the same plugin directory", async () => { + const script = "module.exports = () => {};"; + const notePath = join(PROJECT_ROOT, ".cursor", "hooks", PLUGIN, "user-note.md"); + const otherScript = join(PROJECT_ROOT, ".cursor", "hooks", OTHER, "pre.js"); + const fs = new InMemoryFileAdapter({ + [HOOKS_PATH]: hooksFile(PLUGIN, OTHER), + [SCRIPT_PATH]: script, + [notePath]: "user note", + [otherScript]: "other plugin script", + }); - const undone = await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT); + const undone = await removeProjectHooks( + fs, + plugin({ entry: true, script }), + "cursor", + PROJECT_ROOT + ); expect(undone).toBe(true); expect(JSON.parse(fs.getFile(HOOKS_PATH) ?? "null")).toStrictEqual({ version: 1, hooks: { preToolUse: [entry(OTHER)] }, }); + expect(fs.getFile(SCRIPT_PATH)).toBeUndefined(); + expect(fs.getFile(notePath)).toBe("user note"); + expect(fs.getFile(otherScript)).toBe("other plugin script"); }); + it.each(["metadata", "event"] as const)( + "refuses changed hook %s despite an unchanged command, before deleting any tracked script", + async (change) => { + const script = "module.exports = () => {};"; + const current = JSON.stringify({ + version: 1, + hooks: { + [change === "event" ? "postToolUse" : "preToolUse"]: [ + change === "metadata" ? { ...entry(PLUGIN), timeout: 99 } : entry(PLUGIN), + entry(OTHER), + ], + }, + }); + const fs = new InMemoryFileAdapter({ + [HOOKS_PATH]: current, + [SCRIPT_PATH]: script, + }); + + await expect( + removeProjectHooks(fs, plugin({ entry: true, script }), "cursor", PROJECT_ROOT) + ).rejects.toThrow(/edited after install/); + + expect(fs.getFile(HOOKS_PATH)).toBe(current); + expect(fs.getFile(SCRIPT_PATH)).toBe(script); + } + ); + it("removes the script directory and reports something undone when only the scripts exist", async () => { - const fs = new InMemoryFileAdapter({ [SCRIPT_PATH]: "module.exports = () => {};" }); + const script = "module.exports = () => {};"; + const fs = new InMemoryFileAdapter({ [SCRIPT_PATH]: script }); - const undone = await removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT); + const undone = await removeProjectHooks(fs, plugin({ script }), "cursor", PROJECT_ROOT); expect(undone).toBe(true); expect(fs.listAll()).toStrictEqual([]); @@ -63,8 +349,28 @@ describe("removeProjectHooks", () => { const fs = new FaultingFileAdapter(); fs.failOn("readFile", HOOKS_PATH, errnoError("EACCES")); - await expect(removeProjectHooks(fs, PLUGIN, "cursor", PROJECT_ROOT)).rejects.toThrow( + await expect(removeProjectHooks(fs, plugin(), "cursor", PROJECT_ROOT)).rejects.toThrow( "EACCES: planted by the test" ); }); + + it("refuses a legacy hook with no install digest instead of deleting by plugin name", async () => { + const fs = new InMemoryFileAdapter({ + [HOOKS_PATH]: hooksFile(PLUGIN), + [SCRIPT_PATH]: "legacy", + }); + const legacy = InstalledPlugin.fromJSON({ + name: PLUGIN, + source: { kind: "local", path: "/src" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + }); + await expect(removeProjectHooks(fs, legacy, "cursor", PROJECT_ROOT)).rejects.toThrow( + /unproven/ + ); + expect(fs.getFile(HOOKS_PATH)).toBe(hooksFile(PLUGIN)); + expect(fs.getFile(SCRIPT_PATH)).toBe("legacy"); + }); }); diff --git a/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts b/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts index b032a1c86..238918d43 100644 --- a/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts +++ b/cli/tests/contexts/framework/application/shared/user-scope-plugin-files.unit.test.ts @@ -1,9 +1,14 @@ import "../../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import { createHash } from "node:crypto"; import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { userScopeFilesSafeToDelete } from "../../../../../src/contexts/framework/application/shared/user-scope-plugin-files.js"; +import { describe, expect, it, vi } from "vitest"; +import { + assertUserScopeWriteBoundary, + userScopeFilesSafeToDelete, +} from "../../../../../src/contexts/framework/application/shared/user-scope-plugin-files.js"; import { InstalledPlugin } from "../../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { FileHash, InstallationFile } from "../../../../../src/kernel/file.js"; import { CapturingLogger } from "../../../../helpers/ports/capturing-logger.js"; import { errnoError, @@ -13,7 +18,8 @@ import { InMemoryFileAdapter } from "../../../../helpers/ports/in-memory-file-ad const HOME = "/home/u"; const BOUNDARY = join(HOME, ".cursor", "plugins", "local"); -const HASH = "abc123abc123abc123abc123abc123ab"; +const CONTENT = "owned bytes"; +const HASH = createHash("md5").update(CONTENT).digest("hex"); function plugin(files: Record): InstalledPlugin { return InstalledPlugin.fromJSON({ @@ -80,6 +86,7 @@ describe("userScopeFilesSafeToDelete", () => { const fs = new FaultingFileAdapter(); fs.failOn("realpath", join(BOUNDARY, "aidd-test", "gone.md"), errnoError("ENOENT")); const logger = new CapturingLogger(); + fs.setFile(join(BOUNDARY, "aidd-test", "a.md"), CONTENT); const safe = await userScopeFilesSafeToDelete( fs, @@ -109,4 +116,188 @@ describe("userScopeFilesSafeToDelete", () => { ) ).rejects.toThrow("EACCES: planted by the test"); }); + + it("refuses a user-edited file even though its path remains within the scope", async () => { + const path = join(BOUNDARY, "aidd-test", "plugin.json"); + const fs = new InMemoryFileAdapter({ [path]: "user-edited bytes" }); + await expect( + userScopeFilesSafeToDelete( + fs, + new CapturingLogger(), + plugin({ "aidd-test/plugin.json": HASH }), + "cursor", + HOME + ) + ).rejects.toThrow(/edited.*plugin.json|plugin.json.*edited/); + expect(fs.getFile(path)).toBe("user-edited bytes"); + }); + + it("refuses an unproven legacy digest and a failed content read", async () => { + const path = join(BOUNDARY, "aidd-test", "script.js"); + const fs = new FaultingFileAdapter(); + fs.setFile(path, CONTENT); + await expect( + userScopeFilesSafeToDelete( + fs, + new CapturingLogger(), + plugin({ "aidd-test/script.js": "" }), + "cursor", + HOME + ) + ).rejects.toThrow(/unproven.*script.js|script.js.*unproven/); + fs.failOn("readFile", path, errnoError("EACCES")); + await expect( + userScopeFilesSafeToDelete( + fs, + new CapturingLogger(), + plugin({ "aidd-test/script.js": HASH }), + "cursor", + HOME + ) + ).rejects.toThrow(/EACCES/); + }); +}); + +describe("assertUserScopeWriteBoundary", () => { + const file = (relativePath: string) => + new InstallationFile({ + relativePath, + content: "new bytes", + hash: new FileHash("a".repeat(32)), + }); + + it("refuses a tool without a declared user plugins directory", async () => { + await expect( + assertUserScopeWriteBoundary( + new InMemoryFileAdapter(), + "claude", + "aidd-test", + [file("aidd-test/a.md")], + HOME + ) + ).rejects.toThrow(/no user plugins directory/); + }); + + it.each([BOUNDARY, join(BOUNDARY, "aidd-test")])( + "propagates an unreadable write boundary at %s", + async (path) => { + const fs = new FaultingFileAdapter(); + const error = errnoError("EACCES"); + fs.failOn("realpath", path, error); + + await expect( + assertUserScopeWriteBoundary(fs, "cursor", "aidd-test", [file("aidd-test/a.md")], HOME) + ).rejects.toBe(error); + } + ); + + it("allows missing nested parents only after resolving an existing safe ancestor", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", join(BOUNDARY, "aidd-test/skills/new"), errnoError("ENOENT")); + fs.failOn("realpath", join(BOUNDARY, "aidd-test/skills"), errnoError("ENOENT")); + + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/new/SKILL.md")], + HOME + ) + ).resolves.toBeUndefined(); + }); + + it("refuses a plugin directory that disappears after the initial boundary check", async () => { + const fs = new InMemoryFileAdapter(); + const pluginDir = join(BOUNDARY, "aidd-test"); + let pluginReads = 0; + vi.spyOn(fs, "realpath").mockImplementation(async (path) => { + if (path === BOUNDARY) return path; + if (path === pluginDir && ++pluginReads === 1) return path; + throw errnoError("ENOENT"); + }); + + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/new/SKILL.md")], + HOME + ) + ).rejects.toThrow(/directory disappeared during update/); + }); + + it("allows a new file only within the owned plugin directory", async () => { + const fs = new InMemoryFileAdapter(); + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/demo/SKILL.md")], + HOME + ) + ).resolves.toBeUndefined(); + }); + + it.each([ + "../escape.md", + "aidd-test/../escape.md", + "other-plugin/SKILL.md", + "aidd-test\\skills\\SKILL.md", + "/absolute/escape.md", + ])("refuses the untrusted machine update path %s", async (relativePath) => { + await expect( + assertUserScopeWriteBoundary( + new InMemoryFileAdapter(), + "cursor", + "aidd-test", + [file(relativePath)], + HOME + ) + ).rejects.toThrow(/escapes its plugin directory/); + }); + + it("refuses a plugin directory symlink that resolves outside its user-scope base", async () => { + const fs = new InMemoryFileAdapter(); + fs.setSymlink(join(BOUNDARY, "aidd-test"), "/foreign/aidd-test"); + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/demo/SKILL.md")], + HOME + ) + ).rejects.toThrow(/directory is not safely inside/); + }); + + it("refuses a nested parent symlink before writing new bytes", async () => { + const fs = new InMemoryFileAdapter(); + fs.setSymlink(join(BOUNDARY, "aidd-test/skills"), "/foreign/skills"); + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/demo/SKILL.md")], + HOME + ) + ).rejects.toThrow(/update parent.*escapes/); + }); + + it("refuses an absent plugin directory rather than inventing a safe write base", async () => { + const fs = new FaultingFileAdapter(); + fs.failOn("realpath", join(BOUNDARY, "aidd-test"), errnoError("ENOENT")); + await expect( + assertUserScopeWriteBoundary( + fs, + "cursor", + "aidd-test", + [file("aidd-test/skills/demo/SKILL.md")], + HOME + ) + ).rejects.toThrow(/directory is not safely inside/); + }); }); diff --git a/cli/tests/contexts/framework/application/uninstall-plugin-native-claims.integration.test.ts b/cli/tests/contexts/framework/application/uninstall-plugin-native-claims.integration.test.ts new file mode 100644 index 000000000..2cf9154ea --- /dev/null +++ b/cli/tests/contexts/framework/application/uninstall-plugin-native-claims.integration.test.ts @@ -0,0 +1,304 @@ +import { createHash } from "node:crypto"; +import { homedir } from "node:os"; +import { join } from "node:path"; +import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import { describe, expect, it } from "vitest"; +import { UninstallPluginUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-plugin-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; +import { errnoError, FaultingFileAdapter } from "../../../helpers/ports/faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; +import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; + +const REF = "sample-plugin@real-catalog"; +const OTHER = "other-plugin@other-catalog"; + +function fixture(scope: "project" | "user" = "project") { + const project = Manifest.create(); + project.addTool("codex", "1.0.0", []); + project.addPlugin( + "codex", + InstalledPlugin.fromJSON({ + name: "sample-plugin", + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: scope === "user" ? { "sample-plugin/skills/demo.md": "abc" } : {}, + scope, + marketplace: "project-alias", + }) + ); + project.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [ + { alias: "project-alias", hostName: "real-catalog" }, + { alias: "other-alias", hostName: "other-catalog" }, + ], + pluginRefs: [REF, OTHER], + }); + const machine = Manifest.create(); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + binary: "codex", + marketplaces: [{ alias: "project-alias", hostName: "real-catalog" }], + pluginRefs: [REF], + pluginClaims: [{ ref: REF, dependents: ["/A", "/B"] }], + }); + return { + projectRepo: new InMemoryManifestRepository(project), + machineRepo: new InMemoryManifestRepository(machine), + fs: new InMemoryFileAdapter(), + }; +} + +describe("project plugin uninstall against shared native host ownership", () => { + it("retains A's Cursor projection and shared claim when local hooks are unreadable", async () => { + const pluginName = "sample-plugin"; + const hooksPath = join("/A", ".cursor/hooks.json"); + const hooks = JSON.stringify({ + version: 1, + hooks: { preToolUse: [{ command: `node ./.cursor/hooks/${pluginName}/pre.js` }] }, + }); + const fs = new FaultingFileAdapter(); + fs.setFile(hooksPath, hooks); + fs.setFile(join("/A", `.cursor/hooks/${pluginName}/pre.js`), "A's hook"); + fs.setFile(join("/B", ".cursor/hooks.json"), hooks); + const globalMcp = join(homedir(), `.cursor/plugins/local/${pluginName}/mcp.json`); + fs.setFile(globalMcp, "B's MCP"); + fs.failOn("readFile", hooksPath, errnoError("EACCES")); + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + }) + ); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + dependents: ["/A", "/B"], + }) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(machine); + const useCase = new UninstallPluginUseCase(fs, projectRepo, machineRepo); + + await expect( + useCase.execute({ pluginName, toolIds: ["cursor"], projectRoot: "/A" }) + ).rejects.toThrow(/EACCES/); + + expect(projectRepo.saveCount).toBe(0); + expect(projectRepo.getCurrent()?.getPlugins("cursor")).toHaveLength(1); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/A", "/B"]); + expect(fs.getFile(hooksPath)).toBe(hooks); + expect(fs.getFile(globalMcp)).toBe("B's MCP"); + }); + + it("removes only A's Cursor project hooks before detaching A, preserving B and the global MCP", async () => { + const pluginName = "sample-plugin"; + const hooks = JSON.stringify({ + version: 1, + hooks: { preToolUse: [{ command: `node ./.cursor/hooks/${pluginName}/pre.js` }] }, + }); + const aHooks = join("/A", ".cursor/hooks.json"); + const bHooks = join("/B", ".cursor/hooks.json"); + const aScript = join("/A", `.cursor/hooks/${pluginName}/pre.js`); + const bScript = join("/B", `.cursor/hooks/${pluginName}/pre.js`); + const globalMcp = join(homedir(), `.cursor/plugins/local/${pluginName}/mcp.json`); + const fs = new InMemoryFileAdapter({ + [aHooks]: hooks, + [bHooks]: hooks, + [aScript]: "A's hook", + [bScript]: "B's hook", + [globalMcp]: "B's MCP", + }); + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + marketplace: "project-alias", + projectHooks: { + entries: [ + { + event: "preToolUse", + command: `node ./.cursor/hooks/${pluginName}/pre.js`, + digest: createHash("md5") + .update(JSON.stringify({ command: `node ./.cursor/hooks/${pluginName}/pre.js` })) + .digest("hex"), + }, + ], + scripts: { + [`.cursor/hooks/${pluginName}/pre.js`]: createHash("md5") + .update("A's hook") + .digest("hex"), + }, + }, + }) + ); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromJSON({ + name: pluginName, + source: { kind: "local", path: "/fixture" }, + version: "1.0.0", + strict: false, + files: {}, + scope: "user", + dependents: ["/A", "/B"], + }) + ); + const projectRepo = new InMemoryManifestRepository(project); + const machineRepo = new InMemoryManifestRepository(machine); + const useCase = new UninstallPluginUseCase(fs, projectRepo, machineRepo); + + await useCase.execute({ pluginName, toolIds: ["cursor"], projectRoot: "/A" }); + + expect(fs.getFile(aHooks)).toBeUndefined(); + expect(fs.getFile(aScript)).toBeUndefined(); + expect(fs.getFile(bHooks)).toBe(hooks); + expect(fs.getFile(bScript)).toBe("B's hook"); + expect(fs.getFile(globalMcp)).toBe("B's MCP"); + expect(projectRepo.getCurrent()?.getPlugins("cursor")).toEqual([]); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/B"]); + }); + + it("does not detach a same-name Cursor user claim for a project-scope plugin", async () => { + const project = Manifest.create(); + project.addTool("cursor", "1.0.0", []); + project.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "sample-plugin", + "1.0.0", + { kind: "local", path: "/fixture" }, + false, + "project" + ) + ); + project.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "other-plugin", + "1.0.0", + { kind: "local", path: "/fixture" }, + false, + "user" + ) + ); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + machine.addPlugin( + "cursor", + InstalledPlugin.fromMetadata( + "sample-plugin", + "1.0.0", + { kind: "local", path: "/unrelated" }, + false, + "user" + ).withDependents(["/A", "/B"]) + ); + const machineRepo = new InMemoryManifestRepository(machine); + const useCase = new UninstallPluginUseCase( + new InMemoryFileAdapter(), + new InMemoryManifestRepository(project), + machineRepo + ); + + await useCase.execute({ pluginName: "sample-plugin", toolIds: ["cursor"], projectRoot: "/A" }); + expect(machineRepo.getCurrent()?.getPlugins("cursor")[0]?.dependents).toEqual(["/A", "/B"]); + expect(machineRepo.saveCount).toBe(0); + }); + + it("drops A's exact projection and claim, leaving B and another catalogue intact", async () => { + const f = fixture(); + const useCase = new UninstallPluginUseCase(f.fs, f.projectRepo, f.machineRepo); + expect( + await useCase.execute({ pluginName: "sample-plugin", toolIds: ["codex"], projectRoot: "/A" }) + ).toEqual([{ toolId: "codex", fileCount: 0, deletedFiles: [] }]); + expect(f.projectRepo.getCurrent()?.getPlugins("codex")).toEqual([]); + expect(f.projectRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([ + OTHER, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/B"] }, + ]); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginRefs).toEqual([REF]); + }); + + it("uninstalls a project projection but leaves machine-owned user plugin bytes for B", async () => { + const f = fixture("user"); + const userPath = join("/user-home/.codex/plugins/sample-plugin/skills/demo.md"); + f.fs.setFile(userPath, "B's machine bytes"); + const useCase = new UninstallPluginUseCase(f.fs, f.projectRepo, f.machineRepo); + expect( + await useCase.execute({ pluginName: "sample-plugin", toolIds: [], projectRoot: "/A" }) + ).toEqual([{ toolId: "codex", fileCount: 0, deletedFiles: [] }]); + expect(f.fs.getFile(userPath)).toBe("B's machine bytes"); + expect( + f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims?.[0]?.dependents + ).toEqual(["/B"]); + }); + + it("never detaches a shared claim when local file deletion fails", async () => { + class RefusingFileAdapter extends InMemoryFileAdapter { + override async deleteFile(): Promise { + throw new Error("local delete failed"); + } + } + const f = fixture(); + const plugin = f.projectRepo.getCurrent()?.getPlugins("codex")[0]; + const project = f.projectRepo.getCurrent(); + if (project === null || plugin === undefined) throw new Error("fixture missing plugin"); + project.updatePlugin("codex", plugin.withFiles(new Map([[".codex/skills/demo.md", "abc"]]))); + const useCase = new UninstallPluginUseCase( + new RefusingFileAdapter(), + f.projectRepo, + f.machineRepo + ); + await expect( + useCase.execute({ pluginName: "sample-plugin", toolIds: ["codex"], projectRoot: "/A" }) + ).rejects.toThrow(/local delete failed/); + expect( + f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims?.[0]?.dependents + ).toEqual(["/A", "/B"]); + expect(f.machineRepo.saveCount).toBe(0); + }); + + it("does not detach any machine claim for a missing project plugin", async () => { + const f = fixture(); + await expect( + new UninstallPluginUseCase(f.fs, f.projectRepo, f.machineRepo).execute({ + pluginName: "foreign-plugin", + toolIds: ["codex"], + projectRoot: "/A", + }) + ).rejects.toThrow(/not installed/i); + expect(f.machineRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims).toEqual([ + { ref: REF, dependents: ["/A", "/B"] }, + ]); + }); +}); diff --git a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts index dad3f2454..4412ee454 100644 --- a/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-plugin.unit.test.ts @@ -1,7 +1,9 @@ +import { createHash } from "node:crypto"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; import "../../../../src/contexts/tools/domain/profiles/claude/profile.js"; import "../../../../src/contexts/tools/domain/profiles/codex/profile.js"; +import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import { PluginAddUseCase } from "../../../../src/contexts/framework/application/plugin/plugin-add-use-case.js"; import { UninstallPluginUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-plugin-use-case.js"; import { UninstallUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-use-case.js"; @@ -11,6 +13,7 @@ import { PluginDistributionReaderAdapter } from "../../../../src/contexts/framew import { NoManifestError, PluginNotFoundError } from "../../../../src/kernel/errors.js"; import { buildUnitDeps, initAndInstall } from "../../../helpers/ports/build-unit-deps.js"; import { fakeEnsureBuiltMarketplace } from "../../../helpers/ports/fake-ensure-built-marketplace.js"; +import { errnoError, FaultingFileAdapter } from "../../../helpers/ports/faulting-file-adapter.js"; import { InMemoryFileAdapter } from "../../../helpers/ports/in-memory-file-adapter.js"; import { InMemoryManifestRepository } from "../../../helpers/ports/in-memory-manifest-repository.js"; @@ -34,7 +37,8 @@ describe("UninstallUseCase — plugin scope", () => { deps.hasher, deps.logger, deps.marketplaceRegistry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ).execute({ source: { kind: "local", path: PLUGIN_FIXTURE }, toolIds: ["claude"], @@ -71,6 +75,147 @@ describe("UninstallUseCase — plugin scope", () => { }); describe("UninstallPluginUseCase — which plugin, on which tools", () => { + it.each([ + { name: "missing", error: null }, + { name: "unreadable", error: errnoError("EACCES") }, + ])("handles an $name OpenCode MCP config without losing the project record", async (scenario) => { + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: createHash("md5") + .update(JSON.stringify({ type: "local" })) + .digest("hex"), + }, + scope: "project", + }) + ); + const fs = new FaultingFileAdapter(); + if (scenario.error !== null) + fs.failOn("readFile", join(PROJECT_ROOT, "opencode.json"), scenario.error); + const repo = new InMemoryManifestRepository(manifest); + const operation = new UninstallPluginUseCase(fs, repo).execute({ + pluginName: "sample", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }); + + if (scenario.error === null) { + await expect(operation).resolves.toEqual([ + { toolId: "opencode", fileCount: 0, deletedFiles: [] }, + ]); + expect(repo.getCurrent()?.getPlugins("opencode")).toEqual([]); + } else { + await expect(operation).rejects.toThrow(/EACCES/); + expect(repo.getCurrent()?.getPlugins("opencode")).toHaveLength(1); + expect(repo.saveCount).toBe(0); + } + }); + + it("retains the OpenCode plugin projection when its local MCP config cannot be rewritten", async () => { + const mcpPath = join(PROJECT_ROOT, "opencode.json"); + const content = JSON.stringify({ mcp: { mine: { type: "local" } } }); + class RefusingMcpWriteAdapter extends InMemoryFileAdapter { + override async writeFile(path: string, value: string): Promise { + if (path === mcpPath) throw new Error("MCP write refused"); + return super.writeFile(path, value); + } + } + const fs = new RefusingMcpWriteAdapter({ [mcpPath]: content }); + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: createHash("md5") + .update(JSON.stringify({ type: "local" })) + .digest("hex"), + }, + scope: "project", + }) + ); + const repo = new InMemoryManifestRepository(manifest); + + await expect( + new UninstallPluginUseCase(fs, repo).execute({ + pluginName: "sample", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/MCP write refused/); + + expect(repo.saveCount).toBe(0); + expect(repo.getCurrent()?.getPlugins("opencode")).toHaveLength(1); + expect(fs.getFile(mcpPath)).toBe(content); + }); + + it("unmerges only the installed plugin's OpenCode MCP contribution", async () => { + const manifest = Manifest.create(); + manifest.addTool("opencode", "1.0.0", []); + manifest.addPlugin( + "opencode", + InstalledPlugin.fromJSON({ + name: "sample", + source: { kind: "local", path: "/some/path" }, + version: "1.0.0", + strict: false, + files: {}, + mcpEntries: { + mine: createHash("md5") + .update(JSON.stringify({ type: "local" })) + .digest("hex"), + }, + scope: "project", + }) + ); + const mcpPath = join(PROJECT_ROOT, "opencode.json"); + const fs = new InMemoryFileAdapter({ + [mcpPath]: JSON.stringify({ mcp: { mine: { type: "local" }, theirs: { type: "remote" } } }), + }); + const repo = new InMemoryManifestRepository(manifest); + + fs.setFile( + mcpPath, + JSON.stringify({ mcp: { mine: { type: "user-edited" }, theirs: { type: "remote" } } }) + ); + await expect( + new UninstallPluginUseCase(fs, repo).execute({ + pluginName: "sample", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }) + ).rejects.toThrow(/edited/); + expect(repo.getCurrent()?.getPlugins("opencode")).toHaveLength(1); + fs.setFile( + mcpPath, + JSON.stringify({ mcp: { mine: { type: "local" }, theirs: { type: "remote" } } }) + ); + + await new UninstallPluginUseCase(fs, repo).execute({ + pluginName: "sample", + toolIds: ["opencode"], + projectRoot: PROJECT_ROOT, + }); + + expect(JSON.parse(fs.getFile(mcpPath) ?? "null")).toEqual({ + mcp: { theirs: { type: "remote" } }, + }); + expect((await repo.load())?.getPlugins("opencode")).toEqual([]); + }); + const HASH = "abc123abc123abc123abc123abc123ab"; function pluginNamed(name: string): InstalledPlugin { diff --git a/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts index d8340a1a3..8786cc45e 100644 --- a/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-tools-use-case.unit.test.ts @@ -32,7 +32,7 @@ class RecordingFileAdapter extends InMemoryFileAdapter { const PLUGIN_KEY = "aidd-context/commands/hello.md"; describe("UninstallToolsUseCase — cursor plugin file (user-scope)", () => { - it("deletes the plugin's file from its resolved home directory, not projectRoot", async () => { + it("does not delete a machine-owned user-scope plugin while uninstalling a project tool", async () => { const manifest = Manifest.create(); manifest.addTool("cursor", "1.0.0", []); manifest.addPlugin( @@ -53,7 +53,7 @@ describe("UninstallToolsUseCase — cursor plugin file (user-scope)", () => { expect( fs.deletedPaths.some((p) => p.endsWith(join(".cursor", "plugins", "local", PLUGIN_KEY))) - ).toBe(true); + ).toBe(false); expect(fs.deletedPaths).not.toContain(join(PROJECT_ROOT, PLUGIN_KEY)); }); }); @@ -100,6 +100,35 @@ function remove( } describe("UninstallToolsUseCase — regular files", () => { + it("removes all project-scoped plugin files while preserving unrelated plugin content", async () => { + const first = ".claude/plugins/project-plugin/skills/demo/SKILL.md"; + const second = ".claude/plugins/project-plugin/commands/demo.md"; + const neighbor = ".codex/plugins/other-plugin/skills/demo/SKILL.md"; + const target = project({ [first]: "skill", [second]: "command", [neighbor]: "other plugin" }); + target.manifest.addTool("claude", "test", []); + target.manifest.addTool("codex", "test", []); + target.manifest.addPlugin( + "claude", + InstalledPlugin.fromJSON({ + name: "project-plugin", + source: { kind: "local", path: "/plugins/project-plugin" }, + version: "1.0.0", + strict: false, + scope: "project", + files: { [first]: hasher.hash("skill").value, [second]: hasher.hash("command").value }, + }) + ); + + const result = await remove(target, ["claude"]); + + expect(target.fs.getFile(join(PROJECT_ROOT, first))).toBeUndefined(); + expect(target.fs.getFile(join(PROJECT_ROOT, second))).toBeUndefined(); + expect(target.fs.getFile(join(PROJECT_ROOT, neighbor))).toBe("other plugin"); + expect(target.manifest.hasTool("claude")).toBe(false); + expect(target.manifest.hasTool("codex")).toBe(true); + expect(result).toStrictEqual([{ toolId: "claude", fileCount: 0, deletedFiles: [] }]); + }); + it("announces each tool it removes", async () => { const target = project({}); target.manifest.addTool("claude", "test", []); diff --git a/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts b/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts index 7f85cc001..128251167 100644 --- a/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts +++ b/cli/tests/contexts/framework/application/uninstall-use-case.unit.test.ts @@ -7,6 +7,8 @@ import "../../../../src/contexts/tools/domain/profiles/cursor/profile.js"; import "../../../../src/contexts/tools/domain/profiles/opencode/profile.js"; import "../../../../src/contexts/tools/domain/profiles/vscode/profile.js"; import { UninstallUseCase } from "../../../../src/contexts/framework/application/uninstall/uninstall-use-case.js"; +import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; +import { InstalledPlugin } from "../../../../src/contexts/framework/domain/plugins/installed-plugin.js"; import { InputRequiredError, NoManifestError, @@ -18,6 +20,72 @@ import { buildUnitDeps, initProject, installTool } from "../../../helpers/ports/ const PROJECT_ROOT = "/test-project"; describe("uninstall", () => { + it.each(["cursor", "codex"] as const)( + "detaches only the selected %s tool's shared claims after local removal", + async (selected) => { + const deps = await buildUnitDeps(PROJECT_ROOT); + await initProject(deps, PROJECT_ROOT); + for (const toolId of ["cursor", "codex"] as const) + await installTool(deps, PROJECT_ROOT, toolId); + const project = deps.manifestRepo.getCurrent(); + if (project === null) throw new Error("fixture missing project"); + const record = (name: string, scope: "user" | "project") => + InstalledPlugin.fromMetadata( + name, + "1.0.0", + { kind: "local", path: "/shared" }, + false, + scope + ); + project.addPlugin("cursor", record("shared", "user")); + project.addPlugin("cursor", record("local-only", "project")); + const registration = { + binary: "codex", + marketplaces: [{ alias: "aidd", hostName: "aidd" }], + pluginRefs: ["native@aidd"], + }; + project.setNativeRegistrations("codex", registration); + const machine = Manifest.create(); + machine.addTool("cursor", "1.0.0", []); + for (const name of ["shared", "local-only"]) + machine.addPlugin("cursor", record(name, "user").withDependents([PROJECT_ROOT, "/B"])); + machine.addTool("codex", "1.0.0", []); + machine.setNativeRegistrations("codex", { + ...registration, + pluginClaims: [{ ref: "native@aidd", dependents: [PROJECT_ROOT, "/B"] }], + }); + await deps.userManifestRepo.save(machine); + const retainedTool = selected === "cursor" ? "codex" : "cursor"; + + await new UninstallUseCase( + deps.fs, + deps.manifestRepo, + deps.logger, + deps.userManifestRepo + ).execute({ toolIds: [selected], projectRoot: PROJECT_ROOT }); + + expect(deps.manifestRepo.getCurrent()?.hasTool(selected)).toBe(false); + expect(deps.manifestRepo.getCurrent()?.hasTool(retainedTool)).toBe(true); + expect( + deps.userManifestRepo + .getCurrent() + ?.getPlugins("cursor") + .map((plugin) => ({ + name: plugin.name, + dependents: plugin.dependents, + })) + ).toEqual([ + { name: "shared", dependents: selected === "cursor" ? ["/B"] : [PROJECT_ROOT, "/B"] }, + { name: "local-only", dependents: [PROJECT_ROOT, "/B"] }, + ]); + expect( + deps.userManifestRepo.getCurrent()?.getNativeRegistrations("codex")?.pluginClaims + ).toEqual([ + { ref: "native@aidd", dependents: selected === "codex" ? ["/B"] : [PROJECT_ROOT, "/B"] }, + ]); + } + ); + it("no longer tracks removed tool files", async () => { const deps = await buildUnitDeps(PROJECT_ROOT); await initProject(deps, PROJECT_ROOT); diff --git a/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts b/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts index 3fe84e62a..f291a228b 100644 --- a/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts +++ b/cli/tests/contexts/framework/domain/manifest/native-registrations.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { type NativeRegistrations, + type NativeRegistrationsData, parseNativeRegistrations, toNativeRegistrationsData, } from "../../../../../src/contexts/framework/domain/manifest/native-registrations.js"; @@ -12,6 +13,30 @@ const registrations: NativeRegistrations = { }; describe("native registrations — what a tool's own CLI was asked to register", () => { + it("round-trips the exact effective Codex source while legacy entries remain unproven", () => { + const proven: NativeRegistrations = { + binary: "codex", + marketplaces: [ + { + alias: "local", + hostName: "declared", + provenance: { + kind: "effective-list", + root: "/home/.codex/plugins/marketplaces/declared", + sourceType: "local", + source: "/project-a/.aidd/cache/built", + }, + }, + ], + pluginRefs: [], + }; + + expect(parseNativeRegistrations(toNativeRegistrationsData(proven))).toStrictEqual(proven); + expect(parseNativeRegistrations(toNativeRegistrationsData(registrations))).toStrictEqual( + registrations + ); + }); + describe("serialized", () => { it("carries the binary, every marketplace pair and every plugin ref", () => { expect(toNativeRegistrationsData(registrations)).toStrictEqual({ @@ -23,6 +48,67 @@ describe("native registrations — what a tool's own CLI was asked to register", }); describe("parsed", () => { + it("keeps a registry source without requiring an effective-list root", () => { + const data: NativeRegistrationsData = { + binary: "claude", + marketplaces: [ + { + alias: "local", + hostName: "declared", + provenance: { kind: "registry", source: "/built" }, + }, + ], + pluginRefs: [], + }; + + expect(parseNativeRegistrations(data)).toStrictEqual(data); + }); + + it.each([ + ["null", null], + ["scalar", "registry"], + ["unknown kind", { kind: "unknown", source: "/built" }], + [ + "unknown kind with effective-list fields", + { kind: "unknown", source: "/built", root: "/host", sourceType: "local" }, + ], + ["missing source", { kind: "registry" }], + ["empty source", { kind: "registry", source: "" }], + ["non-string source", { kind: "registry", source: 1 }], + ["missing root", { kind: "effective-list", source: "/built", sourceType: "local" }], + ["empty root", { kind: "effective-list", source: "/built", sourceType: "local", root: "" }], + [ + "non-string root", + { kind: "effective-list", source: "/built", sourceType: "local", root: 1 }, + ], + ["missing source type", { kind: "effective-list", source: "/built", root: "/host" }], + [ + "empty source type", + { kind: "effective-list", source: "/built", root: "/host", sourceType: "" }, + ], + [ + "non-string source type", + { kind: "effective-list", source: "/built", root: "/host", sourceType: 1 }, + ], + ])("leaves %s provenance unproven without dropping the registration", (_name, provenance) => { + // A persisted manifest is JSON: its provenance may not satisfy the declared type. + const data = { + binary: "codex", + marketplaces: [{ alias: "local", hostName: "declared", provenance }], + pluginRefs: ["plugin@declared"], + } as NativeRegistrationsData; + + expect(parseNativeRegistrations(data)).toStrictEqual({ + binary: "codex", + marketplaces: [{ alias: "local", hostName: "declared" }], + pluginRefs: ["plugin@declared"], + }); + }); + + it("keeps an absent registration absent", () => { + expect(parseNativeRegistrations(undefined)).toBeUndefined(); + }); + it("reads back the binary, every marketplace pair and every plugin ref", () => { expect( parseNativeRegistrations({ @@ -37,4 +123,32 @@ describe("native registrations — what a tool's own CLI was asked to register", }); }); }); + + it.each(["serialize", "parse"] as const)( + "%s produces an independent ownership snapshot", + (action) => { + const dependents = ["/project-a", "/project-b"]; + const provenance = { kind: "registry" as const, source: "/built" }; + const marketplace = { alias: "local", hostName: "declared", provenance }; + const pluginClaims = [{ ref: "plugin@declared", dependents }]; + const input: NativeRegistrationsData = { + binary: "claude", + marketplaces: [marketplace], + pluginRefs: ["plugin@declared", "other@declared"], + pluginClaims, + }; + const expected = structuredClone(input); + const snapshot = + action === "serialize" ? toNativeRegistrationsData(input) : parseNativeRegistrations(input); + + provenance.source = "/foreign"; + marketplace.alias = "changed"; + input.marketplaces.push({ alias: "extra", hostName: "extra" }); + input.pluginRefs.push("extra@declared"); + pluginClaims[0] = { ref: "changed@declared", dependents: [] }; + dependents.push("/project-c"); + + expect(snapshot).toStrictEqual(expected); + } + ); }); diff --git a/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts b/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts index f5899af98..2e55eb636 100644 --- a/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts +++ b/cli/tests/contexts/framework/domain/plugins/installed-plugin.unit.test.ts @@ -13,6 +13,26 @@ import { MalformedPluginScopeError, } from "../../../../../src/kernel/errors.js"; +it("round-trips exact Cursor project hook provenance through the plugin manifest", () => { + const data = makePluginData({ + projectHooks: { + entries: [ + { + event: "preToolUse", + command: "node ./.cursor/hooks/my-plugin/pre.js", + digest: "a".repeat(32), + }, + ], + scripts: { ".cursor/hooks/my-plugin/pre.js": "b".repeat(32) }, + }, + }); + const installed = InstalledPlugin.fromJSON(data); + expect(installed.projectHooks?.scripts.get(".cursor/hooks/my-plugin/pre.js")).toBe( + "b".repeat(32) + ); + expect(InstalledPlugin.fromJSON(installed.toJSON()).toJSON()).toEqual(data); +}); + const makeDistribution = (strict?: boolean): PluginDistribution => new PluginDistribution({ manifest: { name: "my-plugin", version: "1.0.0", ...(strict === undefined ? {} : { strict }) }, diff --git a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts index c498d09c4..25e1c1d10 100644 --- a/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts +++ b/cli/tests/contexts/framework/infrastructure/user-manifest-repository-adapter.integration.test.ts @@ -1,10 +1,12 @@ import { existsSync } from "node:fs"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setTimeout as pause } from "node:timers/promises"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Manifest } from "../../../../src/contexts/framework/domain/manifest.js"; import { UserManifestRepositoryAdapter } from "../../../../src/contexts/framework/infrastructure/user-manifest-repository-adapter.js"; +import { atomicWriteFile } from "../../../../src/runtime/filesystem/atomic-write.js"; describe("UserManifestRepositoryAdapter", () => { let userConfigDir: string; @@ -12,7 +14,7 @@ describe("UserManifestRepositoryAdapter", () => { beforeEach(async () => { userConfigDir = await mkdtemp(join(tmpdir(), "aidd-user-manifest-repo-")); - adapter = new UserManifestRepositoryAdapter(() => userConfigDir); + adapter = new UserManifestRepositoryAdapter(() => userConfigDir, atomicWriteFile); }); afterEach(async () => { @@ -76,6 +78,68 @@ describe("UserManifestRepositoryAdapter", () => { }); }); + describe("exclusive machine mutations", () => { + it("fails closed at the bounded deadline without stealing another process's lock", async () => { + const lock = `${adapter.path}.lock`; + await mkdir(lock); + const now = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(30_000); + await expect(adapter.withExclusiveAccess(async () => {})).rejects.toThrow( + /User manifest is busy.*\.lock/ + ); + now.mockRestore(); + expect(existsSync(lock)).toBe(true); + }); + + it("serializes two claims so the second reads the first after the lock releases", async () => { + const second = new UserManifestRepositoryAdapter(() => userConfigDir, atomicWriteFile); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const firstMutation = adapter.withExclusiveAccess(async () => { + const manifest = (await adapter.load()) ?? Manifest.create(); + manifest.addTool("cursor", "1.0.0", []); + await adapter.save(manifest); + await held; + }); + await pause(20); + expect(existsSync(`${adapter.path}.lock`)).toBe(true); + let secondEntered = false; + const secondMutation = second.withExclusiveAccess(async () => { + secondEntered = true; + const manifest = (await second.load()) ?? Manifest.create(); + manifest.addTool("codex", "1.0.0", []); + await second.save(manifest); + }); + await pause(150); + expect(secondEntered).toBe(false); + release(); + await Promise.all([firstMutation, secondMutation]); + expect((await adapter.load())?.getInstalledToolIds()).toEqual(["cursor", "codex"]); + expect(existsSync(`${adapter.path}.lock`)).toBe(false); + }); + + it("keeps the old JSON and releases the lock when the atomic writer fails", async () => { + const baseline = Manifest.create(); + baseline.addTool("cursor", "1.0.0", []); + await adapter.save(baseline); + const failing = new UserManifestRepositoryAdapter( + () => userConfigDir, + async () => { + throw new Error("write failed before rename"); + } + ); + const replacement = Manifest.create(); + replacement.addTool("codex", "1.0.0", []); + await expect(failing.withExclusiveAccess(() => failing.save(replacement))).rejects.toThrow( + "write failed before rename" + ); + expect((await adapter.load())?.getInstalledToolIds()).toEqual(["cursor"]); + expect(existsSync(`${adapter.path}.lock`)).toBe(false); + await expect(adapter.withExclusiveAccess(async () => {})).resolves.toBeUndefined(); + }); + }); + describe("delete()", () => { it("deletes manifest.json from disk", async () => { const manifest = Manifest.create(); @@ -108,7 +172,7 @@ describe("UserManifestRepositoryAdapter", () => { describe("manifest persistence", () => { it("creates the user config dir if it does not exist yet", async () => { const freshDir = join(userConfigDir, "not-yet-created"); - const freshAdapter = new UserManifestRepositoryAdapter(() => freshDir); + const freshAdapter = new UserManifestRepositoryAdapter(() => freshDir, atomicWriteFile); await freshAdapter.save(Manifest.create()); diff --git a/cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts b/cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts index df7076b69..c1c9ab677 100644 --- a/cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts +++ b/cli/tests/contexts/tools/domain/formats/opencode-mcp-merge.unit.test.ts @@ -95,6 +95,27 @@ describe("mergeOpencodeMcp", () => { expect((parsed.mcp["plugin-server"] as { enabled: boolean }).enabled).toBe(false); }); + it("treats a user-edited previously contributed server as a collision on reinstall", () => { + const first = mergeOpencodeMcp( + null, + makeIncoming({ "plugin-server": LOCAL_SERVER }), + new Map(), + hasher + ); + const edited = makeExisting({ "plugin-server": REMOTE_SERVER }); + const next = mergeOpencodeMcp( + edited, + makeIncoming({ "plugin-server": DISABLED_SERVER }), + first.contributedEntries, + hasher + ); + expect(next.collisions).toHaveLength(1); + expect(next.contributedEntries.size).toBe(0); + expect((JSON.parse(next.mergedContent) as { mcp: Record }).mcp).toEqual({ + "plugin-server": REMOTE_SERVER, + }); + }); + it("preserves disabled state (enabled: false) from incoming", () => { const { mergedContent } = mergeOpencodeMcp( null, @@ -225,7 +246,7 @@ describe("buildOpencodeFlatConfig", () => { describe("unmergeOpencodeMcp", () => { it("removes only the tracked entries, preserving other servers", () => { const existing = makeExisting({ plugin: LOCAL_SERVER, user: REMOTE_SERVER }); - const entries = new Map([["plugin", "somehash"]]); + const entries = new Map([["plugin", hasher.hash(JSON.stringify(LOCAL_SERVER)).value]]); const result = unmergeOpencodeMcp(existing, entries); const parsed = JSON.parse(result) as { mcp: Record }; expect(parsed.mcp).not.toHaveProperty("plugin"); @@ -248,4 +269,17 @@ describe("unmergeOpencodeMcp", () => { }; expect(parsed.mcp.server).toEqual(LOCAL_SERVER); }); + + it("refuses to unmerge a tracked server changed by the user", () => { + const entries = new Map([["plugin", hasher.hash(JSON.stringify(LOCAL_SERVER)).value]]); + expect(() => unmergeOpencodeMcp(makeExisting({ plugin: REMOTE_SERVER }), entries)).toThrow( + /edited.*plugin|plugin.*edited/ + ); + }); + + it("refuses an unproven legacy digest before removing a present server", () => { + expect(() => + unmergeOpencodeMcp(makeExisting({ plugin: LOCAL_SERVER }), new Map([["plugin", ""]])) + ).toThrow(/unproven.*plugin|plugin.*unproven/); + }); }); diff --git a/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts b/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts index af6f3ba03..a0c25bf54 100644 --- a/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts +++ b/cli/tests/contexts/tools/domain/host-plugin-registration.unit.test.ts @@ -134,4 +134,11 @@ describe("the sentence each answer carries", () => { expect(entry.answer).toBe("unanswerable"); expect(entry.detail).toBe(`${REGISTRY} could not be read — no reason given`); }); + + it("names ENOENT when the host registry is explicitly absent", () => { + const entry = only(evidence({ reading: { location: REGISTRY, absent: true } })); + + expect(entry.answer).toBe("unanswerable"); + expect(entry.detail).toBe(`${REGISTRY} could not be read — ENOENT`); + }); }); diff --git a/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts index 3a6aeecac..79a344e92 100644 --- a/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/abstract-native-plugin-cli-adapter.integration.test.ts @@ -118,11 +118,11 @@ describe("driving the verbs a tool declares, and only those", () => { it("re-indexes marketplaces with the declared verb, and does nothing without one", () => { mockSpawnSync.mockReturnValue(makeResult({})); - adapter().upgradeMarketplaces(); - adapter({}).upgradeMarketplaces(); + adapter().upgradeMarketplaces("owned-catalog"); + adapter({}).upgradeMarketplaces("owned-catalog"); expect(mockSpawnSync.mock.calls.map((call) => call[1])).toStrictEqual([ - ["plugin", "marketplace", "update"], + ["plugin", "marketplace", "update", "owned-catalog"], ]); }); @@ -160,8 +160,8 @@ describe("naming a failure by the tool, the step and what the tool said", () => it("carries the tool's trimmed stderr on a non-zero exit", () => { mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: " boom \n" })); - expect(() => adapter().upgradeMarketplaces()).toThrow( - new NativePluginCliError("probe-tool marketplace update failed: boom") + expect(() => adapter().upgradeMarketplaces("owned-catalog")).toThrow( + new NativePluginCliError("probe-tool marketplace update owned-catalog failed: boom") ); }); diff --git a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts index 2d3c22910..652cd7a41 100644 --- a/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/host-plugin-registry-reader-adapter.integration.test.ts @@ -1,4 +1,4 @@ -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; @@ -118,11 +118,12 @@ describe("Claude Code's own installed_plugins.json", () => { expect(reading.unreadable).toBeUndefined(); }); - it("says it could not read an absent registry, and carries no refs at all", async () => { + it("distinguishes an absent registry from an unreadable one", async () => { const reading = await readerFor("claude").read(PROJECT); expect(reading.refs).toBeUndefined(); - expect(reading.unreadable).toBe("ENOENT"); + expect(reading.absent).toBe(true); + expect(reading.unreadable).toBeUndefined(); }); /** @@ -254,11 +255,12 @@ describe("Codex's own config.toml", () => { expect(refs?.get("b@m")).toEqual({ enabled: false }); }); - it("says it could not read an absent config, and carries no refs", async () => { + it("distinguishes an absent config from an unreadable one", async () => { const reading = await readerFor("codex").read(PROJECT); expect(reading.refs).toBeUndefined(); - expect(reading.unreadable).toBe("ENOENT"); + expect(reading.absent).toBe(true); + expect(reading.unreadable).toBeUndefined(); }); }); @@ -307,11 +309,37 @@ describe("Copilot's own settings.json", () => { expect(reading.unreadable).toBeUndefined(); }); - it("says it could not read an absent settings file", async () => { - expect((await readerFor("copilot").read(PROJECT)).unreadable).toBe("ENOENT"); + it("distinguishes an absent settings file from an unreadable one", async () => { + const reading = await readerFor("copilot").read(PROJECT); + expect(reading.refs).toBeUndefined(); + expect(reading.absent).toBe(true); + expect(reading.unreadable).toBeUndefined(); }); }); +for (const [tool, path] of [ + ["claude", ".claude/plugins/installed_plugins.json"], + ["codex", ".codex/config.toml"], + ["copilot", ".copilot/settings.json"], +] as const) { + it.runIf(process.platform !== "win32" && process.getuid?.() !== 0)( + `${tool} keeps an EACCES registry unproven`, + async () => { + await write(path, "{}\n"); + const target = join(home, path); + await chmod(target, 0o000); + try { + const reading = await readerFor(tool).read(PROJECT); + expect(reading.refs).toBeUndefined(); + expect(reading.absent).toBeUndefined(); + expect(reading.unreadable).toMatch(/EACCES|EPERM/); + } finally { + await chmod(target, 0o600); + } + } + ); +} + describe("Claude Code's registry, at the edges of its shape", () => { const PATH = ".claude/plugins/installed_plugins.json"; diff --git a/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts new file mode 100644 index 000000000..2da2ae663 --- /dev/null +++ b/cli/tests/contexts/tools/infrastructure/native-marketplace-source-reader.integration.test.ts @@ -0,0 +1,250 @@ +import { spawnSync } from "node:child_process"; +import { describe, expect, it, vi } from "vitest"; +import { codexMarketplaceSourceListContract } from "../../../../src/contexts/tools/domain/profiles/codex/native-marketplace-source.js"; +import { copilotMarketplaceSourceListContract } from "../../../../src/contexts/tools/domain/profiles/copilot/native-marketplace-source.js"; +import { + HostRegistryMarketplaceSourceReaderAdapter, + NativeMarketplaceSourceReaderAdapter, +} from "../../../../src/contexts/tools/infrastructure/native-marketplace-source-reader-adapter.js"; + +vi.mock("node:child_process", () => ({ spawnSync: vi.fn() })); +const spawn = vi.mocked(spawnSync); + +function result(stdout: string, status = 0, stderr = "") { + return { + pid: 1, + output: [], + stdout, + stderr, + status, + signal: null, + error: undefined, + } as ReturnType; +} + +describe("native marketplace source reader", () => { + it("maps Claude's readable marketplace registry to exact file sources", async () => { + const reader = new HostRegistryMarketplaceSourceReaderAdapter({ + read: async () => ({ + location: "/home/.claude/plugins/known_marketplaces.json", + entries: new Map([["aidd-framework", "/project-a/.aidd/cache/built"]]), + }), + }); + + const reading = await reader.read("/project-b"); + + expect(reading.entries?.get("aidd-framework")).toEqual({ + kind: "registry", + source: "/project-a/.aidd/cache/built", + }); + }); + + it("distinguishes Claude's missing registry from an unreadable one", async () => { + for (const [hostReading, expected] of [ + [{ location: "/known", absent: true as const }, "absent"], + [{ location: "/known", unreadable: "EACCES" }, "unreadable"], + ] as const) { + const reading = await new HostRegistryMarketplaceSourceReaderAdapter({ + read: async () => hostReading, + }).read("/project-b"); + + if (expected === "absent") expect(reading.entries?.size).toBe(0); + else expect(reading.unreadable).toContain("EACCES"); + } + }); + + it("reads Codex's effective configured source with the requesting project's CWD", async () => { + spawn.mockReturnValue( + result( + JSON.stringify({ + marketplaces: [ + { + name: "aidd-framework", + root: "/home/.codex/plugins/marketplaces/aidd-framework", + marketplaceSource: { sourceType: "local", source: "/project-a/.aidd/cache/built" }, + }, + ], + }) + ) + ); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-b"); + + expect(spawn).toHaveBeenCalledWith( + "codex", + ["plugin", "marketplace", "list", "--json"], + expect.objectContaining({ cwd: "/project-b", shell: false }) + ); + expect(reading.entries?.get("aidd-framework")).toEqual({ + kind: "effective-list", + root: "/home/.codex/plugins/marketplaces/aidd-framework", + sourceType: "local", + source: "/project-a/.aidd/cache/built", + }); + }); + + it("proves an empty Codex host only from a valid structured empty list", async () => { + spawn.mockReturnValue(result('{"marketplaces":[]}')); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries?.size).toBe(0); + expect(reading.unreadable).toBeUndefined(); + }); + + it("marks a named Codex row without configured source unproven", async () => { + spawn.mockReturnValue(result('{"marketplaces":[{"name":"foreign","root":"/foreign/cache"}]}')); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries?.has("foreign")).toBe(true); + expect(reading.entries?.get("foreign")).toBeNull(); + }); + + it("rejects unknown JSON shapes and duplicate host names without inferring absence", async () => { + for (const output of [ + '{"plugins":[]}', + '{"marketplaces":[{"name":"aidd-framework","root":7}]}', + '{"marketplaces":[{"name":"aidd-framework","root":"/one"},{"name":"aidd-framework","root":"/two"}]}', + "not JSON", + ]) { + spawn.mockReturnValue(result(output)); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toContain("codex marketplace list"); + } + }); + + it.each([ + ["null listing", null], + ["array listing", []], + ["null catalogue", { marketplaces: null }], + ["object catalogue", { marketplaces: {} }], + ["null row", { marketplaces: [null] }], + ["array row", { marketplaces: [[]] }], + ["missing name", { marketplaces: [{ root: "/cache" }] }], + ["numeric name", { marketplaces: [{ name: 7, root: "/cache" }] }], + ["empty name", { marketplaces: [{ name: "", root: "/cache" }] }], + ["missing root", { marketplaces: [{ name: "foreign" }] }], + ["empty root", { marketplaces: [{ name: "foreign", root: "" }] }], + ])("keeps Codex provenance unreadable for a %s", async (_name, payload) => { + spawn.mockReturnValue(result(JSON.stringify(payload))); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toContain("codex marketplace list could not prove a source"); + }); + + it.each([ + ["null source", null], + ["array source", []], + ["missing source type", { source: "/owned" }], + ["numeric source type", { sourceType: 7, source: "/owned" }], + ["empty source type", { sourceType: "", source: "/owned" }], + ["missing source path", { sourceType: "local" }], + ["numeric source path", { sourceType: "local", source: 7 }], + ["empty source path", { sourceType: "local", source: "" }], + ])("does not accept a Codex cache root as proof of a %s", async (_name, source) => { + spawn.mockReturnValue( + result( + JSON.stringify({ + marketplaces: [{ name: "foreign", root: "/owned/cache", marketplaceSource: source }], + }) + ) + ); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toContain("codex marketplace list could not prove a source"); + }); + + it("rejects the whole Codex listing when a later row has no proven source shape", async () => { + spawn.mockReturnValue( + result( + JSON.stringify({ + marketplaces: [ + { + name: "owned", + root: "/owned/cache", + marketplaceSource: { sourceType: "local", source: "/owned/source" }, + }, + { name: "foreign", root: "/foreign/cache", marketplaceSource: null }, + ], + }) + ) + ); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toContain("codex marketplace list could not prove a source"); + }); + + it.each([ + ['{"plugins":[]}', "unexpected Codex JSON shape"], + ['{"marketplaces":[{"name":"","root":"/cache"}]}', "ambiguous Codex marketplace JSON row"], + [ + '{"marketplaces":[{"name":"foreign","root":"/cache","marketplaceSource":null}]}', + "unproven Codex marketplace source shape", + ], + ])("identifies the provenance repair needed for %s", async (output, diagnosis) => { + spawn.mockReturnValue(result(output)); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toBe( + `codex marketplace list could not prove a source: ${diagnosis}` + ); + }); + + it("gives an actionable Codex diagnostic without treating a failed listing as absence", async () => { + spawn.mockReturnValue(result("", 1, "permission denied")); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + codexMarketplaceSourceListContract + ).read("/project-a"); + + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toBe( + "Codex marketplace list --json unavailable; inspect the host CLI and retry: permission denied" + ); + }); + + it("refuses Copilot 1.0.83's unsupported --json without a human-text fallback", async () => { + spawn.mockReturnValue(result("", 1, "error: unknown option '--json'")); + + const reading = await new NativeMarketplaceSourceReaderAdapter( + copilotMarketplaceSourceListContract + ).read("/project-a"); + + expect(spawn).toHaveBeenCalledWith( + "copilot", + ["plugin", "marketplace", "list", "--json"], + expect.objectContaining({ cwd: "/project-a", shell: false }) + ); + expect(reading.entries).toBeUndefined(); + expect(reading.unreadable).toContain("marketplace list --json"); + expect(reading.unreadable).toContain("upgrade"); + }); +}); diff --git a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts index 79f113cc2..0a5052546 100644 --- a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.codex.integration.test.ts @@ -91,21 +91,29 @@ describe("CodexCliAdapter", () => { ); }); - it("upgrades marketplaces via `codex plugin marketplace upgrade`", () => { + it("upgrades only the named marketplace via `codex plugin marketplace upgrade `", () => { mockSpawnSync.mockReturnValue(makeResult({})); new NativePluginCliAdapter("codex", { upgradeVerb: "upgrade", enableVerb: "add", - }).upgradeMarketplaces(); + }).upgradeMarketplaces("owned-catalog"); expect(mockSpawnSync).toHaveBeenCalledWith( "codex", - ["plugin", "marketplace", "upgrade"], + ["plugin", "marketplace", "upgrade", "owned-catalog"], expect.anything() ); }); + it("refuses a targeted plugin update when Codex declares no verified update verb", () => { + mockSpawnSync.mockClear(); + expect(() => + new NativePluginCliAdapter("codex", {}).updatePlugin("aidd-context@real-catalog") + ).toThrow(/does not support targeted native plugin update/); + expect(mockSpawnSync).not.toHaveBeenCalled(); + }); + it("enables a plugin via `codex plugin add `", () => { mockSpawnSync.mockReturnValue(makeResult({})); diff --git a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts index 14ef06673..98e7f8fa5 100644 --- a/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts +++ b/cli/tests/contexts/tools/infrastructure/native-plugin-cli-adapter.copilot.integration.test.ts @@ -83,17 +83,17 @@ describe("CopilotCliAdapter", () => { ); }); - it("refreshes marketplaces via `copilot plugin marketplace update`", () => { + it("refreshes only the named marketplace via `copilot plugin marketplace update `", () => { mockSpawnSync.mockReturnValue(makeResult({})); new NativePluginCliAdapter("copilot", { upgradeVerb: "update", enableVerb: "install", - }).upgradeMarketplaces(); + }).upgradeMarketplaces("owned-catalog"); expect(mockSpawnSync).toHaveBeenCalledWith( "copilot", - ["plugin", "marketplace", "update"], + ["plugin", "marketplace", "update", "owned-catalog"], expect.anything() ); }); @@ -113,6 +113,29 @@ describe("CopilotCliAdapter", () => { ); }); + it("updates only the exact Copilot plugin ref, not its entire catalogue", () => { + mockSpawnSync.mockReturnValue(makeResult({})); + new NativePluginCliAdapter("copilot", { updateVerb: "update" }).updatePlugin( + "aidd-context@real-catalog" + ); + expect(mockSpawnSync).toHaveBeenCalledWith( + "copilot", + ["plugin", "update", "aidd-context@real-catalog"], + expect.anything() + ); + }); + + it("reports a failed targeted Copilot update without retrying a catalogue refresh", () => { + mockSpawnSync.mockClear(); + mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: "plugin update failed" })); + expect(() => + new NativePluginCliAdapter("copilot", { updateVerb: "update" }).updatePlugin( + "aidd-context@real-catalog" + ) + ).toThrow(NativePluginCliError); + expect(mockSpawnSync).toHaveBeenCalledTimes(1); + }); + it("throws NativePluginCliError with stderr detail on non-zero exit", () => { mockSpawnSync.mockReturnValue(makeResult({ status: 1, stderr: 'Marketplace "m1" not found' })); diff --git a/cli/tests/e2e/clean-scope-user.e2e.test.ts b/cli/tests/e2e/clean-scope-user.e2e.test.ts index 8dbbf8c99..fd4119d91 100644 --- a/cli/tests/e2e/clean-scope-user.e2e.test.ts +++ b/cli/tests/e2e/clean-scope-user.e2e.test.ts @@ -202,8 +202,17 @@ describe("E2E: clean --scope user purges the shared source", () => { expect(before).toContain("references.json"); expect(await readJson(join(userConfigDir, "manifest.json")).catch(() => null)).toBeNull(); - const statusBeforeClean = await gitStatusPorcelain(projectDir); + const blocked = await runCli(["clean", "--scope", "user", "--force"], projectDir, fakeHome); + expect(blocked.exitCode).toBe(1); + expect(blocked.stderr).toContain(projectDir); + const projectClean = await runCli( + ["clean", "--scope", "project", "--force"], + projectDir, + fakeHome + ); + expect(projectClean.exitCode).toBe(0); + const statusBeforeClean = await gitStatusPorcelain(projectDir); const cleanResult = await runCli( ["clean", "--scope", "user", "--force"], projectDir, diff --git a/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts b/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts index aa53b0ec3..ffbdf9447 100644 --- a/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts +++ b/cli/tests/e2e/clean-shared-ref-codex.e2e.test.ts @@ -2,18 +2,74 @@ * Codex enables a plugin machine-wide (no `NativeActivation.scopeArgs`), so a `clean` here * must not disable it. `--plugins none` records no `pluginRefs`, so a real name is passed. */ -import { readFile, realpath } from "node:fs/promises"; +import { mkdir, readFile, realpath, writeFile } from "node:fs/promises"; import { delimiter, join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestEnv, pathWithoutAidd, runCli, writeFakeToolBinary } from "./helpers.js"; +import { createTestEnv, pathWithoutAidd, runCli } from "./helpers.js"; const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); const PLUGIN_NAME = "aidd-vcs"; +/** Every string a parsed JSON tree holds. Asserting against `JSON.stringify` instead compares + * an escaped rendering: on Windows a path's backslashes come back doubled and match nothing. */ +function stringLeavesOf(value: unknown): string[] { + if (typeof value === "string") return [value]; + if (Array.isArray(value)) return value.flatMap(stringLeavesOf); + if (value !== null && typeof value === "object") + return Object.values(value).flatMap(stringLeavesOf); + return []; +} + async function readJson(path: string): Promise> { return JSON.parse(await readFile(path, "utf-8")) as Record; } +async function writeCodexWithEffectiveSource( + binDir: string, + logFile: string, + stateFile: string, + configFile: string +): Promise { + await mkdir(binDir, { recursive: true }); + const script = join(binDir, "codex-fake.mjs"); + await writeFile( + script, + [ + 'import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";', + 'import { dirname } from "node:path";', + `const logFile = ${JSON.stringify(logFile)};`, + `const stateFile = ${JSON.stringify(stateFile)};`, + `const configFile = ${JSON.stringify(configFile)};`, + "const args = process.argv.slice(2);", + 'writeFileSync(logFile, args.join(" ") + "\\n", { flag: "a" });', + 'if (args[0] === "plugin" && args[1] === "marketplace" && args[2] === "list" && args[3] === "--json") {', + ' const path = existsSync(stateFile) ? readFileSync(stateFile, "utf-8") : undefined;', + ' process.stdout.write(JSON.stringify({ marketplaces: path ? [{ name: "aidd-framework", root: path, marketplaceSource: { sourceType: "local", source: path } }] : [] }));', + '} else if (args[0] === "plugin" && args[1] === "marketplace" && args[2] === "add") {', + " writeFileSync(stateFile, realpathSync(args[3]));", + '} else if (args[0] === "plugin" && args[1] === "add") {', + " mkdirSync(dirname(configFile), { recursive: true });", + ` if (!existsSync(configFile)) writeFileSync(configFile, ${JSON.stringify('[plugins."')} + args[2] + ${JSON.stringify('"]\nenabled = true\n')});`, + "}", + "", + ].join("\n") + ); + if (process.platform === "win32") { + await writeFile( + join(binDir, "codex.cmd"), + `@echo off\r\n"${process.execPath}" "${script}" %*\r\n` + ); + } else { + await writeFile( + join(binDir, "codex"), + `#!/bin/sh\nexec "${process.execPath}" "${script}" "$@"\n`, + { + mode: 0o755, + } + ); + } +} + describe("E2E: clean leaves a codex ref enabled while another project still shares it", () => { it("keeps the plugin's ref enabled, names the other project, and never calls plugin remove", async () => { const first = await createTestEnv("clean-shared-ref-codex-first"); @@ -21,7 +77,12 @@ describe("E2E: clean leaves a codex ref enabled while another project still shar try { const logFile = join(first.tempDir, "codex-invocations.log"); const binDir = join(first.tempDir, "bin"); - await writeFakeToolBinary(binDir, "codex", logFile); + await writeCodexWithEffectiveSource( + binDir, + logFile, + join(first.tempDir, "codex-marketplace-source.txt"), + join(first.fakeHome, ".codex", "config.toml") + ); const env = { PATH: `${binDir}${delimiter}${pathWithoutAidd()}` }; const setupArgs = [ @@ -41,10 +102,21 @@ describe("E2E: clean leaves a codex ref enabled while another project still shar // `second.fakeHome`. const firstSetup = await runCli(setupArgs, first.projectDir, first.fakeHome, { env }); expect(firstSetup.exitCode).toBe(0); + expect(firstSetup.stderr).toBe(""); const secondSetup = await runCli(setupArgs, second.projectDir, first.fakeHome, { env }); expect(secondSetup.exitCode).toBe(0); const secondRoot = await realpath(second.projectDir); + const activationLog = await readFile(logFile, "utf-8"); + expect(activationLog).toContain("plugin marketplace add"); + expect(activationLog).toContain("plugin add"); + expect(await readFile(join(first.fakeHome, ".codex", "config.toml"), "utf-8")).toContain( + `aidd-vcs@aidd-framework` + ); + const machineManifest = await readJson( + join(first.fakeHome, ".config", "aidd", "manifest.json") + ); + expect(stringLeavesOf(machineManifest)).toContain(secondRoot); const referencesBefore = await readJson( join(first.fakeHome, ".config", "aidd", "references.json") ); diff --git a/cli/tests/e2e/helpers.ts b/cli/tests/e2e/helpers.ts index 751f7dc11..603ff7704 100644 --- a/cli/tests/e2e/helpers.ts +++ b/cli/tests/e2e/helpers.ts @@ -1,6 +1,6 @@ import { execFile } from "node:child_process"; import { accessSync, constants, existsSync } from "node:fs"; -import { copyFile, cp, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { copyFile, cp, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { homedir, tmpdir } from "node:os"; import { delimiter, dirname, join, resolve } from "node:path"; import { promisify } from "node:util"; @@ -57,7 +57,11 @@ export async function createTestEnv(prefix: string): Promise<{ fakeHome: string; cleanup: () => Promise; }> { - const tempDir = await mkdtemp(join(tmpdir(), `aidd-e2e-${prefix}-`)); + // `realpath` because the CLI resolves the roots it prints and compares, and `tmpdir()` does + // not: Windows hands back the 8.3 short form (`C:\\Users\\RUNNER~1\\…`) and macOS a symlink + // (`/var` → `/private/var`). Without this every assertion naming a workspace path compares + // two spellings of the same directory. + const tempDir = await realpath(await mkdtemp(join(tmpdir(), `aidd-e2e-${prefix}-`))); const projectDir = join(tempDir, "project"); const fakeHome = join(tempDir, "home"); await mkdir(projectDir, { recursive: true }); diff --git a/cli/tests/e2e/preexisting-codex-ref.e2e.test.ts b/cli/tests/e2e/preexisting-codex-ref.e2e.test.ts index 57e89e6f3..f9a7bef2a 100644 --- a/cli/tests/e2e/preexisting-codex-ref.e2e.test.ts +++ b/cli/tests/e2e/preexisting-codex-ref.e2e.test.ts @@ -36,9 +36,15 @@ describe("E2E: a codex plugin enabled before setup stays with the person who ena const clean = await runCli(["clean", "--force"], test.projectDir, test.fakeHome, { env }); expect(clean.exitCode).toBe(0); - const log = await readFile(logFile, "utf-8"); - expect(log).not.toContain(`${PLUGIN_NAME}@`); - expect(await readFile(codexConfig, "utf-8")).toContain(THEIR_OWN_ENABLE); + const log = await readFile(logFile, "utf-8").catch((error: NodeJS.ErrnoException) => { + if (error.code === "ENOENT") return ""; + throw error; + }); + expect(log).toContain("plugin marketplace list --json"); + expect(log).not.toContain("plugin marketplace add"); + expect(log).not.toContain("plugin add"); + expect(log).not.toContain("plugin remove"); + expect(await readFile(codexConfig, "utf-8")).toBe(THEIR_OWN_ENABLE); } finally { await test.cleanup(); } diff --git a/cli/tests/e2e/refused-marketplace-clean.e2e.test.ts b/cli/tests/e2e/refused-marketplace-clean.e2e.test.ts index 5621bf4fc..1a8a2e105 100644 --- a/cli/tests/e2e/refused-marketplace-clean.e2e.test.ts +++ b/cli/tests/e2e/refused-marketplace-clean.e2e.test.ts @@ -13,6 +13,7 @@ async function writeCopilotThatRefusesMarketplaceAdd(binDir: string, logFile: st [ "#!/bin/sh", `echo "$@" >> "${logFile}"`, + 'case "$*" in *"marketplace list --json"*) echo "error: unknown option --json" >&2; exit 1;; esac', 'case "$*" in *"marketplace add"*) echo "marketplace is already registered" >&2; exit 1;; esac', "exit 0", "", @@ -28,6 +29,13 @@ describe("E2E: clean leaves a marketplace the host refused to register for this const logFile = join(test.tempDir, "copilot-invocations.log"); const binDir = join(test.tempDir, "bin"); await writeCopilotThatRefusesMarketplaceAdd(binDir, logFile); + const hostSettings = join(test.fakeHome, ".copilot", "settings.json"); + const hostCache = join(test.fakeHome, ".copilot", "plugins", "probe", "foreign.txt"); + const foreignSettings = '{"enabledPlugins":{"foreign@probe":true}}'; + const foreignCache = "foreign cache bytes"; + await mkdir(join(test.fakeHome, ".copilot", "plugins", "probe"), { recursive: true }); + await writeFile(hostSettings, foreignSettings); + await writeFile(hostCache, foreignCache); const env = { PATH: `${binDir}${delimiter}${pathWithoutAidd()}` }; const run = (args: string[]) => runCli(args, test.projectDir, test.fakeHome, { env }); @@ -54,13 +62,16 @@ describe("E2E: clean leaves a marketplace the host refused to register for this "--yes", ]); expect(add.exitCode).toBe(0); - expect(await readFile(logFile, "utf-8")).toContain("marketplace add"); + expect(await readFile(logFile, "utf-8")).toContain("marketplace list --json"); + expect(await readFile(logFile, "utf-8")).not.toContain("marketplace add"); await writeFile(logFile, ""); const clean = await run(["clean", "--force"]); expect(clean.exitCode).toBe(0); expect(await readFile(logFile, "utf-8")).not.toContain("marketplace remove"); + expect(await readFile(hostSettings, "utf-8")).toBe(foreignSettings); + expect(await readFile(hostCache, "utf-8")).toBe(foreignCache); } finally { await test.cleanup(); } diff --git a/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts b/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts index cf3bfb9e8..81e302d79 100644 --- a/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts +++ b/cli/tests/e2e/sync-recreates-machine-scope-registration.e2e.test.ts @@ -19,7 +19,7 @@ async function readJson(path: string): Promise> { } describe("E2E: sync recreates a machine-scope registration a fresh clone never carried", () => { - it("registers the shared source and this project's own reference, with native activation unavailable", async () => { + it("registers the shared marketplace without inventing a host-source reference when activation is unavailable", async () => { const origin = await createTestEnv("machine-scope-sync-origin"); const clone = await createTestEnv("machine-scope-sync-clone"); try { @@ -62,10 +62,9 @@ describe("E2E: sync recreates a machine-scope registration a fresh clone never c const names = (marketplaces.marketplaces as Array<{ name: string }>).map((m) => m.name); expect(names).toContain("aidd-framework"); - const references = await readJson(join(clone.fakeHome, ".config", "aidd", "references.json")); - const allProjectRoots = Object.values(references).flat() as string[]; - const expectedRoot = await realpath(clone.projectDir); - expect(allProjectRoots).toContain(expectedRoot); + await expect( + readFile(join(clone.fakeHome, ".config", "aidd", "references.json")) + ).rejects.toMatchObject({ code: "ENOENT" }); } finally { await origin.cleanup(); await clone.cleanup(); diff --git a/cli/tests/e2e/telemetry-check.e2e.test.ts b/cli/tests/e2e/telemetry-check.e2e.test.ts index b63cbad1e..3f9afefa1 100644 --- a/cli/tests/e2e/telemetry-check.e2e.test.ts +++ b/cli/tests/e2e/telemetry-check.e2e.test.ts @@ -208,7 +208,7 @@ describe("aidd telemetry check — the journey and its edge cases", () => { ); const result = await runCli(["telemetry", "check"], projectDir, fakeHome, { - env: { CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, + env: { CODEX_THREAD_ID: "", CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, }); expect(result.exitCode, result.stderr).toBe(0); @@ -633,7 +633,7 @@ describe("aidd telemetry check — not yet stops being a failure", () => { ); const result = await runCli(["telemetry", "check"], projectDir, fakeHome, { - env: { CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, + env: { CODEX_THREAD_ID: "", CLAUDE_CODE_SESSION_ID: CLAUDE_SESSION }, }); expect(result.exitCode, result.stderr).toBe(1); diff --git a/cli/tests/e2e/two-projects-one-home-cursor.e2e.test.ts b/cli/tests/e2e/two-projects-one-home-cursor.e2e.test.ts index 113fdc74f..ea6d3cd30 100644 --- a/cli/tests/e2e/two-projects-one-home-cursor.e2e.test.ts +++ b/cli/tests/e2e/two-projects-one-home-cursor.e2e.test.ts @@ -1,8 +1,9 @@ import { existsSync } from "node:fs"; -import { mkdir, readdir, readFile } from "node:fs/promises"; +import { mkdir, readdir, readFile, realpath, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import { createTestEnv, runCli } from "./helpers.js"; +import { Manifest } from "../../src/contexts/framework/domain/manifest.js"; +import { copyFixtureTree, createTestEnv, runCli } from "./helpers.js"; const FRAMEWORK_REAL_PATH = resolve(process.cwd(), "tests/fixtures/framework-real"); const PLUGIN = "aidd-context"; @@ -32,6 +33,8 @@ describe("E2E: two projects on one HOME install the same cursor plugin", () => { test.fakeHome ); const hooksOf = (cwd: string) => readFile(join(cwd, ".cursor", "hooks.json"), "utf-8"); + const manifestAt = async (path: string) => + Manifest.fromJSON(JSON.parse(await readFile(path, "utf-8"))); expect((await setup(test.projectDir)).exitCode).toBe(0); expect(await hooksOf(test.projectDir)).toContain("update_memory"); @@ -40,8 +43,27 @@ describe("E2E: two projects on one HOME install the same cursor plugin", () => { const secondSetup = await setup(second); expect(secondSetup.exitCode).toBe(0); - expect(secondSetup.stdout + secondSetup.stderr).toContain("was already there"); expect(await hooksOf(second)).toContain("update_memory"); + const machine = await manifestAt(join(test.fakeHome, ".config", "aidd", "manifest.json")); + const owned = machine.getPlugins("cursor").find((plugin) => plugin.name === PLUGIN); + expect(owned?.files.size).toBeGreaterThan(0); + expect(owned?.dependents).toEqual([await realpath(test.projectDir), await realpath(second)]); + for (const cwd of [test.projectDir, second]) { + const project = await manifestAt(join(cwd, ".aidd", "manifest.json")); + expect( + project.getPlugins("cursor").find((plugin) => plugin.name === PLUGIN)?.files.size + ).toBe(0); + } + const prematureGlobalClean = await runCli( + ["clean", "--scope", "user", "--force"], + test.projectDir, + test.fakeHome + ); + expect(prematureGlobalClean.exitCode).not.toBe(0); + expect(prematureGlobalClean.stdout + prematureGlobalClean.stderr).toContain( + await realpath(second) + ); + expect(await readdir(userDir, { recursive: true })).toEqual(firstInstall); const remove = await runCli( ["plugin", "remove", PLUGIN, "--tool", "cursor"], @@ -53,9 +75,144 @@ describe("E2E: two projects on one HOME install the same cursor plugin", () => { expect(await readdir(userDir, { recursive: true })).toEqual(firstInstall); expect((await runCli(["clean", "--force"], test.projectDir, test.fakeHome)).exitCode).toBe(0); + expect(existsSync(userDir)).toBe(true); + expect( + (await runCli(["clean", "--scope", "user", "--force"], test.projectDir, test.fakeHome)) + .exitCode + ).toBe(0); expect(existsSync(userDir)).toBe(false); } finally { await test.cleanup(); } }); + + it("removes one machine-owned plugin only after both projects detach", async () => { + const test = await createTestEnv("two-projects-targeted-user-remove"); + try { + const second = join(test.tempDir, "second"); + await mkdir(second, { recursive: true }); + for (const cwd of [test.projectDir, second]) { + const setup = await runCli( + [ + "setup", + "--source", + "local", + "--path", + FRAMEWORK_REAL_PATH, + "--ai", + "cursor", + "--plugins", + "aidd-context,aidd-dev", + "--yes", + ], + cwd, + test.fakeHome + ); + expect(setup.exitCode).toBe(0); + } + const contextDir = join(test.fakeHome, ".cursor", "plugins", "local", "aidd-context"); + const devDir = join(test.fakeHome, ".cursor", "plugins", "local", "aidd-dev"); + const blocked = await runCli( + ["plugin", "remove", "aidd-context", "--tool", "cursor", "--scope", "user"], + test.projectDir, + test.fakeHome + ); + expect(blocked.exitCode).not.toBe(0); + expect(blocked.stdout + blocked.stderr).toContain(await realpath(second)); + expect(existsSync(contextDir)).toBe(true); + for (const cwd of [test.projectDir, second]) { + expect((await runCli(["clean", "--force"], cwd, test.fakeHome)).exitCode).toBe(0); + } + const removed = await runCli( + ["plugin", "remove", "aidd-context", "--tool", "cursor", "--scope", "user"], + test.projectDir, + test.fakeHome + ); + expect(removed.exitCode).toBe(0); + expect(existsSync(contextDir)).toBe(false); + expect(existsSync(devDir)).toBe(true); + expect( + (await runCli(["clean", "--scope", "user", "--force"], test.projectDir, test.fakeHome)) + .exitCode + ).toBe(0); + } finally { + await test.cleanup(); + } + }); + + it("updates only the selected machine plugin and names dependent projects", async () => { + const test = await createTestEnv("two-projects-targeted-user-update"); + try { + const source = join(test.tempDir, "framework-source"); + const second = join(test.tempDir, "second"); + await copyFixtureTree(FRAMEWORK_REAL_PATH, source); + await mkdir(second, { recursive: true }); + for (const cwd of [test.projectDir, second]) { + expect( + ( + await runCli( + [ + "setup", + "--source", + "local", + "--path", + source, + "--ai", + "cursor", + "--plugins", + "aidd-context,aidd-dev", + "--yes", + ], + cwd, + test.fakeHome + ) + ).exitCode + ).toBe(0); + } + const userBase = join(test.fakeHome, ".cursor", "plugins", "local"); + const devBefore = await readdir(join(userBase, "aidd-dev"), { recursive: true }); + const hooksBefore = await Promise.all( + [test.projectDir, second].map((cwd) => + readFile(join(cwd, ".cursor", "hooks.json"), "utf-8") + ) + ); + const pluginJson = join(source, "plugins", "aidd-context", ".claude-plugin", "plugin.json"); + const marketJson = join(source, ".claude-plugin", "marketplace.json"); + const pluginData = JSON.parse(await readFile(pluginJson, "utf-8")); + pluginData.version = "1.1.0"; + await writeFile(pluginJson, JSON.stringify(pluginData)); + const marketData = JSON.parse(await readFile(marketJson, "utf-8")); + marketData.version = "1.1.0"; + const contextEntry = marketData.plugins.find( + (entry: { name: string }) => entry.name === "aidd-context" + ); + contextEntry.version = "1.1.0"; + await writeFile(marketJson, JSON.stringify(marketData)); + const skill = join(source, "plugins", "aidd-context", "skills", "01-bootstrap", "SKILL.md"); + await writeFile(skill, `${await readFile(skill, "utf-8")}\nMachine update marker.\n`); + const update = await runCli( + ["plugin", "update", "aidd-context", "--tool", "cursor", "--scope", "user"], + test.projectDir, + test.fakeHome + ); + expect(update.exitCode).toBe(0); + expect(update.stdout + update.stderr).toContain(await realpath(second)); + expect( + await readFile( + join(userBase, "aidd-context", "skills", "01-bootstrap", "SKILL.md"), + "utf-8" + ) + ).toContain("Machine update marker."); + expect(await readdir(join(userBase, "aidd-dev"), { recursive: true })).toEqual(devBefore); + expect( + await Promise.all( + [test.projectDir, second].map((cwd) => + readFile(join(cwd, ".cursor", "hooks.json"), "utf-8") + ) + ) + ).toEqual(hooksBefore); + } finally { + await test.cleanup(); + } + }); }); diff --git a/cli/tests/fixtures/plugins/codex-format/marketplace-foreign-aidd/.agents/plugins/marketplace.json b/cli/tests/fixtures/plugins/codex-format/marketplace-foreign-aidd/.agents/plugins/marketplace.json new file mode 100644 index 000000000..ed755c0eb --- /dev/null +++ b/cli/tests/fixtures/plugins/codex-format/marketplace-foreign-aidd/.agents/plugins/marketplace.json @@ -0,0 +1 @@ +{"name":"aidd-framework","plugins":[]} diff --git a/cli/tests/fixtures/plugins/codex-format/marketplace-provenance/.agents/plugins/marketplace.json b/cli/tests/fixtures/plugins/codex-format/marketplace-provenance/.agents/plugins/marketplace.json new file mode 100644 index 000000000..25834e04b --- /dev/null +++ b/cli/tests/fixtures/plugins/codex-format/marketplace-provenance/.agents/plugins/marketplace.json @@ -0,0 +1,4 @@ +{ + "name": "aidd-provenance-fixture", + "plugins": [] +} diff --git a/cli/tests/golden/snapshots/help/surface.json b/cli/tests/golden/snapshots/help/surface.json index 0123934dd..359cb62ff 100644 --- a/cli/tests/golden/snapshots/help/surface.json +++ b/cli/tests/golden/snapshots/help/surface.json @@ -87,7 +87,7 @@ { "invocation": "aidd marketplace remove", "exitCode": 0, - "help": "Usage: aidd marketplace remove [options] \n\nRemove a registered plugin marketplace\n\nOptions:\n --yes Skip the orphan-cleanup prompt\n -h, --help display help for command" + "help": "Usage: aidd marketplace remove [options] \n\nRemove a registered plugin marketplace\n\nOptions:\n --yes Skip the orphan-cleanup prompt\n --scope Remove from project or user scope (default: \"project\")\n -h, --help display help for command" }, { "invocation": "aidd plugin", @@ -107,7 +107,7 @@ { "invocation": "aidd plugin remove", "exitCode": 0, - "help": "Usage: aidd plugin remove [options] \n\nRemove a plugin from one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n -h, --help display help for command" + "help": "Usage: aidd plugin remove [options] \n\nRemove a plugin from one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n --scope Removal scope (default: project)\n -h, --help display help for command" }, { "invocation": "aidd plugin search", @@ -117,7 +117,7 @@ { "invocation": "aidd plugin update", "exitCode": 0, - "help": "Usage: aidd plugin update [options] [name]\n\nUpdate one or all plugins for one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n -h, --help display help for command" + "help": "Usage: aidd plugin update [options] [name]\n\nUpdate one or all plugins for one or all AI tools\n\nOptions:\n --tool Target AI tool (default: all installed)\n --scope Update scope (default: project)\n -h, --help display help for command" }, { "invocation": "aidd setup", diff --git a/cli/tests/golden/snapshots/phase0/snapshot.json b/cli/tests/golden/snapshots/phase0/snapshot.json index e28a34f61..286fde4b8 100644 --- a/cli/tests/golden/snapshots/phase0/snapshot.json +++ b/cli/tests/golden/snapshots/phase0/snapshot.json @@ -293,14 +293,20 @@ }, "version": "", "strict": true, - "files": { - "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", - "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", - "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", - "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" - }, + "files": {}, "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e" + } + }, "marketplace": "aidd-framework" } ] @@ -361,14 +367,20 @@ }, "version": "", "strict": true, - "files": { - "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", - "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", - "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", - "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" - }, + "files": {}, "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e" + } + }, "marketplace": "aidd-framework" } ] @@ -429,14 +441,20 @@ }, "version": "", "strict": true, - "files": { - "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", - "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", - "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", - "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" - }, + "files": {}, "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e" + } + }, "marketplace": "aidd-framework" } ] @@ -496,15 +514,21 @@ "path": "/plugins/aidd-test" }, "version": "", - "strict": false, - "files": { - "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", - "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", - "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", - "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" - }, + "strict": true, + "files": {}, "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e" + } + }, "marketplace": "aidd-framework" } ] @@ -564,15 +588,21 @@ "path": "/plugins/aidd-test" }, "version": "", - "strict": false, - "files": { - "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", - "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", - "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", - "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", - "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" - }, + "strict": true, + "files": {}, "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e" + } + }, "marketplace": "aidd-framework" } ] @@ -584,7 +614,7 @@ "command": "plugin remove aidd-test", "exitCode": 0, "stdout": "Plugin 'aidd-test' removed.\n", - "stderr": "Warning: claude CLI not found on PATH — 'aidd-test@aidd-framework' was not uninstalled from claude's own plugin registry and may still be enabled there.\nWarning: claude CLI not found on PATH — skipping native plugin activation.\n", + "stderr": "Warning: claude CLI not found on PATH — 'aidd-test@aidd-framework' was not uninstalled from the host; its ref may remain enabled after local removal.\nWarning: claude CLI not found on PATH — skipping native plugin activation.\n", "filesWritten": [], "manifest": { "version": 8, @@ -652,6 +682,44 @@ "version": "", "files": [], "mergeFiles": [] + }, + "cursor": { + "toolId": "cursor", + "version": "", + "files": [], + "mergeFiles": [], + "plugins": [ + { + "name": "aidd-test", + "source": { + "kind": "local", + "path": "/plugins/aidd-test" + }, + "version": "", + "strict": true, + "files": { + "aidd-test/.cursor-plugin/plugin.json": "af3965dc3bb38289cd5501b7244926fa", + "aidd-test/.mcp.json": "8d5f495dc98074770f3390b2271ddf4a", + "aidd-test/agents/code-reviewer.md": "3085f2108f7b9448d523f1555a802bb2", + "aidd-test/skills/commit/SKILL.md": "01c4b6a281146776eb7304577c56bb79", + "aidd-test/skills/hello.md": "f00ea16a97341b9314df0da073633624" + }, + "scope": "user", + "projectHooks": { + "entries": [ + { + "event": "preToolUse", + "command": "./.cursor/hooks/aidd-test/check.sh", + "digest": "a2f1a3d6f4ae24d6e7011f435e0f223c" + } + ], + "scripts": { + ".cursor/hooks/aidd-test/check.sh": "d3cb6c174a3ae1041a087fef46f9b70e" + } + }, + "marketplace": "aidd-framework" + } + ] } } } diff --git a/cli/tests/helpers/ports/build-unit-deps.ts b/cli/tests/helpers/ports/build-unit-deps.ts index b6a741d30..a228ee980 100644 --- a/cli/tests/helpers/ports/build-unit-deps.ts +++ b/cli/tests/helpers/ports/build-unit-deps.ts @@ -49,6 +49,7 @@ export async function buildUnitDeps(_projectRoot: string) { const hasher = new DeterministicHasher(); const fs = new InMemoryFileAdapter({}, hasher); const manifestRepo = new InMemoryManifestRepository(); + const userManifestRepo = new InMemoryManifestRepository(); const logger = new CLIOutput(false); const assetProvider = new BundledAssetProviderAdapter(); const pluginFetcher = new FixturePluginFetcher(); @@ -92,6 +93,7 @@ export async function buildUnitDeps(_projectRoot: string) { hasher, fs, manifestRepo, + userManifestRepo, logger, assetProvider, pluginFetcher, diff --git a/cli/tests/helpers/ports/fake-native-marketplace-source-reader.ts b/cli/tests/helpers/ports/fake-native-marketplace-source-reader.ts new file mode 100644 index 000000000..5b212925e --- /dev/null +++ b/cli/tests/helpers/ports/fake-native-marketplace-source-reader.ts @@ -0,0 +1,40 @@ +import type { + NativeMarketplaceSource, + NativeMarketplaceSourceReader, + NativeMarketplaceSourceReading, +} from "../../../src/contexts/tools/domain/ports/native-marketplace-source-reader.js"; +import type { FakeNativePluginActivator } from "./fake-native-plugin-activator.js"; + +/** A fake host listing, not an ownership oracle: initial host state and add-result names are explicit. */ +export class FakeNativeMarketplaceSourceReader implements NativeMarketplaceSourceReader { + private readonly entries: Map; + private seenAdds = 0; + + constructor( + private readonly activator: FakeNativePluginActivator, + private readonly kind: "registry" | "effective-list", + private readonly nameForAddedPath: (path: string) => string | undefined, + initial: ReadonlyMap + ) { + this.entries = new Map(initial); + } + + async read(projectRoot: string): Promise { + while (this.seenAdds < this.activator.addedMarketplaces.length) { + const path = this.activator.addedMarketplaces[this.seenAdds++]; + const name = this.nameForAddedPath(path); + if (name === undefined) + return { + location: `fake native host (cwd ${projectRoot})`, + unreadable: `no catalogue identity for added path ${path}`, + }; + this.entries.set( + name, + this.kind === "registry" + ? { kind: "registry", source: path } + : { kind: "effective-list", root: path, sourceType: "local", source: path } + ); + } + return { location: `fake native host (cwd ${projectRoot})`, entries: new Map(this.entries) }; + } +} diff --git a/cli/tests/helpers/ports/fake-native-plugin-activator.ts b/cli/tests/helpers/ports/fake-native-plugin-activator.ts index 93340770a..fca466ea8 100644 --- a/cli/tests/helpers/ports/fake-native-plugin-activator.ts +++ b/cli/tests/helpers/ports/fake-native-plugin-activator.ts @@ -11,6 +11,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { readonly forcedRemovals: boolean[] = []; readonly enabledPlugins: string[] = []; readonly uninstalledPlugins: string[] = []; + readonly updatedPlugins: string[] = []; /** The scope each call actually carried, in call order, never guessed from the ref. */ readonly enabledPluginScopes: MarketplaceScope[] = []; readonly uninstalledPluginScopes: MarketplaceScope[] = []; @@ -21,6 +22,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { private readonly pluginsEnabledHere: boolean; private readonly state: "live" | "dead" | "unknown"; private readonly failOnUninstall: ReadonlySet; + private readonly failOnUpdate: ReadonlySet; private readonly crashOnAddMarketplace: boolean; private readonly crashOnUninstall: boolean; private readonly installedAtScope: ReadonlyMap; @@ -36,6 +38,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { /** What the tool answers about a name already registered. */ registrationState?: "live" | "dead" | "unknown"; failOnUninstall?: readonly string[]; + failOnUpdate?: readonly string[]; crashOnAddMarketplace?: boolean; crashOnUninstall?: boolean; installedAtScope?: ReadonlyMap; @@ -48,6 +51,7 @@ export class FakeNativePluginActivator implements NativePluginActivator { this.pluginsEnabledHere = options.enablesPlugins ?? true; this.state = options.registrationState ?? "unknown"; this.failOnUninstall = new Set(options.failOnUninstall ?? []); + this.failOnUpdate = new Set(options.failOnUpdate ?? []); this.crashOnAddMarketplace = options.crashOnAddMarketplace ?? false; this.crashOnUninstall = options.crashOnUninstall ?? false; this.installedAtScope = options.installedAtScope ?? new Map(); @@ -115,4 +119,10 @@ export class FakeNativePluginActivator implements NativePluginActivator { } this.uninstalledPlugins.push(pluginRef); } + + updatePlugin(pluginRef: string): void { + if (this.failOnUpdate.has(pluginRef)) + throw new NativePluginCliError(`plugin update '${pluginRef}' failed`); + this.updatedPlugins.push(pluginRef); + } } diff --git a/cli/tests/helpers/ports/faulting-file-adapter.ts b/cli/tests/helpers/ports/faulting-file-adapter.ts index 6dafc14e7..a6f6765d0 100644 --- a/cli/tests/helpers/ports/faulting-file-adapter.ts +++ b/cli/tests/helpers/ports/faulting-file-adapter.ts @@ -1,3 +1,4 @@ +import { resolve } from "node:path"; import { InMemoryFileAdapter } from "./in-memory-file-adapter.js"; type FaultableMethod = "readFile" | "realpath" | "listDirectory" | "deleteDirectory"; @@ -40,5 +41,6 @@ export function errnoError(code: string): NodeJS.ErrnoException { } function faultKey(method: FaultableMethod, path: string): string { - return `${method}:${path.replaceAll("\\", "/")}`; + const canonicalPath = method === "realpath" ? resolve(path) : path; + return `${method}:${canonicalPath.replaceAll("\\", "/")}`; } diff --git a/cli/tests/helpers/ports/in-memory-file-adapter.ts b/cli/tests/helpers/ports/in-memory-file-adapter.ts index ab2c8d5d2..c9b8643d2 100644 --- a/cli/tests/helpers/ports/in-memory-file-adapter.ts +++ b/cli/tests/helpers/ports/in-memory-file-adapter.ts @@ -1,4 +1,5 @@ import { createHash } from "node:crypto"; +import { resolve } from "node:path"; import type { FileMerger } from "../../../src/contexts/tools/domain/ports/file-merger.js"; import { FileHash } from "../../../src/kernel/file.js"; import { @@ -99,7 +100,7 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { /** Resolves through the longest matching registered symlink, like a real `fs.realpath` * walking a symlinked ancestor; identity when none was declared for the path. */ async realpath(path: string): Promise { - const key = norm(path); + const key = norm(resolve(path)); let bestMatch: { link: string; target: string } | undefined; for (const [link, target] of this.symlinks) { if (key === link || key.startsWith(`${link}/`)) { @@ -108,14 +109,14 @@ export class InMemoryFileAdapter implements FileReader, FileWriter, FileMerger { } } } - if (bestMatch === undefined) return key; + if (bestMatch === undefined) return norm(path); return bestMatch.target + key.slice(bestMatch.link.length); } /** Test-only: declares that `path` is a symlink resolving to `target`, so a test can * prove `realpath`-based containment without touching a real filesystem. */ setSymlink(path: string, target: string): void { - this.symlinks.set(norm(path), norm(target)); + this.symlinks.set(norm(resolve(path)), norm(target)); } async mergeJsonFile(path: string, content: string, strategy: MergeStrategy): Promise { diff --git a/cli/tests/helpers/ports/realpath-windows.unit.test.ts b/cli/tests/helpers/ports/realpath-windows.unit.test.ts new file mode 100644 index 000000000..f7a21b909 --- /dev/null +++ b/cli/tests/helpers/ports/realpath-windows.unit.test.ts @@ -0,0 +1,35 @@ +import { resolve } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { errnoError, FaultingFileAdapter } from "./faulting-file-adapter.js"; +import { InMemoryFileAdapter } from "./in-memory-file-adapter.js"; + +vi.mock("node:path", async (importOriginal) => { + const path = await importOriginal(); + return { + ...path, + resolve: (...segments: string[]) => path.win32.resolve("C:\\workspace", ...segments), + }; +}); + +describe("Windows realpath fixtures", () => { + it("resolves a rooted fixture's symlink after the caller adds the current drive", async () => { + const fs = new InMemoryFileAdapter(); + fs.setSymlink("/project/link", "/foreign"); + + expect(await fs.realpath(resolve("/project/link/file"))).toBe("/foreign/file"); + }); + + it("keeps unsymlinked virtual path identities stable for seeded project claims", async () => { + const fs = new InMemoryFileAdapter(); + + expect(await fs.realpath("/project/file")).toBe("/project/file"); + }); + + it("preserves a registered realpath fault when the caller resolves the fixture path", async () => { + const fs = new FaultingFileAdapter(); + const error = errnoError("ENOENT"); + fs.failOn("realpath", "/project/missing", error); + + await expect(fs.realpath(resolve("/project/missing"))).rejects.toBe(error); + }); +}); diff --git a/cli/tests/kernel/paths.unit.test.ts b/cli/tests/kernel/paths.unit.test.ts index af7ec81ca..6911a0bf6 100644 --- a/cli/tests/kernel/paths.unit.test.ts +++ b/cli/tests/kernel/paths.unit.test.ts @@ -4,6 +4,7 @@ import { parseBuiltMarketplaceDir, parseBuiltMarketplaceDirAtAnyRoot, parseUserBuiltMarketplaceDir, + samePath, samePathSegment, } from "../../src/kernel/paths.js"; @@ -68,3 +69,22 @@ describe("samePathSegment()", () => { expect(samePathSegment("alpha", "beta", "win32")).toBe(false); }); }); + +describe("samePath()", () => { + it("calls a realpath answer and a stored path the same directory when only separators differ", () => { + expect(samePath("/user-cache/built/1.0.0", "\\user-cache\\built\\1.0.0", "win32")).toBe(true); + }); + + it("still tells two different directories apart", () => { + expect(samePath("/user-cache/built/1.0.0", "\\user-cache\\built\\2.0.0", "win32")).toBe(false); + }); + + it("folds case where the filesystem does, and nowhere else", () => { + expect(samePath("/Cache/Built", "/cache/built", "win32")).toBe(true); + expect(samePath("/Cache/Built", "/cache/built", "linux")).toBe(false); + }); + + it("leaves a posix path that legitimately holds a backslash alone on posix", () => { + expect(samePath("/odd/a", "/odd\\a", "linux")).toBe(false); + }); +}); diff --git a/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts b/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts index 6491de134..63b5de066 100644 --- a/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/marketplace-wiring.integration.test.ts @@ -5,6 +5,7 @@ import { parsePluginSourceShorthand } from "../../../src/kernel/source.js"; const marketplaceAdd = vi.fn(); const marketplaceList = vi.fn(); const marketplaceRemove = vi.fn(); +const userMarketplaceRemove = vi.fn(); const marketplaceRefresh = vi.fn(); const marketplaceCheck = vi.fn(); const activation = vi.fn(); @@ -17,6 +18,7 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ marketplaceAddUseCase: { execute: marketplaceAdd }, marketplaceListUseCase: { execute: marketplaceList }, marketplaceRemoveUseCase: { execute: marketplaceRemove }, + userMarketplaceRemoveUseCase: { execute: userMarketplaceRemove }, marketplaceRefreshUseCase: { execute: marketplaceRefresh }, marketplaceCheckUseCase: { execute: marketplaceCheck }, marketplaceSyncSettingsUseCase: { execute: activation }, @@ -62,6 +64,10 @@ beforeEach(() => { marketplace: { name: "market-b" }, removedPluginCount: 2, }); + userMarketplaceRemove.mockResolvedValue({ + marketplace: { name: "market-b" }, + removedPluginCount: 1, + }); marketplaceRefresh.mockResolvedValue({ results: [{ name: "market-b", status: "refreshed" }], failedCount: 0, @@ -265,6 +271,31 @@ describe("aidd marketplace — a failed read or removal", () => { }); describe("aidd marketplace remove", () => { + it("routes explicit user removal to machine ownership without re-driving project activation", async () => { + expect(await run("remove", "market-b", "--scope", "user")).toEqual([ + "Marketplace 'market-b' removed (1 plugin(s) cleaned up).", + ]); + expect(userMarketplaceRemove).toHaveBeenCalledWith({ + name: "market-b", + projectRoot: PROJECT_ROOT, + autoConfirm: false, + scope: "user", + }); + expect(marketplaceRemove).not.toHaveBeenCalled(); + expect(activation).not.toHaveBeenCalled(); + }); + + it("refuses an invalid removal scope before wiring or mutation", async () => { + vi.spyOn(process, "exit").mockImplementation(() => { + throw new Error("exited"); + }); + await expect(run("remove", "market-b", "--scope", "machine")).rejects.toThrow("exited"); + expect(errors.join("")).toBe("Error: Invalid scope 'machine'. Expected 'project' or 'user'.\n"); + expect(vi.mocked(createDeps)).not.toHaveBeenCalled(); + expect(marketplaceRemove).not.toHaveBeenCalled(); + expect(userMarketplaceRemove).not.toHaveBeenCalled(); + }); + it("removes the named one, re-drives every activation, and counts what went with it", async () => { expect(await run("remove", "market-b")).toEqual([ "Marketplace 'market-b' removed (2 plugin(s) cleaned up).", @@ -273,6 +304,7 @@ describe("aidd marketplace remove", () => { name: "market-b", projectRoot: PROJECT_ROOT, autoConfirm: false, + scope: "project", }); expect(activation).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, @@ -409,7 +441,10 @@ describe("aidd marketplace — the help surface", () => { expect(optionsOf("list")).toEqual([ ["--plugins", "Also fetch and print all plugins from each marketplace catalog", undefined], ]); - expect(optionsOf("remove")).toEqual([["--yes", "Skip the orphan-cleanup prompt", undefined]]); + expect(optionsOf("remove")).toEqual([ + ["--yes", "Skip the orphan-cleanup prompt", undefined], + ["--scope ", "Remove from project or user scope", "project"], + ]); expect(optionsOf("refresh")).toEqual([ ["--force", "Clear cache before re-fetching", undefined], ]); diff --git a/cli/tests/presentation/commands/plugin-wiring.integration.test.ts b/cli/tests/presentation/commands/plugin-wiring.integration.test.ts index 467c2ea4b..23d7deb45 100644 --- a/cli/tests/presentation/commands/plugin-wiring.integration.test.ts +++ b/cli/tests/presentation/commands/plugin-wiring.integration.test.ts @@ -7,6 +7,7 @@ const pluginList = vi.fn(); const pluginInstall = vi.fn(); const pluginSearch = vi.fn(); const pluginUpdate = vi.fn(); +const userPluginUpdate = vi.fn(); const activation = vi.fn(); const menuSelect = vi.fn(); const spawn = vi.fn(); @@ -18,6 +19,7 @@ vi.mock("../../../src/runtime/wiring/framework.js", () => ({ pluginInstallUseCase: { execute: pluginInstall }, pluginSearchUseCase: { execute: pluginSearch }, pluginUpdateUseCase: { execute: pluginUpdate }, + userPluginUpdateUseCase: { execute: userPluginUpdate }, marketplaceSyncSettingsUseCase: { execute: activation }, })), createMenuDeps: vi.fn(() => ({ prompter: { select: menuSelect } })), @@ -57,6 +59,7 @@ beforeEach(() => { pluginInstall.mockResolvedValue({ kind: "marketplace", installed: ["aidd-dev"] }); pluginSearch.mockResolvedValue({ hits: [] }); pluginUpdate.mockResolvedValue(["aidd-dev"]); + userPluginUpdate.mockResolvedValue(["aidd-vcs@aidd-framework"]); activation.mockResolvedValue({ binaryMissing: [], errors: [] }); menuSelect.mockResolvedValue("list"); spawn.mockResolvedValue(0); @@ -134,6 +137,7 @@ describe("aidd plugin remove", () => { pluginName: "aidd-dev", toolIds: "all", projectRoot: PROJECT_ROOT, + scope: "project", }); expect(activation).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, @@ -267,12 +271,26 @@ describe("aidd plugin search", () => { }); describe("aidd plugin update", () => { + it.each(["copilot", "cursor"])("routes %s --scope user to machine ownership", async (toolId) => { + expect(await run("update", "aidd-vcs", "--tool", toolId, "--scope", "user")).toEqual([ + "Updated: aidd-vcs@aidd-framework.", + ]); + expect(userPluginUpdate).toHaveBeenCalledWith({ + pluginNames: ["aidd-vcs"], + toolIds: [toolId], + projectRoot: PROJECT_ROOT, + scope: "user", + }); + expect(pluginUpdate).not.toHaveBeenCalled(); + }); + it("sweeps every plugin when none was named, and lists what moved", async () => { expect(await run("update")).toEqual(["Updated: aidd-dev."]); expect(pluginUpdate).toHaveBeenCalledWith({ pluginNames: undefined, toolIds: "all", projectRoot: PROJECT_ROOT, + scope: "project", }); expect(activation).toHaveBeenCalledWith({ projectRoot: PROJECT_ROOT, @@ -360,9 +378,15 @@ describe("aidd plugin — the help surface", () => { ["--tool ", "Target AI tool (default: all installed)"], ]; - expect(optionsOf("remove")).toEqual(tool); + expect(optionsOf("remove")).toEqual([ + ...tool, + ["--scope ", "Removal scope (default: project)"], + ]); expect(optionsOf("list")).toEqual(tool); - expect(optionsOf("update")).toEqual(tool); + expect(optionsOf("update")).toEqual([ + ...tool, + ["--scope ", "Update scope (default: project)"], + ]); }); it("says what install may be told about the source, the scope and the prompts", () => { diff --git a/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts b/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts index 38642d781..b95714583 100644 --- a/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts +++ b/cli/tests/presentation/prompts/plugin-pick-use-case.unit.test.ts @@ -62,7 +62,8 @@ async function buildUseCase(prompter: Prompter = new KeepPrompter()) { deps.hasher, deps.logger, registry, - fakeEnsureBuiltMarketplace() + fakeEnsureBuiltMarketplace(), + deps.userManifestRepo ); const fetchMarketplaceSource = new FetchMarketplaceSourceUseCase(deps.pluginFetcher); const resolveMarketplace = new ResolveMarketplaceUseCase(