@gajae-code/coding-agent 0.16.7 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (321) hide show
  1. package/CHANGELOG.md +180 -1
  2. package/dist/types/capability/fs.d.ts +37 -4
  3. package/dist/types/capability/index.d.ts +19 -0
  4. package/dist/types/capability/types.d.ts +9 -0
  5. package/dist/types/cli/doctor/args.d.ts +36 -0
  6. package/dist/types/cli/doctor/checks.d.ts +18 -0
  7. package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
  8. package/dist/types/cli/doctor/context.d.ts +89 -0
  9. package/dist/types/cli/doctor/files.d.ts +27 -0
  10. package/dist/types/cli/doctor/ids.d.ts +20 -0
  11. package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
  12. package/dist/types/cli/doctor/journal.d.ts +43 -0
  13. package/dist/types/cli/doctor/managed-link.d.ts +39 -0
  14. package/dist/types/cli/doctor/native-probe.d.ts +2 -0
  15. package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
  16. package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
  17. package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
  18. package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
  19. package/dist/types/cli/doctor/probe-client.d.ts +9 -0
  20. package/dist/types/cli/doctor/probe-types.d.ts +18 -0
  21. package/dist/types/cli/doctor/probe.d.ts +3 -0
  22. package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
  23. package/dist/types/cli/doctor/repairs.d.ts +28 -0
  24. package/dist/types/cli/doctor/report.d.ts +27 -0
  25. package/dist/types/cli/doctor/runner.d.ts +4 -0
  26. package/dist/types/cli/doctor/safety.d.ts +10 -0
  27. package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
  28. package/dist/types/cli/doctor/service-targets.d.ts +41 -0
  29. package/dist/types/cli/doctor/types.d.ts +145 -0
  30. package/dist/types/cli/doctor-cli.d.ts +7 -0
  31. package/dist/types/cli/doctor-supervisor.d.ts +121 -0
  32. package/dist/types/cli/doctor-worker.d.ts +29 -0
  33. package/dist/types/cli/install-activation.d.ts +158 -0
  34. package/dist/types/cli/macos-community-app.d.ts +41 -0
  35. package/dist/types/cli/mcp-cli.d.ts +0 -11
  36. package/dist/types/cli/update-cli.d.ts +41 -4
  37. package/dist/types/cli-main.d.ts +174 -0
  38. package/dist/types/cli.d.ts +1 -171
  39. package/dist/types/commands/doctor.d.ts +7 -0
  40. package/dist/types/commands/sdk.d.ts +17 -8
  41. package/dist/types/config/autorouting-tier-map.d.ts +19 -0
  42. package/dist/types/config/file-lock.d.ts +27 -3
  43. package/dist/types/config/model-preset-registry.d.ts +1 -0
  44. package/dist/types/config/model-registry.d.ts +12 -5
  45. package/dist/types/config/model-resolver.d.ts +16 -3
  46. package/dist/types/config/models-config-schema.d.ts +2 -0
  47. package/dist/types/config/settings-schema.d.ts +1 -1
  48. package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
  49. package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
  50. package/dist/types/cursor.d.ts +9 -9
  51. package/dist/types/discovery/agents-md.d.ts +6 -1
  52. package/dist/types/discovery/helpers.d.ts +36 -7
  53. package/dist/types/discovery/index.d.ts +1 -1
  54. package/dist/types/discovery/mcp-json.d.ts +2 -0
  55. package/dist/types/extensibility/extensions/loader.d.ts +22 -1
  56. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
  57. package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
  58. package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
  59. package/dist/types/extensibility/plugins/manager.d.ts +33 -4
  60. package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
  61. package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
  62. package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
  63. package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
  64. package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
  65. package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
  66. package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
  67. package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
  68. package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
  69. package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
  70. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
  71. package/dist/types/main.d.ts +19 -1
  72. package/dist/types/modes/acp/acp-agent.d.ts +40 -0
  73. package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
  74. package/dist/types/modes/components/model-selector.d.ts +2 -0
  75. package/dist/types/modes/interactive-mode.d.ts +1 -1
  76. package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
  77. package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
  78. package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
  79. package/dist/types/runtime-mcp/types.d.ts +2 -2
  80. package/dist/types/sdk/acp/adapter.d.ts +5 -1
  81. package/dist/types/sdk/broker/broker.d.ts +51 -0
  82. package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
  83. package/dist/types/sdk/broker/discovery.d.ts +38 -0
  84. package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
  85. package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
  86. package/dist/types/sdk/broker/ensure.d.ts +14 -0
  87. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  88. package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
  89. package/dist/types/sdk/broker/runtime.d.ts +26 -0
  90. package/dist/types/sdk/broker/session-index.d.ts +13 -4
  91. package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
  92. package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
  93. package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
  94. package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
  95. package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
  96. package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
  97. package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
  98. package/dist/types/sdk/bus/index.d.ts +2 -0
  99. package/dist/types/sdk/bus/notification-service.d.ts +3 -0
  100. package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
  101. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
  102. package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
  103. package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
  104. package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
  105. package/dist/types/sdk/cli/session-cli.d.ts +1 -0
  106. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  107. package/dist/types/sdk/host/surface-policy.d.ts +3 -1
  108. package/dist/types/sdk/lifecycle/service.d.ts +44 -10
  109. package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
  110. package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
  111. package/dist/types/sdk/prompt-failure.d.ts +47 -0
  112. package/dist/types/sdk/prompt-status.d.ts +19 -0
  113. package/dist/types/sdk/router/session-router.d.ts +3 -4
  114. package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
  115. package/dist/types/sdk/session-authority.d.ts +9 -0
  116. package/dist/types/sdk/session.d.ts +3 -1
  117. package/dist/types/session/agent-session.d.ts +1 -1
  118. package/dist/types/session/blob-store.d.ts +1 -1
  119. package/dist/types/session/fallback-chain-controller.d.ts +3 -6
  120. package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
  121. package/dist/types/session/primary-control-surface.d.ts +4 -0
  122. package/dist/types/session/session-manager.d.ts +6 -0
  123. package/dist/types/system-prompt.d.ts +6 -0
  124. package/dist/types/tools/ask.d.ts +25 -0
  125. package/dist/types/tools/image-gen.d.ts +0 -4
  126. package/dist/types/tools/skill-discovery.d.ts +7 -5
  127. package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
  128. package/dist/types/utils/changelog.d.ts +0 -9
  129. package/dist/types/utils/mupdf-wasm.d.ts +1 -0
  130. package/package.json +9 -8
  131. package/scripts/compile-args.ts +8 -4
  132. package/src/capability/fs.ts +395 -17
  133. package/src/capability/index.ts +273 -10
  134. package/src/capability/types.ts +9 -0
  135. package/src/cli/customize-doctor.ts +18 -12
  136. package/src/cli/doctor/args.ts +236 -0
  137. package/src/cli/doctor/checks.ts +431 -0
  138. package/src/cli/doctor/config-repairs.ts +517 -0
  139. package/src/cli/doctor/context.ts +149 -0
  140. package/src/cli/doctor/files.ts +149 -0
  141. package/src/cli/doctor/ids.ts +115 -0
  142. package/src/cli/doctor/install-repairs.ts +335 -0
  143. package/src/cli/doctor/journal.ts +423 -0
  144. package/src/cli/doctor/managed-link.ts +514 -0
  145. package/src/cli/doctor/native-probe.ts +13 -0
  146. package/src/cli/doctor/permission-repairs.ts +242 -0
  147. package/src/cli/doctor/plugin-quarantine.ts +377 -0
  148. package/src/cli/doctor/plugin-restore.ts +59 -0
  149. package/src/cli/doctor/plugin-targets.ts +373 -0
  150. package/src/cli/doctor/probe-client.ts +195 -0
  151. package/src/cli/doctor/probe-types.ts +21 -0
  152. package/src/cli/doctor/probe.ts +42 -0
  153. package/src/cli/doctor/projection-probe.ts +249 -0
  154. package/src/cli/doctor/repairs.ts +1157 -0
  155. package/src/cli/doctor/report.ts +176 -0
  156. package/src/cli/doctor/runner.ts +342 -0
  157. package/src/cli/doctor/safety.ts +40 -0
  158. package/src/cli/doctor/service-repairs.ts +452 -0
  159. package/src/cli/doctor/service-targets.ts +274 -0
  160. package/src/cli/doctor/types.ts +164 -0
  161. package/src/cli/doctor-cli.ts +62 -0
  162. package/src/cli/doctor-supervisor.ts +571 -0
  163. package/src/cli/doctor-worker.ts +181 -0
  164. package/src/cli/install-activation.ts +666 -0
  165. package/src/cli/macos-community-app.ts +1449 -0
  166. package/src/cli/mcp-cli.ts +11 -21
  167. package/src/cli/nofile-limit.ts +5 -1
  168. package/src/cli/plugin-cli.ts +6 -1
  169. package/src/cli/update-cli.ts +362 -264
  170. package/src/cli-main.ts +517 -0
  171. package/src/cli.ts +64 -433
  172. package/src/commands/autoresearch.ts +11 -5
  173. package/src/commands/completion.ts +1 -1
  174. package/src/commands/doctor.ts +13 -0
  175. package/src/commands/sdk.ts +58 -10
  176. package/src/commands/ultragoal.ts +11 -5
  177. package/src/config/autorouting-tier-map.ts +7 -0
  178. package/src/config/file-lock.ts +736 -71
  179. package/src/config/mcp-schema.json +3 -3
  180. package/src/config/model-preset-registry.ts +1 -0
  181. package/src/config/model-registry.ts +80 -27
  182. package/src/config/model-resolver.ts +33 -4
  183. package/src/config/models-config-schema.ts +2 -0
  184. package/src/config/settings-schema.ts +6 -2
  185. package/src/coordinator-mcp/question-gate-codec.ts +1 -0
  186. package/src/coordinator-mcp/server.ts +534 -51
  187. package/src/coordinator-mcp/session-reaper.ts +64 -8
  188. package/src/cursor.ts +223 -53
  189. package/src/discovery/agents-md.ts +74 -8
  190. package/src/discovery/agents.ts +32 -7
  191. package/src/discovery/builtin.ts +184 -75
  192. package/src/discovery/claude-plugins.ts +85 -17
  193. package/src/discovery/cline.ts +38 -10
  194. package/src/discovery/cursor.ts +19 -6
  195. package/src/discovery/gemini.ts +62 -22
  196. package/src/discovery/github.ts +13 -3
  197. package/src/discovery/helpers.ts +483 -89
  198. package/src/discovery/index.ts +1 -0
  199. package/src/discovery/mcp-json.ts +36 -10
  200. package/src/discovery/opencode.ts +36 -19
  201. package/src/discovery/ssh.ts +11 -8
  202. package/src/discovery/windsurf.ts +12 -3
  203. package/src/exec/bash-executor.ts +10 -3
  204. package/src/exec/isolated-shell.ts +15 -2
  205. package/src/extensibility/extensions/loader.ts +88 -26
  206. package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
  207. package/src/extensibility/gjc-plugins/paths.ts +4 -1
  208. package/src/extensibility/gjc-plugins/types.ts +27 -0
  209. package/src/extensibility/plugins/manager.ts +200 -22
  210. package/src/extensibility/plugins/marketplace/cache.ts +55 -2
  211. package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
  212. package/src/extensibility/plugins/marketplace/registry.ts +153 -0
  213. package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
  214. package/src/extensibility/runtime-skill-discovery.ts +61 -9
  215. package/src/extensibility/skill-management.ts +7 -1
  216. package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
  217. package/src/gjc-runtime/launch-worktree.test.ts +248 -0
  218. package/src/gjc-runtime/launch-worktree.ts +275 -38
  219. package/src/gjc-runtime/repository-binding.ts +26 -1
  220. package/src/gjc-runtime/session-layout.ts +10 -0
  221. package/src/gjc-runtime/session-state-lock.ts +330 -23
  222. package/src/gjc-runtime/session-state-sidecar.ts +328 -80
  223. package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
  224. package/src/harness-control-plane/session-lease.ts +2 -2
  225. package/src/internal-urls/docs-index.generated.ts +28 -26
  226. package/src/main.ts +150 -29
  227. package/src/memories/index.ts +24 -2
  228. package/src/modes/acp/acp-agent.ts +522 -68
  229. package/src/modes/components/agent-dashboard.ts +3 -1
  230. package/src/modes/components/model-selector.ts +28 -19
  231. package/src/modes/components/plugin-settings.ts +2 -1
  232. package/src/modes/controllers/selector-controller.ts +15 -9
  233. package/src/modes/interactive-mode.ts +21 -3
  234. package/src/prompts/system/system-prompt.md +5 -1
  235. package/src/prompts/tools/browser.md +1 -1
  236. package/src/prompts/tools/move-session.md +4 -0
  237. package/src/prompts/tools/read.md +2 -1
  238. package/src/prompts/tools/skill-discovery.md +6 -5
  239. package/src/prompts/tools/task.md +1 -0
  240. package/src/runtime-mcp/config-writer.ts +228 -0
  241. package/src/runtime-mcp/oauth-discovery.ts +114 -14
  242. package/src/runtime-mcp/policy-json-edit.ts +129 -0
  243. package/src/runtime-mcp/startup-policy.ts +14 -0
  244. package/src/runtime-mcp/types.ts +2 -2
  245. package/src/sdk/acp/adapter.ts +27 -12
  246. package/src/sdk/broker/broker.ts +273 -44
  247. package/src/sdk/broker/daemon-entry.ts +119 -0
  248. package/src/sdk/broker/discovery.ts +173 -20
  249. package/src/sdk/broker/doctor-restart.ts +206 -0
  250. package/src/sdk/broker/endpoint-authority.ts +36 -0
  251. package/src/sdk/broker/ensure.ts +49 -14
  252. package/src/sdk/broker/lifecycle.ts +174 -42
  253. package/src/sdk/broker/process-incarnation.ts +86 -2
  254. package/src/sdk/broker/runtime.ts +145 -21
  255. package/src/sdk/broker/session-index.ts +136 -22
  256. package/src/sdk/broker/spawn-substrate.ts +4 -1
  257. package/src/sdk/broker/transport.ts +5 -0
  258. package/src/sdk/broker/worktree-occupancy.ts +21 -1
  259. package/src/sdk/bus/chat-daemon-cli.ts +63 -0
  260. package/src/sdk/bus/chat-daemon-control.ts +260 -64
  261. package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
  262. package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
  263. package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
  264. package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
  265. package/src/sdk/bus/index.ts +252 -51
  266. package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
  267. package/src/sdk/bus/notification-service.ts +21 -3
  268. package/src/sdk/bus/reconciliation-store.ts +17 -11
  269. package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
  270. package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
  271. package/src/sdk/bus/telegram-daemon-control.ts +47 -0
  272. package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
  273. package/src/sdk/bus/telegram-daemon.ts +178 -13
  274. package/src/sdk/cli/session-cli.ts +100 -12
  275. package/src/sdk/host/host.ts +2 -7
  276. package/src/sdk/host/session-runtime.test.ts +329 -8
  277. package/src/sdk/host/session-runtime.ts +202 -50
  278. package/src/sdk/host/surface-policy.ts +8 -1
  279. package/src/sdk/lifecycle/service.ts +199 -22
  280. package/src/sdk/mcp/server.ts +1 -1
  281. package/src/sdk/prompt-deadline-lease.ts +12 -4
  282. package/src/sdk/prompt-deadline-manager.ts +5 -7
  283. package/src/sdk/prompt-failure.ts +148 -0
  284. package/src/sdk/prompt-status.ts +30 -1
  285. package/src/sdk/prompt-watchdog.ts +11 -3
  286. package/src/sdk/protocol/operation-inventory.generated.json +18 -0
  287. package/src/sdk/protocol/operation-registry.ts +20 -8
  288. package/src/sdk/router/session-router.ts +8 -3
  289. package/src/sdk/service-artifact-paths.ts +26 -0
  290. package/src/sdk/session-authority.ts +10 -0
  291. package/src/sdk/session.ts +361 -194
  292. package/src/session/agent-session.ts +398 -93
  293. package/src/session/blob-store.ts +9 -9
  294. package/src/session/fallback-chain-controller.ts +3 -7
  295. package/src/session/internal/managed-session-scope.ts +445 -29
  296. package/src/session/internal/managed-session-storage.ts +38 -6
  297. package/src/session/primary-control-surface.ts +12 -0
  298. package/src/session/session-manager.ts +11 -3
  299. package/src/session/session-storage.ts +1 -1
  300. package/src/session-import/codex.ts +6 -1
  301. package/src/session-import/redact.ts +7 -0
  302. package/src/skill-state/workflow-mutation-guard.ts +645 -39
  303. package/src/slash-commands/builtin-registry.ts +7 -3
  304. package/src/system-prompt.ts +8 -0
  305. package/src/task/executor.ts +36 -3
  306. package/src/task/types.ts +27 -5
  307. package/src/tools/ask.ts +114 -34
  308. package/src/tools/browser/actions.ts +2 -2
  309. package/src/tools/browser/tab-worker.ts +11 -8
  310. package/src/tools/descriptors.ts +6 -1
  311. package/src/tools/fetch.ts +7 -2
  312. package/src/tools/image-gen.ts +33 -34
  313. package/src/tools/read.ts +78 -32
  314. package/src/tools/skill-discovery.ts +17 -7
  315. package/src/tools/tool-catalog.generated.ts +8 -8
  316. package/src/tools/ultragoal-ask-guard.ts +16 -26
  317. package/src/utils/changelog.ts +7 -1
  318. package/src/utils/git.ts +3 -1
  319. package/src/utils/markit.ts +24 -2
  320. package/src/utils/mupdf-wasm.ts +26 -0
  321. package/src/web/search/providers/openai-compatible.ts +191 -14
@@ -6,13 +6,20 @@
6
6
  * the real `clearAnthropic modelPluginRootsCache` while tests supply a counter stub.
7
7
  */
8
8
 
9
+ import { createHash, randomBytes } from "node:crypto";
9
10
  import * as fs from "node:fs/promises";
10
11
  import * as os from "node:os";
11
12
  import * as path from "node:path";
12
-
13
+ import {
14
+ exactRemoveDirectoryTree,
15
+ type NativeDirectoryTreeSnapshot,
16
+ type NativeExactUnlinkResult,
17
+ renameDirectoryNoReplacePathAsync,
18
+ snapshotDirectoryTree,
19
+ } from "@gajae-code/natives";
13
20
  import { isEnoent, logger, pathIsWithin } from "@gajae-code/utils";
14
-
15
- import { cachePlugin, getCachedPluginPath } from "./cache";
21
+ import { withFileLock } from "../../../config/file-lock";
22
+ import { cachePlugin, getCachedPluginPath, inspectCachedPlugin, stageCachedPlugin } from "./cache";
16
23
  import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
17
24
  import {
18
25
  addInstalledPlugin,
@@ -26,7 +33,7 @@ import {
26
33
  writeInstalledPluginsRegistry,
27
34
  writeMarketplacesRegistry,
28
35
  } from "./registry";
29
- import { resolvePluginSource } from "./source-resolver";
36
+ import { assertPinnedSource, resolvePluginSource, sourcePin, verifyResolvedProvenance } from "./source-resolver";
30
37
  import type {
31
38
  InstalledPluginEntry,
32
39
  InstalledPluginSummary,
@@ -54,8 +61,284 @@ export interface MarketplaceManagerOptions {
54
61
  * Receives any additional file paths that should also be invalidated from the fs cache.
55
62
  */
56
63
  clearPluginRootsCache?: (extraPaths?: readonly string[]) => void;
64
+ /**
65
+ * Test seam ONLY: injected immediately after {@link MarketplaceManager.applyPluginRestore}
66
+ * renames the staged candidate onto `finalPath` and captures its own
67
+ * published identity, before any further verification. Production callers
68
+ * never set this — default behavior is a no-op. Exists so a test can
69
+ * deterministically simulate a concurrent second writer republishing
70
+ * `finalPath` with a different inode inside the real race window, to
71
+ * exercise the `foreign_owner_conflict` rollback branch without a second
72
+ * process. Never used to bypass or weaken the identity re-check itself.
73
+ */
74
+ afterRestorePublish?: (finalPath: string) => Promise<void>;
75
+ }
76
+
77
+ export type MarketplaceRestoreRiskClass = "plugin-change" | "install-replace" | "network" | "external-execution";
78
+
79
+ /**
80
+ * Pure, read-only restoration descriptor. Every field is an observation of
81
+ * already-stored registry/catalog/local-artifact state; nothing here resolves
82
+ * a source, touches the network, or stages anything. `installPath` names the
83
+ * artifact this plan is ABOUT (the thing that would be repaired), never a
84
+ * staged candidate — the plan carries no writable authority.
85
+ */
86
+ export interface MarketplaceRestorePlanV1 {
87
+ schemaVersion: 1;
88
+ kind: "marketplace-plugin.restore-artifact";
89
+ pluginId: string;
90
+ scope: "user" | "project";
91
+ version: string;
92
+ installPath: string;
93
+ enabled: boolean;
94
+ /** Pinned SHA already recorded for this install, when known. */
95
+ gitCommitSha?: string;
96
+ /** Covers every entry recorded for this pluginId (all scopes) plus the local artifact observation. */
97
+ baselineFingerprint: string;
98
+ catalogFingerprint: string;
99
+ source: MarketplacePluginEntry["source"];
100
+ artifact: { status: "present" | "absent" | "unreadable"; digest?: string };
101
+ riskClasses: readonly MarketplaceRestoreRiskClass[];
102
+ writes: false;
103
+ fetch: false;
104
+ execute: false;
105
+ }
106
+
107
+ /**
108
+ * Materialized after explicit risk authorization: a verified, staged,
109
+ * pinned-SHA candidate bound to the exact baseline/catalog it was reviewed
110
+ * against. `originalInstallPath` (what apply repairs) and `stagedArtifactPath`
111
+ * (the verified candidate bytes) are always distinct paths — apply never
112
+ * confuses the target it is replacing with the replacement.
113
+ */
114
+ export interface MarketplaceReviewedRestoreTokenV1 {
115
+ schemaVersion: 1;
116
+ kind: "marketplace-plugin.restore-artifact";
117
+ purpose: "restore-artifact";
118
+ pluginId: string;
119
+ scope: "user" | "project";
120
+ version: string;
121
+ originalInstallPath: string;
122
+ stagedArtifactPath: string;
123
+ enabled: boolean;
124
+ pinnedSha: string;
125
+ baselineFingerprint: string;
126
+ catalogFingerprint: string;
127
+ candidateFingerprint: string;
128
+ candidateArtifact: { status: "present"; digest: string };
129
+ riskClasses: readonly MarketplaceRestoreRiskClass[];
130
+ reviewedAt: string;
131
+ }
132
+
133
+ function fingerprint(value: unknown): string {
134
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex");
135
+ }
136
+
137
+ /**
138
+ * Snapshot a directory tree for {@link exactRemoveDirectoryTree}, or return
139
+ * `undefined` if nothing exists there (native snapshot failure for a missing
140
+ * path is expected, not an error).
141
+ */
142
+ /**
143
+ * `exactRemoveDirectoryTree` reports a verified detach as `cleanup_pending` with
144
+ * `payloadDurable: true` and the detached path: the tree was atomically moved out
145
+ * of the canonical name and its bytes are durable, only the final unlink of that
146
+ * detached copy is outstanding. Treating that as failure would refuse a mutation
147
+ * that already happened, so detachment is judged by the detached path plus
148
+ * durability, never by `ok` alone.
149
+ */
150
+ function detachedTree(result: NativeExactUnlinkResult): string | undefined {
151
+ if (result.ok && result.detachedPath) return result.detachedPath;
152
+ return result.code === "cleanup_pending" &&
153
+ result.payloadDurable === true &&
154
+ result.detachedPath &&
155
+ !result.retainedUnknownPath
156
+ ? result.detachedPath
157
+ : undefined;
158
+ }
159
+
160
+ /** True when the canonical name is vacated, whether or not a detached copy still awaits cleanup. */
161
+ function treeRemoved(result: NativeExactUnlinkResult): boolean {
162
+ return result.ok || detachedTree(result) !== undefined;
57
163
  }
58
164
 
165
+ async function snapshotIfPresent(targetPath: string): Promise<NativeDirectoryTreeSnapshot | undefined> {
166
+ const stat = await fs.lstat(targetPath).catch(() => undefined);
167
+ if (!stat || stat.isSymbolicLink() || !stat.isDirectory()) return undefined;
168
+ const snapshot = snapshotDirectoryTree(targetPath);
169
+ return snapshot.ok ? snapshot.snapshot : undefined;
170
+ }
171
+
172
+ /**
173
+ * Full identity comparison between two directory tree snapshots. A same-
174
+ * filesystem `renameat`-class namespace operation changes neither the root's
175
+ * nor any entry's dev/ino/nlink — the inode identity travels with the rename,
176
+ * only its containing-directory pathname changes — so the snapshot captured
177
+ * BEFORE the publish rename remains exact identity evidence for what the
178
+ * rename just published. Used here to CONFIRM that expectation holds (an
179
+ * unexpected mismatch means a third party touched finalPath in the interim),
180
+ * never as a substitute for the native exactRemoveDirectoryTree identity check
181
+ * itself, which independently re-validates the same snapshot at mutation time.
182
+ */
183
+ function sameDirectoryTreeIdentity(
184
+ expected: NativeDirectoryTreeSnapshot,
185
+ actual: NativeDirectoryTreeSnapshot,
186
+ ): boolean {
187
+ if (expected.rootDev !== actual.rootDev || expected.rootIno !== actual.rootIno) return false;
188
+ if (expected.entries.length !== actual.entries.length) return false;
189
+ const byPath = new Map(actual.entries.map(entry => [entry.relativePath, entry]));
190
+ return expected.entries.every(entry => {
191
+ const match = byPath.get(entry.relativePath);
192
+ return (
193
+ match !== undefined &&
194
+ match.kind === entry.kind &&
195
+ match.dev === entry.dev &&
196
+ match.ino === entry.ino &&
197
+ match.nlink === entry.nlink &&
198
+ match.size === entry.size &&
199
+ match.sha256 === entry.sha256
200
+ );
201
+ });
202
+ }
203
+
204
+ /**
205
+ * `refused` — nothing was mutated; safe to retry after fixing the cause.
206
+ * `conflict` — a filesystem mutation was attempted and then fully, verifiably
207
+ * rolled back to OUR exact prior artifact; the installed state is unchanged
208
+ * from before this apply ran.
209
+ * `foreign_owner_conflict` — a filesystem mutation was attempted, but the
210
+ * publish target now belongs to a different, concurrently-completed owner.
211
+ * Neither the foreign publication nor our own retained backup is touched;
212
+ * `retainedBackupPath` on the error names exactly where our pre-swap artifact
213
+ * is preserved for inspection/manual reconciliation.
214
+ * `uncertain` — a filesystem mutation was attempted and rollback to OUR prior
215
+ * artifact could not be verified; the installed state may not match either
216
+ * the old or new artifact and requires manual inspection.
217
+ * A caller (doctor journal) MUST record all four distinctly and MUST NOT
218
+ * treat any of the non-`refused` outcomes as a clean no-op.
219
+ */
220
+ export type MarketplaceRestoreOutcome = "refused" | "conflict" | "foreign_owner_conflict" | "uncertain";
221
+
222
+ /**
223
+ * Typed refusal/failure for the restore lane. Every throw site in
224
+ * previewPluginRestore/authorizePluginRestore/applyPluginRestore uses this
225
+ * class (never a bare `Error`) so a caller (the doctor journal) can
226
+ * distinguish a clean pre-mutation refusal from a filesystem mutation that
227
+ * was attempted and either rolled back, lost to a foreign owner, or left
228
+ * uncertain.
229
+ */
230
+ export class MarketplaceRestoreError extends Error {
231
+ readonly reasonCode: MarketplaceRestoreReasonCode;
232
+ readonly sideEffectStarted: boolean;
233
+ readonly outcome: MarketplaceRestoreOutcome;
234
+ /** Set whenever a pre-swap backup survives this call untouched: `foreign_owner_conflict` and every `uncertain` outcome that retains it. */
235
+ readonly retainedBackupPath?: string;
236
+ /**
237
+ * Private, non-reported free-text context (e.g. a wrapped underlying error
238
+ * message). NEVER surfaced as `reasonCode` — `reasonCode` reaches redacted
239
+ * external reports and must stay a fixed enum member.
240
+ */
241
+ readonly detail?: string;
242
+ constructor(
243
+ reasonCode: MarketplaceRestoreReasonCode,
244
+ options: {
245
+ sideEffectStarted?: boolean;
246
+ outcome?: MarketplaceRestoreOutcome;
247
+ retainedBackupPath?: string;
248
+ detail?: string;
249
+ } = {},
250
+ ) {
251
+ super(reasonCode);
252
+ this.name = "MarketplaceRestoreError";
253
+ this.reasonCode = reasonCode;
254
+ this.sideEffectStarted = options.sideEffectStarted ?? false;
255
+ this.outcome = options.outcome ?? (this.sideEffectStarted ? "conflict" : "refused");
256
+ if (options.detail !== undefined) this.detail = options.detail;
257
+ if (options.retainedBackupPath !== undefined) this.retainedBackupPath = options.retainedBackupPath;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Fixed enum of every `MarketplaceRestoreError.reasonCode` produced by
263
+ * previewPluginRestore/authorizePluginRestore/applyPluginRestore. `reasonCode`
264
+ * reaches redacted external reports, so it MUST stay a member of this exact
265
+ * set — never an interpolated raw Error message (that goes into `detail`,
266
+ * a private, non-reported field on the error).
267
+ */
268
+ export const MARKETPLACE_RESTORE_REASON_CODES = [
269
+ "invalid_plugin_id",
270
+ "not_installed",
271
+ "marketplace_missing",
272
+ "catalog_entry_missing",
273
+ "catalog_changed",
274
+ "baseline_changed",
275
+ "original_path_not_canonical_private_layout",
276
+ "candidate_changed",
277
+ "unpinned_source",
278
+ "unsupported_shared_layout",
279
+ "install_path_not_canonical_private_layout",
280
+ "resolved_provenance_mismatch",
281
+ "unsafe_candidate_artifact",
282
+ "authorization_missing",
283
+ "staged_artifact_inside_cache_root",
284
+ "final_path_outside_private_cache_root",
285
+ "invalid_restore_token",
286
+ "staged_snapshot_unavailable",
287
+ "private_cache_root_missing",
288
+ "private_cache_root_changed",
289
+ "final_path_snapshot_unavailable",
290
+ "final_path_is_symlink",
291
+ "backup_detach_failed",
292
+ "final_path_changed_before_backup",
293
+ "backup_detach_unverified",
294
+ "backup_publish_failed",
295
+ "final_path_claimed_by_new_owner",
296
+ "post_publish_identity_unconfirmed",
297
+ "post_publish_digest_mismatch",
298
+ "registry_write_failed",
299
+ "post_publish_registry_verification_failed",
300
+ "unexpected_occupant_before_our_publish",
301
+ "rollback_rename_failed",
302
+ "rollback_digest_mismatch",
303
+ "restore_publish_failed",
304
+ ] as const;
305
+ export type MarketplaceRestoreReasonCode = (typeof MARKETPLACE_RESTORE_REASON_CODES)[number];
306
+
307
+ export type MarketplaceRestoreApplyStatus = "verified" | "not_needed";
308
+
309
+ /**
310
+ * Discriminated success result for {@link MarketplaceManager.applyPluginRestore}.
311
+ * A TypeScript discriminated union (not one interface with optional fields):
312
+ * `not_needed` structurally has ONLY `staleStagedArtifactPath` and can never
313
+ * carry a `retainedBackupPath` field — there is no backup rollback state in
314
+ * a branch that made zero mutations. `verified` carries neither field, since
315
+ * a fully committed apply has nothing stale or retained to report.
316
+ *
317
+ * `sideEffectStarted` is explicit and MUST be `false` for `not_needed` (zero
318
+ * filesystem or registry mutation of any kind is attempted — the token is not
319
+ * consumed and can be safely retried or discarded) and `true` for `verified`
320
+ * (the cache directory was swapped and the registry written). `entry` for
321
+ * `verified` is re-read from disk after the write, not the in-memory value
322
+ * that was written, so it is independent observation rather than an echo of
323
+ * the input.
324
+ *
325
+ * `not_needed.staleStagedArtifactPath` names the token's now-redundant staged
326
+ * scratch directory for the CALLER to dispose of; this function does not
327
+ * delete it (doing so would itself be a write inside a branch that must
328
+ * remain a true zero-effect no-op), and it is populated ONLY with the exact
329
+ * path this same call's precheck confirmed still exists and still matches
330
+ * the token — never a path this call did not itself verify.
331
+ */
332
+ export type MarketplaceRestoreApplyResult =
333
+ | { status: "verified"; sideEffectStarted: true; entry: InstalledPluginEntry }
334
+ | {
335
+ status: "not_needed";
336
+ sideEffectStarted: false;
337
+ entry: InstalledPluginEntry;
338
+ /** Present only when this call itself verified the staged directory still exists. */
339
+ staleStagedArtifactPath?: string;
340
+ };
341
+
59
342
  // ── Manager ──────────────────────────────────────────────────────────────────
60
343
 
61
344
  export class MarketplaceManager {
@@ -221,7 +504,788 @@ export class MarketplaceManager {
221
504
  return plugins.find(p => p.name === name) ?? null;
222
505
  }
223
506
 
224
- // ── Install / uninstall ───────────────────────────────────────────────────
507
+ /**
508
+ * Pure, read-only restoration observation. Reads the installed registry
509
+ * entry, the marketplace catalog entry, and the local cache artifact only.
510
+ * Never resolves the plugin source, never touches the network, never stages
511
+ * or writes anything.
512
+ */
513
+ async previewPluginRestore(pluginId: string, scope: "user" | "project"): Promise<MarketplaceRestorePlanV1> {
514
+ const parsed = parsePluginId(pluginId);
515
+ if (!parsed) throw new MarketplaceRestoreError("invalid_plugin_id");
516
+ const registryPath = this.#registryPath(scope);
517
+ const registry = await readInstalledPluginsRegistry(registryPath);
518
+ const siblings = getInstalledPlugin(registry, pluginId) ?? [];
519
+ const installed = siblings.find(entry => entry.scope === scope);
520
+ if (!installed) throw new MarketplaceRestoreError("not_installed");
521
+ const marketplace = getMarketplaceEntry(
522
+ await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath),
523
+ parsed.marketplace,
524
+ );
525
+ if (!marketplace) throw new MarketplaceRestoreError("marketplace_missing");
526
+ const catalog = await this.#readCatalog(marketplace);
527
+ const plugin = catalog.plugins.find(item => item.name === parsed.name);
528
+ if (!plugin) throw new MarketplaceRestoreError("catalog_entry_missing");
529
+ const artifact = await inspectCachedPlugin(installed.installPath);
530
+ return {
531
+ schemaVersion: 1,
532
+ kind: "marketplace-plugin.restore-artifact",
533
+ pluginId,
534
+ scope,
535
+ version: installed.version,
536
+ installPath: installed.installPath,
537
+ enabled: installed.enabled !== false,
538
+ ...(installed.gitCommitSha ? { gitCommitSha: installed.gitCommitSha } : {}),
539
+ // Fingerprints EVERY sibling entry recorded under this pluginId in this
540
+ // scope's registry file, not just the matched entry, so a later apply
541
+ // that would silently drop a sibling is caught as a baseline mismatch.
542
+ baselineFingerprint: fingerprint({ pluginId, scope, siblings, marketplace: marketplace.updatedAt, artifact }),
543
+ catalogFingerprint: fingerprint({ marketplace, plugin }),
544
+ source: plugin.source,
545
+ artifact,
546
+ riskClasses: this.#restoreRiskClasses(plugin),
547
+ writes: false,
548
+ fetch: false,
549
+ execute: false,
550
+ };
551
+ }
552
+
553
+ /** Recompute the exact risk classes a restore of this catalog entry needs. Never trust a caller-supplied set. */
554
+ #restoreRiskClasses(plugin: MarketplacePluginEntry): MarketplaceRestoreRiskClass[] {
555
+ const classes: MarketplaceRestoreRiskClass[] = ["plugin-change", "install-replace"];
556
+ if (typeof plugin.source !== "string") classes.push("network");
557
+ return classes;
558
+ }
559
+
560
+ /**
561
+ * Materialize a verified, pinned-SHA candidate after explicit risk
562
+ * authorization. Re-derives every fact from live state instead of trusting
563
+ * the caller-supplied plan: risk classes are recomputed (never taken from
564
+ * `plan.riskClasses`), the source is re-read from the current catalog entry
565
+ * (never from `plan.source`), and the resolved checkout's HEAD is
566
+ * independently re-verified against the pinned SHA before anything is
567
+ * staged. The plan itself confers no authority — only an explicit,
568
+ * risk-complete `authorizations` set does.
569
+ */
570
+ async authorizePluginRestore(
571
+ plan: MarketplaceRestorePlanV1,
572
+ authorizations: readonly string[],
573
+ ): Promise<MarketplaceReviewedRestoreTokenV1> {
574
+ const parsed = parsePluginId(plan.pluginId);
575
+ if (!parsed) throw new MarketplaceRestoreError("invalid_plugin_id");
576
+ const currentRegistry = await readInstalledPluginsRegistry(this.#registryPath(plan.scope));
577
+ const currentSiblings = getInstalledPlugin(currentRegistry, plan.pluginId) ?? [];
578
+ const current = currentSiblings.find(entry => entry.scope === plan.scope);
579
+ if (!current) throw new MarketplaceRestoreError("not_installed");
580
+ const marketplace = getMarketplaceEntry(
581
+ await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath),
582
+ parsed.marketplace,
583
+ );
584
+ if (!marketplace) throw new MarketplaceRestoreError("marketplace_missing");
585
+ const currentArtifact = await inspectCachedPlugin(current.installPath);
586
+ const currentBaseline = fingerprint({
587
+ pluginId: plan.pluginId,
588
+ scope: plan.scope,
589
+ siblings: currentSiblings,
590
+ marketplace: marketplace.updatedAt,
591
+ artifact: currentArtifact,
592
+ });
593
+ if (currentBaseline !== plan.baselineFingerprint) throw new MarketplaceRestoreError("baseline_changed");
594
+ const catalog = await this.#readCatalog(marketplace);
595
+ const plugin = catalog.plugins.find(item => item.name === parsed.name);
596
+ if (!plugin || fingerprint({ marketplace, plugin }) !== plan.catalogFingerprint)
597
+ throw new MarketplaceRestoreError("catalog_changed");
598
+ // Recomputed from the CURRENT catalog entry, never from plan.riskClasses
599
+ // or plan.source — a caller cannot under-report risk to bypass authorization.
600
+ const riskClasses = this.#restoreRiskClasses(plugin);
601
+ // reasonCode stays the fixed enum member "authorization_missing"; the
602
+ // specific missing risk class is carried in the private, non-reported
603
+ // `detail` field, never interpolated into the reported reasonCode.
604
+ for (const risk of riskClasses)
605
+ if (!authorizations.includes(risk))
606
+ throw new MarketplaceRestoreError("authorization_missing", { detail: `missing risk class: ${risk}` });
607
+ // Every source shape that is NOT a pinnable single-plugin git object maps to
608
+ // unsupported_shared_layout, not a generic pin-failure code, so a report
609
+ // never implies D6 support for a shared/non-private layout:
610
+ // - npm sources are the D7-scoped shared node_modules layout.
611
+ // - string (relative "./plugins/foo") sources resolve INSIDE the shared
612
+ // marketplace clone tree, not a private per-(marketplace,name,version)
613
+ // cache entry — there is no independently pinnable object to restore.
614
+ if (typeof plugin.source === "string" || (typeof plugin.source === "object" && plugin.source.source === "npm"))
615
+ throw new MarketplaceRestoreError("unsupported_shared_layout");
616
+ assertPinnedSource(plugin.source);
617
+ const pin = sourcePin(plugin.source);
618
+ if (!pin.sha) throw new MarketplaceRestoreError("unpinned_source");
619
+ // current.installPath must be exactly the canonical cache path for its OWN
620
+ // recorded version — mirrors installPlugin/uninstallPlugin's
621
+ // #validateCacheDeletionTargets identity check, so restore never touches a
622
+ // path outside the private per-(marketplace,name,version) cache layout.
623
+ const recordedPath = getCachedPluginPath(
624
+ this.#opts.pluginsCacheDir,
625
+ parsed.marketplace,
626
+ parsed.name,
627
+ current.version,
628
+ );
629
+ if (path.resolve(current.installPath) !== recordedPath)
630
+ throw new MarketplaceRestoreError("install_path_not_canonical_private_layout");
631
+ const resolved = await resolvePluginSource(plugin, {
632
+ marketplaceClonePath: this.#resolveMarketplaceRoot(marketplace),
633
+ catalogMetadata: catalog.metadata,
634
+ tmpDir: os.tmpdir(),
635
+ });
636
+ // Staged under a fresh random scratch path, NOT a predictable path derived
637
+ // from the target's own name — apply never guesses a staging location.
638
+ const stagingPath = path.join(os.tmpdir(), `gjc-marketplace-restore-${randomBytes(12).toString("hex")}`);
639
+ try {
640
+ // Independently re-derive provenance from the checkout itself (a fresh
641
+ // `rev-parse HEAD`) rather than trusting whatever the resolver claims to
642
+ // have cloned. Verify against the actual git repository root
643
+ // (tempCloneRoot) rather than resolved.dir: for git-subdir sources,
644
+ // resolved.dir is a SUBDIRECTORY of the clone and has no HEAD of its own.
645
+ // npm/string sources are already refused above (unsupported_shared_layout)
646
+ // before reaching this point, so `plugin.source` is always a pinnable
647
+ // git-style object here.
648
+ {
649
+ const provenanceRoot = resolved.tempCloneRoot ?? resolved.dir;
650
+ const provenanceOk = await verifyResolvedProvenance(provenanceRoot, pin.sha);
651
+ if (!provenanceOk) throw new MarketplaceRestoreError("resolved_provenance_mismatch");
652
+ }
653
+ await stageCachedPlugin(resolved.dir, stagingPath);
654
+ const digest = await inspectCachedPlugin(stagingPath);
655
+ // This is a scratch-directory failure only — nothing installed has been
656
+ // touched, so it is a clean refusal, not a partial mutation.
657
+ if (digest.status !== "present" || !digest.digest)
658
+ throw new MarketplaceRestoreError("unsafe_candidate_artifact");
659
+ return {
660
+ schemaVersion: 1,
661
+ kind: "marketplace-plugin.restore-artifact",
662
+ purpose: "restore-artifact",
663
+ pluginId: plan.pluginId,
664
+ scope: plan.scope,
665
+ version: current.version,
666
+ originalInstallPath: current.installPath,
667
+ stagedArtifactPath: stagingPath,
668
+ enabled: current.enabled !== false,
669
+ pinnedSha: pin.sha,
670
+ baselineFingerprint: plan.baselineFingerprint,
671
+ catalogFingerprint: plan.catalogFingerprint,
672
+ candidateFingerprint: fingerprint({
673
+ plugin,
674
+ digest: digest.digest,
675
+ version: current.version,
676
+ pinnedSha: pin.sha,
677
+ }),
678
+ candidateArtifact: { status: "present", digest: digest.digest },
679
+ riskClasses,
680
+ reviewedAt: new Date().toISOString(),
681
+ };
682
+ } catch (error) {
683
+ await fs.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
684
+ throw error;
685
+ } finally {
686
+ if (resolved.tempCloneRoot)
687
+ await fs.rm(resolved.tempCloneRoot, { recursive: true, force: true }).catch(() => {});
688
+ }
689
+ }
690
+
691
+ /**
692
+ * Publish a verified restore candidate. Re-verifies catalog/baseline/
693
+ * candidate CAS against live state (the token confers no authority by
694
+ * itself), swaps the private per-version cache directory atomically with a
695
+ * randomized backup name, and mutates ONLY the matched (pluginId, scope)
696
+ * registry entry — every sibling entry, every other pluginId, and the
697
+ * opposite scope's registry file are left untouched. On any failure after
698
+ * the filesystem swap the exact old artifact/registry pair is restored.
699
+ */
700
+ async applyPluginRestore(token: MarketplaceReviewedRestoreTokenV1): Promise<MarketplaceRestoreApplyResult> {
701
+ const parsed = parsePluginId(token.pluginId);
702
+ if (!parsed || token.purpose !== "restore-artifact" || token.schemaVersion !== 1)
703
+ throw new MarketplaceRestoreError("invalid_restore_token");
704
+ const pluginsCacheDir = path.resolve(this.#opts.pluginsCacheDir);
705
+ // Path containment: the staged candidate must live under a scratch root we
706
+ // control (never inside the private cache tree itself), and the original
707
+ // install path must be the exact canonical private-layout path for the
708
+ // token's own (marketplace, name, version) — never an arbitrary foreign path.
709
+ // Everything up to here is a pure precheck: no mutation has been attempted,
710
+ // so every throw is a clean `refused`.
711
+ const stagedResolved = path.resolve(token.stagedArtifactPath);
712
+ if (pathIsWithin(pluginsCacheDir, stagedResolved) || stagedResolved === pluginsCacheDir)
713
+ throw new MarketplaceRestoreError("staged_artifact_inside_cache_root");
714
+ const finalPath = getCachedPluginPath(this.#opts.pluginsCacheDir, parsed.marketplace, parsed.name, token.version);
715
+ if (!pathIsWithin(pluginsCacheDir, finalPath))
716
+ throw new MarketplaceRestoreError("final_path_outside_private_cache_root");
717
+ if (path.resolve(token.originalInstallPath) !== finalPath)
718
+ throw new MarketplaceRestoreError("original_path_not_canonical_private_layout");
719
+
720
+ const registryPath = this.#registryPath(token.scope);
721
+ // Shared exclusion primitive: the same withFileLock every OTHER writer in
722
+ // this class uses against this exact path, so restore's CAS is real
723
+ // exclusion against every writer of this registry file.
724
+ return await withFileLock(registryPath, async () => {
725
+ const registry = await readInstalledPluginsRegistry(registryPath);
726
+ const siblings = getInstalledPlugin(registry, token.pluginId) ?? [];
727
+ const matchIndex = siblings.findIndex(entry => entry.scope === token.scope);
728
+ if (matchIndex < 0) throw new MarketplaceRestoreError("not_installed");
729
+ const current = siblings[matchIndex] as InstalledPluginEntry;
730
+ const marketplace = getMarketplaceEntry(
731
+ await readMarketplacesRegistry(this.#opts.marketplacesRegistryPath),
732
+ parsed.marketplace,
733
+ );
734
+ if (!marketplace) throw new MarketplaceRestoreError("marketplace_missing");
735
+ const catalog = await this.#readCatalog(marketplace);
736
+ const plugin = catalog.plugins.find(item => item.name === parsed.name);
737
+ if (!plugin || fingerprint({ marketplace, plugin }) !== token.catalogFingerprint)
738
+ throw new MarketplaceRestoreError("catalog_changed");
739
+ // If the catalog was rewritten to a shared/non-private source shape between
740
+ // authorize and apply (e.g. an operator edited the catalog to point at an
741
+ // npm package or a relative in-clone path), that is the SAME
742
+ // unsupported_shared_layout condition authorize would have refused up
743
+ // front — report it identically here rather than falling through to a
744
+ // generic catalog_changed/pin-mismatch code.
745
+ if (typeof plugin.source === "string" || (typeof plugin.source === "object" && plugin.source.source === "npm"))
746
+ throw new MarketplaceRestoreError("unsupported_shared_layout");
747
+ // Explicit pin recheck, independent of the hash comparison above: the
748
+ // CURRENT catalog entry's own pinned SHA must still equal the exact SHA
749
+ // this token was authorized against. The catalogFingerprint hash already
750
+ // covers the full plugin object (including source.sha), so a catalog
751
+ // rewrite that changes the pin is already caught above; this makes the
752
+ // pin-specific failure mode explicit rather than relying solely on
753
+ // implied hash coverage, and fails closed if source.sha is ever removed
754
+ // or the catalog entry becomes unpinned between authorize and apply.
755
+ const currentPin = sourcePin(plugin.source);
756
+ if (!currentPin.sha || currentPin.sha.toLowerCase() !== token.pinnedSha.toLowerCase())
757
+ throw new MarketplaceRestoreError("catalog_changed");
758
+ const currentArtifact = await inspectCachedPlugin(current.installPath);
759
+ if (path.resolve(current.installPath) !== finalPath)
760
+ throw new MarketplaceRestoreError("original_path_not_canonical_private_layout");
761
+
762
+ // The baseline fingerprint authorizes a MUTATION, and it covers the observed
763
+ // artifact — so a successful apply necessarily changes it. Checking it before
764
+ // the zero-effect no-op below would make a retry of the very token that just
765
+ // succeeded fail as `baseline_changed`. The no-op writes nothing, renames
766
+ // nothing, and returns freshly read disk state, so it needs no mutation
767
+ // authority; every non-baseline CAS check (catalog fingerprint, explicit pin,
768
+ // canonical layout) has already run above and still gates it.
769
+ const baselineMatches =
770
+ fingerprint({
771
+ pluginId: token.pluginId,
772
+ scope: token.scope,
773
+ siblings,
774
+ marketplace: marketplace.updatedAt,
775
+ artifact: currentArtifact,
776
+ }) === token.baselineFingerprint;
777
+
778
+ // Idempotent retry: if the currently installed artifact already matches
779
+ // the verified candidate (e.g. a prior apply of this exact token already
780
+ // published it), this is a no-op.
781
+ if (currentArtifact.status === "present" && currentArtifact.digest === token.candidateArtifact.digest) {
782
+ // TRUE zero-effect no-op: no rm, no rename, no registry write. The
783
+ // now-redundant staged scratch directory is left exactly where it is;
784
+ // disposing of it is the caller's responsibility (or a separate,
785
+ // explicitly-effectful cleanup step), never folded into this branch.
786
+ // `current` here is itself the fresh disk-read entry from the top of
787
+ // this function, not an echo of the token.
788
+ //
789
+ // staleStagedArtifactPath is populated ONLY when THIS call's own
790
+ // read-only precheck confirms a directory still exists at exactly that
791
+ // path — a prior apply of the same token (e.g. a second not_needed in a
792
+ // row) may have already had it disposed of by the caller, and this
793
+ // function must never report a path it did not itself just verify.
794
+ const stagedStillPresent = await inspectCachedPlugin(token.stagedArtifactPath);
795
+ return stagedStillPresent.status === "present"
796
+ ? {
797
+ status: "not_needed",
798
+ sideEffectStarted: false,
799
+ entry: current,
800
+ staleStagedArtifactPath: token.stagedArtifactPath,
801
+ }
802
+ : { status: "not_needed", sideEffectStarted: false, entry: current };
803
+ }
804
+
805
+ // A real mutation follows, so the recorded baseline must still authorize it.
806
+ if (!baselineMatches) throw new MarketplaceRestoreError("baseline_changed");
807
+
808
+ // Independently re-verify the staged candidate's bytes right now — never
809
+ // trust the digest recorded on the token without recomputing it. Still no
810
+ // mutation of installed state, so still a clean refusal on failure.
811
+ const stagedDigest = await inspectCachedPlugin(token.stagedArtifactPath);
812
+ if (stagedDigest.status !== "present" || stagedDigest.digest !== token.candidateArtifact.digest)
813
+ throw new MarketplaceRestoreError("candidate_changed");
814
+ if (
815
+ fingerprint({ plugin, digest: stagedDigest.digest, version: token.version, pinnedSha: token.pinnedSha }) !==
816
+ token.candidateFingerprint
817
+ )
818
+ throw new MarketplaceRestoreError("candidate_changed");
819
+
820
+ // Snapshot the staged candidate's identity BEFORE it is ever renamed. A
821
+ // `renameat`-class syscall does not change a directory's dev/ino on the
822
+ // same filesystem (rename is a namespace operation, not a copy), so this
823
+ // exact snapshot remains valid evidence of what now sits at finalPath
824
+ // once the rename below lands — no post-publish re-snapshot is needed to
825
+ // establish that identity, closing the window a post-publish snapshot
826
+ // would otherwise reopen. A cross-device rename (different filesystem)
827
+ // changes this invariant, which is exactly why the no-replace publish
828
+ // below is required to fail closed on cross_device rather than silently
829
+ // falling back to copy semantics — see the cross-device handling note
830
+ // further down.
831
+ const stagedSnapshot = await snapshotIfPresent(token.stagedArtifactPath);
832
+ if (!stagedSnapshot) throw new MarketplaceRestoreError("staged_snapshot_unavailable");
833
+
834
+ // Parent-directory identity check immediately before the destructive
835
+ // swap sequence, closing the TOCTOU window between the reads above and
836
+ // the filesystem mutation below.
837
+ const parentBefore = await fs.lstat(pluginsCacheDir, { bigint: true }).catch(() => undefined);
838
+ if (!parentBefore?.isDirectory()) throw new MarketplaceRestoreError("private_cache_root_missing");
839
+
840
+ const finalSnapshotBefore = await snapshotIfPresent(finalPath);
841
+ const finalStat = await fs.lstat(finalPath).catch(error => {
842
+ if (isEnoent(error)) return undefined;
843
+ throw error;
844
+ });
845
+ if (finalStat?.isSymbolicLink()) throw new MarketplaceRestoreError("final_path_is_symlink");
846
+ const hadFinal = finalStat !== undefined;
847
+ // Single-component name; never renamed elsewhere on collision since the
848
+ // native no-replace primitive is what actually refuses an occupied slot.
849
+ const backup = `${finalPath}.restore-backup-${process.pid}-${randomBytes(6).toString("hex")}`;
850
+
851
+ const parentRecheck = await fs.lstat(pluginsCacheDir, { bigint: true }).catch(() => undefined);
852
+ if (!parentRecheck || parentRecheck.dev !== parentBefore.dev || parentRecheck.ino !== parentBefore.ino)
853
+ throw new MarketplaceRestoreError("private_cache_root_changed");
854
+
855
+ // Step 1: move any existing occupant out of finalPath into the backup
856
+ // slot using the native identity-bound exact-remove-then-no-replace-
857
+ // publish sequence, so check+mutate is one atomic native operation
858
+ // rather than TypeScript-level lstat-then-rename.
859
+ // exactRemoveDirectoryTree only succeeds when the on-disk tree still
860
+ // matches the snapshot taken microseconds earlier;
861
+ // renameDirectoryNoReplacePathAsync only succeeds when `backup` does not
862
+ // already exist. Neither call ever overwrites a foreign occupant.
863
+ //
864
+ // From here on a filesystem mutation is about to be attempted. Every
865
+ // subsequent failure MUST surface as `conflict` (verified rollback),
866
+ // `foreign_owner_conflict` (a new owner claimed the slot; nothing of
867
+ // ours is destroyed), or `uncertain` (rollback could not be verified) —
868
+ // never a plain refusal once this point is reached.
869
+ let backupSnapshot: NativeDirectoryTreeSnapshot | undefined;
870
+ if (hadFinal) {
871
+ // Not yet an effect: nothing has been renamed or detached at this point,
872
+ // merely a decision about whether it is safe to proceed. sideEffectStarted
873
+ // stays false — effects are physical, not procedural.
874
+ if (!finalSnapshotBefore) throw new MarketplaceRestoreError("final_path_snapshot_unavailable");
875
+ const detached = exactRemoveDirectoryTree(finalPath, finalSnapshotBefore, {
876
+ dev: parentBefore.dev,
877
+ ino: parentBefore.ino,
878
+ });
879
+ const detachedPath = detachedTree(detached);
880
+ if (!detachedPath) {
881
+ // identity_mismatch means finalPath already changed before we ever
882
+ // touched it — exactRemoveDirectoryTree refused the mutation entirely,
883
+ // so this is a pure precheck refusal: nothing moved, sideEffectStarted
884
+ // stays false.
885
+ if (detached.code === "identity_mismatch")
886
+ throw new MarketplaceRestoreError("final_path_changed_before_backup");
887
+ // Any other native refusal from exactRemoveDirectoryTree also means the
888
+ // call did not mutate anything (the native primitive only mutates on a
889
+ // verified match); this is the same class of pure refusal.
890
+ throw new MarketplaceRestoreError("backup_detach_failed");
891
+ }
892
+ const detachedSnapshot = await snapshotIfPresent(detachedPath);
893
+ if (!detachedSnapshot)
894
+ throw new MarketplaceRestoreError("backup_detach_unverified", {
895
+ sideEffectStarted: true,
896
+ outcome: "uncertain",
897
+ });
898
+ // The native detach parks the tree at a FIXED `<name>.removing` slot. Once it
899
+ // has been republished under the backup name that slot must be vacated, or a
900
+ // later detach of the same canonical path inside this same transaction (the
901
+ // rollback path) collides with our own leftover and is misread as a foreign
902
+ // claim. The rename below is what vacates it; failure is reported, never assumed.
903
+ const backupPublish = await renameDirectoryNoReplacePathAsync(detachedPath, backup);
904
+ if (!backupPublish.ok) {
905
+ // mutationState "not_committed" proves the rename never moved the
906
+ // object; it is still at the detached path, so that path is real
907
+ // observed evidence to report. mutationState "unknown" means we
908
+ // cannot trust EITHER location from the result alone — independently
909
+ // observe which of the two paths actually holds the object right now
910
+ // (via a real fs check) before naming it, rather than assuming.
911
+ const observedAtDetached =
912
+ backupPublish.mutationState === "not_committed"
913
+ ? true
914
+ : await fs.lstat(detachedPath).then(
915
+ () => true,
916
+ () => false,
917
+ );
918
+ const observedAtBackup = observedAtDetached
919
+ ? false
920
+ : await fs.lstat(backup).then(
921
+ () => true,
922
+ () => false,
923
+ );
924
+ throw new MarketplaceRestoreError("backup_publish_failed", {
925
+ sideEffectStarted: true,
926
+ outcome: "uncertain",
927
+ // Only ever names a path THIS call independently confirmed holds the
928
+ // object right now — never a computed/assumed name. If neither
929
+ // location can be confirmed, omit the field entirely and let the
930
+ // uncertain outcome stand alone rather than pointing at empty air.
931
+ ...(observedAtDetached
932
+ ? { retainedBackupPath: detachedPath }
933
+ : observedAtBackup
934
+ ? { retainedBackupPath: backup }
935
+ : {}),
936
+ });
937
+ }
938
+ backupSnapshot = detachedSnapshot;
939
+ }
940
+ // `stagedSnapshot` (captured above, before any rename) is the AUTHORITATIVE
941
+ // identity a rollback removal binds to — a same-filesystem `renameat` does
942
+ // not change dev/ino, so this pre-publish snapshot remains valid evidence
943
+ // of what sits at finalPath once our rename lands, with no post-publish
944
+ // re-snapshot needed to establish it (that would reopen exactly the TOCTOU
945
+ // window this closes).
946
+ let published = false;
947
+ try {
948
+ // INVARIANT COUPLING (do not weaken without re-checking `stagedSnapshot`
949
+ // authority above and in the rollback catch block below): this call MUST
950
+ // remain a pure namespace rename with no copy-semantics fallback on ANY
951
+ // failure reason, including `cross_device`. `renameDirectoryNoReplacePathAsync`
952
+ // returns `{ ok: false, reason: "cross_device" }` rather than transparently
953
+ // copying across filesystems; we only branch on `.ok`, so a cross-device
954
+ // staging root fails closed as a plain publish refusal. If this primitive
955
+ // (or a future replacement) is ever changed to copy-then-delete across
956
+ // devices on cross_device, the dev/ino-stability assumption behind
957
+ // `stagedSnapshot` breaks silently and every identity-bound check in this
958
+ // method becomes unsound.
959
+ const publish = await renameDirectoryNoReplacePathAsync(token.stagedArtifactPath, finalPath);
960
+ // Test seam only (no-op in production): lets a test deterministically
961
+ // simulate a concurrent second writer republishing finalPath here,
962
+ // exercising the foreign-owner branch below without weakening it.
963
+ if (this.#opts.afterRestorePublish) await this.#opts.afterRestorePublish(finalPath);
964
+ if (!publish.ok) {
965
+ // A failed no-replace publish reports `mutationState`: "not_committed"
966
+ // (proven nothing landed — e.g. destination_exists) is the ONLY case
967
+ // safe to treat as a clean "someone else occupies finalPath, nothing of
968
+ // ours was ever published" refusal. "unknown" (durability unprovable)
969
+ // means the rename MAY have partially committed despite `.ok === false`
970
+ // — that is NOT a clean refusal; it must be treated as uncertain and,
971
+ // if we detached a backup, retained rather than silently dropped.
972
+ // Cheap to confirm rather than assume: an lstat on `backup` right now,
973
+ // not a derived-from-control-flow claim. If hadFinal but backup is not
974
+ // actually there (a concurrent actor in the same 0700 tree unlinked or
975
+ // replaced it between our detach and this check), omit the field rather
976
+ // than report a stale path.
977
+ const backupObservedNow = hadFinal
978
+ ? await fs.lstat(backup).then(
979
+ () => true,
980
+ () => false,
981
+ )
982
+ : false;
983
+ if (publish.mutationState === "unknown") {
984
+ throw new MarketplaceRestoreError("post_publish_identity_unconfirmed", {
985
+ sideEffectStarted: true,
986
+ outcome: "uncertain",
987
+ ...(backupObservedNow ? { retainedBackupPath: backup } : {}),
988
+ });
989
+ }
990
+ // mutationState === "not_committed": proven nothing of ours landed at
991
+ // finalPath. Our backup (if any) is intact and untouched; report
992
+ // without entering the destructive rollback path below at all.
993
+ throw new MarketplaceRestoreError("final_path_claimed_by_new_owner", {
994
+ sideEffectStarted: hadFinal,
995
+ outcome: "foreign_owner_conflict",
996
+ ...(backupObservedNow ? { retainedBackupPath: backup } : {}),
997
+ });
998
+ }
999
+ published = true;
1000
+ // Confirmation, NOT authority: an independent post-publish snapshot must
1001
+ // agree with the pre-publish `stagedSnapshot` on entry count/content
1002
+ // (dev/ino legitimately differ across the parent-directory boundary the
1003
+ // rename crossed, but the tree's own content identity does not). A
1004
+ // mismatch here is never trusted as "ours" — it means a third party
1005
+ // touched finalPath in the sub-millisecond gap and is reported as
1006
+ // foreign_owner_conflict without deleting anything.
1007
+ const postPublishConfirm = await snapshotIfPresent(finalPath);
1008
+ // A mismatch here means the object at the canonical name is no longer the one
1009
+ // we just published: a concurrent owner claimed it. That is the same fact the
1010
+ // pre-publish refusal reports, so it carries the same reason code; an absent
1011
+ // or unreadable observation is only unconfirmed, not proof of a new owner.
1012
+ if (!postPublishConfirm || !sameDirectoryTreeIdentity(stagedSnapshot, postPublishConfirm)) {
1013
+ const claimed = postPublishConfirm !== undefined;
1014
+ // Real lstat confirmation, not derived-from-control-flow: `backup` was
1015
+ // last touched during Step 1 and nothing between then and here should
1016
+ // have moved it, but a concurrent actor in the same tree could have.
1017
+ const backupObservedNow = hadFinal
1018
+ ? await fs.lstat(backup).then(
1019
+ () => true,
1020
+ () => false,
1021
+ )
1022
+ : false;
1023
+ throw new MarketplaceRestoreError(
1024
+ claimed ? "final_path_claimed_by_new_owner" : "post_publish_identity_unconfirmed",
1025
+ {
1026
+ sideEffectStarted: true,
1027
+ outcome: "foreign_owner_conflict",
1028
+ ...(backupObservedNow ? { retainedBackupPath: backup } : {}),
1029
+ },
1030
+ );
1031
+ }
1032
+ const postDigest = await inspectCachedPlugin(finalPath);
1033
+ if (postDigest.status !== "present" || postDigest.digest !== token.candidateArtifact.digest)
1034
+ throw new MarketplaceRestoreError("post_publish_digest_mismatch", { sideEffectStarted: true });
1035
+ const nextEntry: InstalledPluginEntry = {
1036
+ ...current,
1037
+ installPath: finalPath,
1038
+ gitCommitSha: token.pinnedSha,
1039
+ enabled: current.enabled,
1040
+ lastUpdated: new Date().toISOString(),
1041
+ };
1042
+ // Replace ONLY the matched sibling; every other entry under this
1043
+ // pluginId (other scopes' duplicates recorded in this same file, if
1044
+ // any) is preserved verbatim, in order.
1045
+ const nextSiblings = [...siblings];
1046
+ nextSiblings[matchIndex] = nextEntry;
1047
+ try {
1048
+ await writeInstalledPluginsRegistry(registryPath, {
1049
+ ...registry,
1050
+ plugins: { ...registry.plugins, [token.pluginId]: nextSiblings },
1051
+ });
1052
+ } catch {
1053
+ // Registry write failed after the fs swap succeeded: roll the fs pair
1054
+ // back so artifact and registry never drift out of the pair they were
1055
+ // published as.
1056
+ throw new MarketplaceRestoreError("registry_write_failed", {
1057
+ sideEffectStarted: true,
1058
+ outcome: "conflict",
1059
+ });
1060
+ }
1061
+ // Independent post-write observation, BEFORE the backup is discarded:
1062
+ // re-read installed_plugins.json FROM DISK (not the in-memory
1063
+ // registry/nextEntry we just wrote), so success evidence is a fresh
1064
+ // observation of the actually-persisted state rather than an echo of the
1065
+ // write input. The artifact itself was ALREADY independently confirmed
1066
+ // (postDigest, above) before this registry write was even attempted, so
1067
+ // there is no separate artifact-durability question left to re-ask here.
1068
+ // Exact-CAS-bound registry revert used by both verification failure
1069
+ // branches below: reverts to the EXACT pre-write `registry` object this
1070
+ // call itself read at the top (preserving every sibling scope and any
1071
+ // concurrent unrelated edit that object already captured), then
1072
+ // independently re-reads the file to CONFIRM the revert actually landed
1073
+ // — never silently swallowed. If the revert write throws, or the
1074
+ // post-revert re-read does not exactly match the pre-write registry for
1075
+ // this pluginId's entries, that is NOT a clean rollback: it is reported
1076
+ // as `uncertain`, never folded into a bare `failed`/silently-assumed
1077
+ // `conflict`.
1078
+ const revertRegistryAndVerify = async (reasonCode: MarketplaceRestoreReasonCode): Promise<never> => {
1079
+ let revertWriteOk = true;
1080
+ try {
1081
+ await writeInstalledPluginsRegistry(registryPath, registry);
1082
+ } catch {
1083
+ revertWriteOk = false;
1084
+ }
1085
+ const revertConfirmed = revertWriteOk
1086
+ ? await readInstalledPluginsRegistry(registryPath).then(
1087
+ reverted =>
1088
+ JSON.stringify(getInstalledPlugin(reverted, token.pluginId) ?? []) ===
1089
+ JSON.stringify(siblings),
1090
+ () => false,
1091
+ )
1092
+ : false;
1093
+ throw new MarketplaceRestoreError(reasonCode, {
1094
+ sideEffectStarted: true,
1095
+ // Registry revert verified byte-for-byte against the exact pre-write
1096
+ // siblings this call read → conflict (installed state provably back
1097
+ // to before this apply). Unverified/failed revert → uncertain, since
1098
+ // the registry may now claim an entry the artifact pair does not
1099
+ // actually match.
1100
+ outcome: revertConfirmed ? "conflict" : "uncertain",
1101
+ });
1102
+ };
1103
+ const confirmedRegistry = await readInstalledPluginsRegistry(registryPath);
1104
+ const confirmedEntry = getInstalledPlugin(confirmedRegistry, token.pluginId)?.find(
1105
+ entry => entry.scope === token.scope,
1106
+ );
1107
+ if (!confirmedEntry || confirmedEntry.installPath !== finalPath) {
1108
+ // The registry write is not provably durable. Revert to the exact
1109
+ // pre-write CAS baseline and independently verify that revert before
1110
+ // classifying the outcome — never a silently-swallowed best-effort.
1111
+ throw await revertRegistryAndVerify("post_publish_registry_verification_failed");
1112
+ }
1113
+ // One final independent artifact re-verification, bound to this SAME
1114
+ // canonical registry+disk read (not the earlier postDigest snapshot):
1115
+ // the "verified" success result reported to the caller must be backed
1116
+ // by evidence gathered AFTER the registry write, not only before it —
1117
+ // otherwise a caller could report `verified` for a state that changed
1118
+ // again between the artifact check and the registry write.
1119
+ //
1120
+ // A durable write plus a successful digest check on a STALE read is not
1121
+ // proof: inspectCachedPlugin(path) alone reads by PATH ONLY and could
1122
+ // observe a DIFFERENT object if something replaced finalPath in the
1123
+ // interim yet coincidentally produced the same digest, or more subtly,
1124
+ // could simply race a concurrent mutation without any way to detect it.
1125
+ // Bind this final read to the exact identity captured at publish time
1126
+ // (`stagedSnapshot`, dev/ino-stable across the same-filesystem rename) via
1127
+ // the same sameDirectoryTreeIdentity check used to confirm the publish
1128
+ // itself — not merely re-reading the same path and hoping it is still
1129
+ // the same object.
1130
+ const finalSnapshot = await snapshotIfPresent(confirmedEntry.installPath);
1131
+ if (!finalSnapshot || !sameDirectoryTreeIdentity(stagedSnapshot, finalSnapshot)) {
1132
+ throw await revertRegistryAndVerify("post_publish_registry_verification_failed");
1133
+ }
1134
+ const finalDigest = await inspectCachedPlugin(confirmedEntry.installPath);
1135
+ if (finalDigest.status !== "present" || finalDigest.digest !== token.candidateArtifact.digest) {
1136
+ throw await revertRegistryAndVerify("post_publish_registry_verification_failed");
1137
+ }
1138
+ // Confirmed: only now is the backup discarded (identity-bound exact
1139
+ // removal, never a blind rm) and the mutation treated as durably
1140
+ // committed.
1141
+ if (hadFinal && backupSnapshot) {
1142
+ const backupParent = await fs.lstat(pluginsCacheDir, { bigint: true }).catch(() => undefined);
1143
+ if (backupParent)
1144
+ exactRemoveDirectoryTree(backup, backupSnapshot, { dev: backupParent.dev, ino: backupParent.ino });
1145
+ }
1146
+ this.#clearCache();
1147
+ return { status: "verified", sideEffectStarted: true, entry: confirmedEntry };
1148
+ } catch (error) {
1149
+ // If we never detached an original (nothing of ours to protect: either
1150
+ // !hadFinal, or a precheck refused before Step 1 ran), there is nothing
1151
+ // to roll back — rethrow as-is rather than touching finalPath at all,
1152
+ // which matters especially for !hadFinal: a foreign occupant that just
1153
+ // appeared there must never be snapshotted/removed by us.
1154
+ if (!hadFinal || !backupSnapshot) throw error;
1155
+ // A specific foreign-owner-conflict thrown above already fully
1156
+ // evaluated and reported the retained-backup state; do not re-run
1157
+ // rollback logic over it.
1158
+ if (error instanceof MarketplaceRestoreError && error.outcome === "foreign_owner_conflict") throw error;
1159
+ // From here we KNOW an original was detached into `backup`
1160
+ // (backupSnapshot is set), so finalPath is either empty (our publish
1161
+ // never landed) or holds our own just-published candidate (a later
1162
+ // step failed, e.g. registry write). Roll it back to the backup using
1163
+ // the SAME identity-bound exact-remove-then-no-replace-publish pattern:
1164
+ // never blind-rm/overwrite, and the object currently at finalPath must
1165
+ // match the snapshot we captured AT PUBLISH TIME (not a fresh re-snapshot
1166
+ // — a fresh snapshot would trivially "match" any foreign object currently
1167
+ // sitting there and defeat identity binding entirely).
1168
+ // Every retainedBackupPath below is a real lstat confirmation taken at
1169
+ // the point of the throw, not a name derived purely from control flow —
1170
+ // a concurrent actor in the same 0700 tree could in principle unlink or
1171
+ // replace `backup` between an earlier step and this error construction.
1172
+ const confirmBackupObserved = () =>
1173
+ fs.lstat(backup).then(
1174
+ () => true,
1175
+ () => false,
1176
+ );
1177
+ const finalPathExists = await fs
1178
+ .lstat(finalPath)
1179
+ .then(() => true)
1180
+ .catch(() => false);
1181
+ if (finalPathExists) {
1182
+ if (!published) {
1183
+ // Our own publish never actually landed (failure happened before Step 2
1184
+ // succeeded), yet something now occupies finalPath: that is necessarily
1185
+ // a foreign write, not ours — refuse to touch it rather than guess.
1186
+ const backupObservedNow = await confirmBackupObserved();
1187
+ throw new MarketplaceRestoreError("unexpected_occupant_before_our_publish", {
1188
+ sideEffectStarted: true,
1189
+ outcome: "uncertain",
1190
+ ...(backupObservedNow ? { retainedBackupPath: backup } : {}),
1191
+ });
1192
+ }
1193
+ // stagedSnapshot (captured before our rename, dev/ino-stable across a
1194
+ // same-filesystem renameat) is the authoritative identity a rollback
1195
+ // removal binds to here — the same evidence used to confirm the publish
1196
+ // above, not a freshly re-derived snapshot that would trivially match
1197
+ // whatever a third party just wrote.
1198
+ const publishedParent = await fs.lstat(pluginsCacheDir, { bigint: true }).catch(() => undefined);
1199
+ if (!publishedParent) {
1200
+ const backupObservedNow = await confirmBackupObserved();
1201
+ throw new MarketplaceRestoreError("private_cache_root_missing", {
1202
+ sideEffectStarted: true,
1203
+ outcome: "uncertain",
1204
+ ...(backupObservedNow ? { retainedBackupPath: backup } : {}),
1205
+ });
1206
+ }
1207
+ const removedPublished = exactRemoveDirectoryTree(finalPath, stagedSnapshot, {
1208
+ dev: publishedParent.dev,
1209
+ ino: publishedParent.ino,
1210
+ });
1211
+ if (!treeRemoved(removedPublished)) {
1212
+ const backupObservedNow = await confirmBackupObserved();
1213
+ // `identity_mismatch` is the only native outcome that proves the object at
1214
+ // finalPath is not the one we published — i.e. a new owner claimed it.
1215
+ // Any other refusal (an occupied detach slot, an I/O failure) means the
1216
+ // removal could not be carried out at all, which is unresolved, not proof
1217
+ // of a foreign claim. Reporting the wrong one of those two would either
1218
+ // slander a healthy state or hide an unfinished rollback.
1219
+ const claimed = removedPublished.code === "identity_mismatch";
1220
+ throw new MarketplaceRestoreError(
1221
+ claimed ? "final_path_claimed_by_new_owner" : "rollback_rename_failed",
1222
+ {
1223
+ sideEffectStarted: true,
1224
+ outcome: claimed ? "foreign_owner_conflict" : "uncertain",
1225
+ ...(backupObservedNow ? { retainedBackupPath: backup } : {}),
1226
+ },
1227
+ );
1228
+ }
1229
+ }
1230
+ const restorePublish = await renameDirectoryNoReplacePathAsync(backup, finalPath);
1231
+ if (!restorePublish.ok) {
1232
+ // Restore failed: confirm what actually remains at `backup` right now
1233
+ // (the no-replace rename may have left it there proven-untouched, or
1234
+ // its outcome may be unprovable — either way, verify rather than assume).
1235
+ const backupObservedNow = await confirmBackupObserved();
1236
+ throw new MarketplaceRestoreError("rollback_rename_failed", {
1237
+ sideEffectStarted: true,
1238
+ outcome: "uncertain",
1239
+ ...(backupObservedNow ? { retainedBackupPath: backup } : {}),
1240
+ });
1241
+ }
1242
+ const restoredDigest = await inspectCachedPlugin(finalPath);
1243
+ if (restoredDigest.status !== "present" || restoredDigest.digest !== currentArtifact.digest) {
1244
+ throw new MarketplaceRestoreError("rollback_digest_mismatch", {
1245
+ sideEffectStarted: true,
1246
+ outcome: "uncertain",
1247
+ retainedBackupPath: finalPath,
1248
+ });
1249
+ }
1250
+ if (error instanceof MarketplaceRestoreError) {
1251
+ // The FILESYSTEM half of rollback (artifact restored to `backup`,
1252
+ // digest-verified above) succeeded. But the triggering error may ALSO
1253
+ // carry its own already-computed outcome from an earlier, independent
1254
+ // verification — most notably `revertRegistryAndVerify`, whose
1255
+ // `uncertain` means the REGISTRY-level CAS revert could not be
1256
+ // confirmed even though the fs artifact rollback here succeeded.
1257
+ // Never force-upgrade that to `conflict`: the overall outcome must be
1258
+ // the worse of the two independent verifications, so a caller is never
1259
+ // told "clean rollback" when either half is unconfirmed.
1260
+ // The fs rollback already moved the artifact OUT of `backup` and back
1261
+ // onto finalPath (digest-verified above), so `backup` is no longer a
1262
+ // separate standalone artifact — no retainedBackupPath is carried
1263
+ // forward here regardless of which outcome wins.
1264
+ // `foreign_owner_conflict` on the triggering error described the state
1265
+ // BEFORE this rollback ran. The rollback has now removed our publication
1266
+ // and digest-verified the prior artifact back at the canonical name, so
1267
+ // nothing foreign remains there and reporting it as a foreign claim would
1268
+ // misdescribe the settled state; only genuine uncertainty survives.
1269
+ throw new MarketplaceRestoreError(error.reasonCode, {
1270
+ sideEffectStarted: true,
1271
+ outcome: error.outcome === "uncertain" ? "uncertain" : "conflict",
1272
+ ...(error.detail !== undefined ? { detail: error.detail } : {}),
1273
+ });
1274
+ }
1275
+ // A non-MarketplaceRestoreError (e.g. a raw filesystem error) reaching
1276
+ // here: reasonCode stays a fixed enum member ("restore_publish_failed"),
1277
+ // never the interpolated Error.message, which is only carried in the
1278
+ // private `detail` field.
1279
+ throw new MarketplaceRestoreError("restore_publish_failed", {
1280
+ sideEffectStarted: true,
1281
+ outcome: "conflict",
1282
+ ...(error instanceof Error ? { detail: error.message } : {}),
1283
+ });
1284
+ }
1285
+ });
1286
+ }
1287
+
1288
+ // ── Install / uninstall ────────────────────────────────────────────────────────────
225
1289
 
226
1290
  async installPlugin(
227
1291
  name: string,
@@ -248,104 +1312,112 @@ export class MarketplaceManager {
248
1312
 
249
1313
  const pluginId = buildPluginId(name, marketplace);
250
1314
 
251
- // 3. Check if already installed
252
- const instReg = await readInstalledPluginsRegistry(registryPath);
253
- const existing = getInstalledPlugin(instReg, pluginId);
254
- if (existing && existing.length > 0 && !force) {
255
- throw new Error(`Plugin "${pluginId}" is already installed. Use force option to reinstall.`);
256
- }
257
- const existingCachePaths = existing ? await this.#validateCacheDeletionTargets(pluginId, existing) : [];
258
-
259
- // 4. Resolve source path.
260
- // marketplaceClonePath is the marketplace root — the directory containing .Anthropic model-plugin/
261
- // catalogPath is <marketplacesCacheDir>/<name>/marketplace.json, so the root is two levels up.
262
- // For local sources the content was fetched from a local path; the stored catalog is a copy
263
- // under marketplacesCacheDir. We need the original source root for resolving relative paths.
264
- // Use: path.dirname(catalogPath) is <cacheDir>/<name>/, and that IS the stored copy root,
265
- // so `path.resolve(mktEntry.catalogPath, "../..")` = parent of <name>/ inside cacheDir
266
- // which is wrong for local sources. Instead, derive from the stored catalog directory:
267
- // stored at: <marketplacesCacheDir>/<catalogName>/marketplace.json
268
- // The marketplace root for local sources should be the actual local path, but we only have
269
- // sourceUri. For local sources, use path.resolve of sourceUri; for others use the cache dir.
270
- const marketplaceClonePath = this.#resolveMarketplaceRoot(mktEntry);
271
-
272
- // URL-sourced marketplaces only cache marketplace.json, not the full plugin tree.
273
- // Relative string sources ("./plugins/foo") cannot be resolved against the cache dir.
274
- if (mktEntry.sourceType === "url" && typeof pluginEntry.source === "string") {
275
- throw new Error(
276
- `Plugin "${name}" uses a relative source path but marketplace "${marketplace}" was added via URL. ` +
277
- `Relative sources require a git or local marketplace. Re-add the marketplace using its git URL.`,
278
- );
279
- }
280
-
281
- const { dir: sourcePath, tempCloneRoot } = await resolvePluginSource(pluginEntry, {
282
- marketplaceClonePath,
283
- catalogMetadata: catalog.metadata,
284
- tmpDir: os.tmpdir(),
285
- });
286
-
287
- // 5. Determine version: catalog entry > plugin manifest > git SHA > fallback
288
- let version!: string;
289
- let cachePath!: string;
290
- try {
291
- version = await this.#resolvePluginVersion(pluginEntry, sourcePath);
292
- if ([marketplace, name, version].some(component => /[. ]$/.test(component))) {
293
- throw new Error(`Invalid cache identity for plugin "${pluginId}"`);
1315
+ // Steps 3-6 (registry read-check, source resolve/cache, and the final
1316
+ // registry read-modify-write) run under the SAME registry lock every other
1317
+ // writer of this file (applyPluginRestore, uninstallPlugin,
1318
+ // setPluginEnabled) now takes, so the already-installed check and the
1319
+ // terminal write are CAS-consistent against concurrent writers rather than
1320
+ // racing them.
1321
+ return await withFileLock(registryPath, async () => {
1322
+ // 3. Check if already installed
1323
+ const instReg = await readInstalledPluginsRegistry(registryPath);
1324
+ const existing = getInstalledPlugin(instReg, pluginId);
1325
+ if (existing && existing.length > 0 && !force) {
1326
+ throw new Error(`Plugin "${pluginId}" is already installed. Use force option to reinstall.`);
294
1327
  }
295
- cachePath = await cachePlugin(sourcePath, this.#opts.pluginsCacheDir, marketplace, name, version);
296
- await this.#writeEmbeddedLspConfig(pluginEntry, cachePath);
297
- } finally {
298
- // Clean up temp clone dirs created by resolvePluginSource; leave user-supplied local dirs alone
299
- if (tempCloneRoot) {
300
- await fs.rm(tempCloneRoot, { recursive: true, force: true }).catch(() => {});
1328
+ const existingCachePaths = existing ? await this.#validateCacheDeletionTargets(pluginId, existing) : [];
1329
+
1330
+ // 4. Resolve source path.
1331
+ // marketplaceClonePath is the marketplace root — the directory containing .Anthropic model-plugin/
1332
+ // catalogPath is <marketplacesCacheDir>/<name>/marketplace.json, so the root is two levels up.
1333
+ // For local sources the content was fetched from a local path; the stored catalog is a copy
1334
+ // under marketplacesCacheDir. We need the original source root for resolving relative paths.
1335
+ // Use: path.dirname(catalogPath) is <cacheDir>/<name>/, and that IS the stored copy root,
1336
+ // so `path.resolve(mktEntry.catalogPath, "../..")` = parent of <name>/ inside cacheDir
1337
+ // which is wrong for local sources. Instead, derive from the stored catalog directory:
1338
+ // stored at: <marketplacesCacheDir>/<catalogName>/marketplace.json
1339
+ // The marketplace root for local sources should be the actual local path, but we only have
1340
+ // sourceUri. For local sources, use path.resolve of sourceUri; for others use the cache dir.
1341
+ const marketplaceClonePath = this.#resolveMarketplaceRoot(mktEntry);
1342
+
1343
+ // URL-sourced marketplaces only cache marketplace.json, not the full plugin tree.
1344
+ // Relative string sources ("./plugins/foo") cannot be resolved against the cache dir.
1345
+ if (mktEntry.sourceType === "url" && typeof pluginEntry.source === "string") {
1346
+ throw new Error(
1347
+ `Plugin "${name}" uses a relative source path but marketplace "${marketplace}" was added via URL. ` +
1348
+ `Relative sources require a git or local marketplace. Re-add the marketplace using its git URL.`,
1349
+ );
301
1350
  }
302
- }
303
1351
 
304
- // Only now clean up old entries — new cache succeeded, so it is safe to remove old ones.
305
- if (existing && existing.length > 0) {
306
- // Remove from scope-appropriate registry first, then cross-check refs before disk deletion.
307
- const prunedReg = removeInstalledPlugin(await readInstalledPluginsRegistry(registryPath), pluginId);
308
- await writeInstalledPluginsRegistry(registryPath, prunedReg);
309
-
310
- // Read both registries AFTER removal — only delete paths no longer referenced by either.
311
- const [userReg, projectReg] = await Promise.all([
312
- readInstalledPluginsRegistry(this.#opts.installedRegistryPath),
313
- this.#opts.projectInstalledRegistryPath
314
- ? readInstalledPluginsRegistry(this.#opts.projectInstalledRegistryPath)
315
- : Promise.resolve({ version: 2 as const, plugins: {} as Record<string, InstalledPluginEntry[]> }),
316
- ]);
317
- const referenced = this.#collectReferencedCacheIdentities(userReg, projectReg);
318
- const cachePathIdentity = this.#cacheDeletionIdentity(cachePath);
319
-
320
- for (const existingCachePath of existingCachePaths) {
321
- const existingIdentity = this.#cacheDeletionIdentity(existingCachePath);
322
- if (existingIdentity !== cachePathIdentity && !referenced.has(existingIdentity)) {
323
- await fs.rm(existingCachePath, { recursive: true, force: true });
1352
+ const { dir: sourcePath, tempCloneRoot } = await resolvePluginSource(pluginEntry, {
1353
+ marketplaceClonePath,
1354
+ catalogMetadata: catalog.metadata,
1355
+ tmpDir: os.tmpdir(),
1356
+ });
1357
+
1358
+ // 5. Determine version: catalog entry > plugin manifest > git SHA > fallback
1359
+ let version!: string;
1360
+ let cachePath!: string;
1361
+ try {
1362
+ version = await this.#resolvePluginVersion(pluginEntry, sourcePath);
1363
+ if ([marketplace, name, version].some(component => /[. ]$/.test(component))) {
1364
+ throw new Error(`Invalid cache identity for plugin "${pluginId}"`);
1365
+ }
1366
+ cachePath = await cachePlugin(sourcePath, this.#opts.pluginsCacheDir, marketplace, name, version);
1367
+ await this.#writeEmbeddedLspConfig(pluginEntry, cachePath);
1368
+ } finally {
1369
+ // Clean up temp clone dirs created by resolvePluginSource; leave user-supplied local dirs alone
1370
+ if (tempCloneRoot) {
1371
+ await fs.rm(tempCloneRoot, { recursive: true, force: true }).catch(() => {});
324
1372
  }
325
1373
  }
326
- }
327
-
328
- // 6. Build and register the entry, preserving enabled state from previous install
329
- const now = new Date().toISOString();
330
- // Carry over enabled flag from existing entry — a disabled plugin must stay disabled after upgrade
331
- const wasDisabled = existing?.some(e => e.enabled === false);
332
- const installedEntry: InstalledPluginEntry = {
333
- scope,
334
- installPath: cachePath,
335
- version,
336
- installedAt: now,
337
- lastUpdated: now,
338
- ...(wasDisabled ? { enabled: false } : {}),
339
- };
340
-
341
- const freshInstReg = await readInstalledPluginsRegistry(registryPath);
342
- const newInstReg = addInstalledPlugin(freshInstReg, pluginId, installedEntry);
343
- await writeInstalledPluginsRegistry(registryPath, newInstReg);
344
1374
 
345
- this.#clearCache();
1375
+ // Only now clean up old entries — new cache succeeded, so it is safe to remove old ones.
1376
+ if (existing && existing.length > 0) {
1377
+ // Remove from scope-appropriate registry first, then cross-check refs before disk deletion.
1378
+ const prunedReg = removeInstalledPlugin(await readInstalledPluginsRegistry(registryPath), pluginId);
1379
+ await writeInstalledPluginsRegistry(registryPath, prunedReg);
1380
+
1381
+ // Read both registries AFTER removal — only delete paths no longer referenced by either.
1382
+ const [userReg, projectReg] = await Promise.all([
1383
+ readInstalledPluginsRegistry(this.#opts.installedRegistryPath),
1384
+ this.#opts.projectInstalledRegistryPath
1385
+ ? readInstalledPluginsRegistry(this.#opts.projectInstalledRegistryPath)
1386
+ : Promise.resolve({ version: 2 as const, plugins: {} as Record<string, InstalledPluginEntry[]> }),
1387
+ ]);
1388
+ const referenced = this.#collectReferencedCacheIdentities(userReg, projectReg);
1389
+ const cachePathIdentity = this.#cacheDeletionIdentity(cachePath);
1390
+
1391
+ for (const existingCachePath of existingCachePaths) {
1392
+ const existingIdentity = this.#cacheDeletionIdentity(existingCachePath);
1393
+ if (existingIdentity !== cachePathIdentity && !referenced.has(existingIdentity)) {
1394
+ await fs.rm(existingCachePath, { recursive: true, force: true });
1395
+ }
1396
+ }
1397
+ }
346
1398
 
347
- logger.debug("Plugin installed", { pluginId, version, cachePath });
348
- return installedEntry;
1399
+ // 6. Build and register the entry, preserving enabled state from previous install
1400
+ const now = new Date().toISOString();
1401
+ // Carry over enabled flag from existing entry — a disabled plugin must stay disabled after upgrade
1402
+ const wasDisabled = existing?.some(e => e.enabled === false);
1403
+ const installedEntry: InstalledPluginEntry = {
1404
+ scope,
1405
+ installPath: cachePath,
1406
+ version,
1407
+ installedAt: now,
1408
+ lastUpdated: now,
1409
+ ...(wasDisabled ? { enabled: false } : {}),
1410
+ };
1411
+
1412
+ const freshInstReg = await readInstalledPluginsRegistry(registryPath);
1413
+ const newInstReg = addInstalledPlugin(freshInstReg, pluginId, installedEntry);
1414
+ await writeInstalledPluginsRegistry(registryPath, newInstReg);
1415
+
1416
+ this.#clearCache();
1417
+
1418
+ logger.debug("Plugin installed", { pluginId, version, cachePath });
1419
+ return installedEntry;
1420
+ });
349
1421
  }
350
1422
 
351
1423
  async #writeEmbeddedLspConfig(entry: MarketplacePluginEntry, cachePath: string): Promise<void> {
@@ -539,34 +1611,52 @@ export class MarketplaceManager {
539
1611
  }
540
1612
 
541
1613
  async uninstallPlugin(pluginId: string, scope?: "user" | "project"): Promise<void> {
542
- const {
543
- scope: targetScope,
544
- cachePaths: targetCachePaths,
545
- registry: targetReg,
546
- } = await this.resolveUninstallTarget(pluginId, scope);
547
- const registryPath = this.#registryPath(targetScope);
548
-
549
- const updatedReg = removeInstalledPlugin(targetReg, pluginId);
550
- await writeInstalledPluginsRegistry(registryPath, updatedReg);
551
-
552
- // Read both registries AFTER removal — only delete paths no longer referenced by either.
553
- const [freshUserReg, freshProjectReg] = await Promise.all([
554
- readInstalledPluginsRegistry(this.#opts.installedRegistryPath),
555
- this.#opts.projectInstalledRegistryPath
556
- ? readInstalledPluginsRegistry(this.#opts.projectInstalledRegistryPath)
557
- : Promise.resolve({ version: 2 as const, plugins: {} as Record<string, InstalledPluginEntry[]> }),
558
- ]);
559
- const referenced = this.#collectReferencedCacheIdentities(freshUserReg, freshProjectReg);
560
-
561
- for (const targetCachePath of targetCachePaths) {
562
- if (!referenced.has(this.#cacheDeletionIdentity(targetCachePath))) {
563
- await fs.rm(targetCachePath, { recursive: true, force: true });
564
- }
565
- }
1614
+ // Resolve the target and hold the same shared registry lock across both the
1615
+ // resolve and the write, so a concurrent writer cannot change which entry
1616
+ // this removes between the read and the write (the un-locked
1617
+ // resolveUninstallTarget remains available separately for read-only CLI
1618
+ // dry-run preview, which never mutates and therefore needs no CAS).
1619
+ const registryPathFor = (targetScope: "user" | "project") => this.#registryPath(targetScope);
1620
+ const userLockPath = this.#opts.installedRegistryPath;
1621
+ const projectLockPath = this.#opts.projectInstalledRegistryPath;
1622
+ // Lock BOTH scope registries in a fixed order (user, then project) so a
1623
+ // disambiguation decision that must inspect both scopes is made under
1624
+ // exclusion against every other writer of either file, avoiding deadlock
1625
+ // with any other caller that also locks in this same order.
1626
+ await withFileLock(userLockPath, async () => {
1627
+ const runUnderProjectLock = async (): Promise<void> => {
1628
+ const {
1629
+ scope: targetScope,
1630
+ cachePaths: targetCachePaths,
1631
+ registry: targetReg,
1632
+ } = await this.resolveUninstallTarget(pluginId, scope);
1633
+ const registryPath = registryPathFor(targetScope);
1634
+
1635
+ const updatedReg = removeInstalledPlugin(targetReg, pluginId);
1636
+ await writeInstalledPluginsRegistry(registryPath, updatedReg);
1637
+
1638
+ // Read both registries AFTER removal — only delete paths no longer referenced by either.
1639
+ const [freshUserReg, freshProjectReg] = await Promise.all([
1640
+ readInstalledPluginsRegistry(this.#opts.installedRegistryPath),
1641
+ this.#opts.projectInstalledRegistryPath
1642
+ ? readInstalledPluginsRegistry(this.#opts.projectInstalledRegistryPath)
1643
+ : Promise.resolve({ version: 2 as const, plugins: {} as Record<string, InstalledPluginEntry[]> }),
1644
+ ]);
1645
+ const referenced = this.#collectReferencedCacheIdentities(freshUserReg, freshProjectReg);
1646
+
1647
+ for (const targetCachePath of targetCachePaths) {
1648
+ if (!referenced.has(this.#cacheDeletionIdentity(targetCachePath))) {
1649
+ await fs.rm(targetCachePath, { recursive: true, force: true });
1650
+ }
1651
+ }
566
1652
 
567
- this.#clearCache();
1653
+ this.#clearCache();
568
1654
 
569
- logger.debug("Plugin uninstalled", { pluginId, scope: targetScope });
1655
+ logger.debug("Plugin uninstalled", { pluginId, scope: targetScope });
1656
+ };
1657
+ if (projectLockPath) await withFileLock(projectLockPath, runUnderProjectLock);
1658
+ else await runUnderProjectLock();
1659
+ });
570
1660
  }
571
1661
 
572
1662
  // ── Plugin state ──────────────────────────────────────────────────────────
@@ -607,52 +1697,62 @@ export class MarketplaceManager {
607
1697
  }
608
1698
 
609
1699
  async setPluginEnabled(pluginId: string, enabled: boolean, scope?: "user" | "project"): Promise<void> {
610
- const { userEntries, projectEntries, userReg, projectReg } = await this.#findInBothRegistries(pluginId);
611
-
612
- const inUser = userEntries && userEntries.length > 0;
613
- const inProject = projectEntries && projectEntries.length > 0;
614
-
615
- if (!inUser && !inProject) {
616
- throw new Error(`Plugin "${pluginId}" is not installed`);
617
- }
618
-
619
- // Disambiguation: if installed in both scopes and no explicit scope, require one.
620
- let targetScope: "user" | "project";
621
- if (inUser && inProject) {
622
- if (!scope) {
623
- throw new Error(
624
- `Plugin "${pluginId}" is installed in both user and project scope. Use --scope user or --scope project to specify which to modify.`,
625
- );
626
- }
627
- targetScope = scope;
628
- } else if (inProject) {
629
- if (scope === "user") {
630
- throw new Error(`Plugin "${pluginId}" is not installed in user scope`);
1700
+ // Same fixed user->project lock order as uninstallPlugin: the entire
1701
+ // disambiguation read plus the write runs under exclusion against every
1702
+ // other registry writer.
1703
+ const projectLockPath = this.#opts.projectInstalledRegistryPath;
1704
+ const runUnderProjectLock = async (): Promise<void> => {
1705
+ const { userEntries, projectEntries, userReg, projectReg } = await this.#findInBothRegistries(pluginId);
1706
+
1707
+ const inUser = userEntries && userEntries.length > 0;
1708
+ const inProject = projectEntries && projectEntries.length > 0;
1709
+
1710
+ if (!inUser && !inProject) {
1711
+ throw new Error(`Plugin "${pluginId}" is not installed`);
631
1712
  }
632
- targetScope = "project";
633
- } else {
634
- if (scope === "project") {
635
- throw new Error(`Plugin "${pluginId}" is not installed in project scope`);
1713
+
1714
+ // Disambiguation: if installed in both scopes and no explicit scope, require one.
1715
+ let targetScope: "user" | "project";
1716
+ if (inUser && inProject) {
1717
+ if (!scope) {
1718
+ throw new Error(
1719
+ `Plugin "${pluginId}" is installed in both user and project scope. Use --scope user or --scope project to specify which to modify.`,
1720
+ );
1721
+ }
1722
+ targetScope = scope;
1723
+ } else if (inProject) {
1724
+ if (scope === "user") {
1725
+ throw new Error(`Plugin "${pluginId}" is not installed in user scope`);
1726
+ }
1727
+ targetScope = "project";
1728
+ } else {
1729
+ if (scope === "project") {
1730
+ throw new Error(`Plugin "${pluginId}" is not installed in project scope`);
1731
+ }
1732
+ targetScope = "user";
636
1733
  }
637
- targetScope = "user";
638
- }
639
1734
 
640
- const reg = targetScope === "project" ? projectReg : userReg;
641
- const entries = targetScope === "project" ? projectEntries! : userEntries!;
642
- const registryPath = this.#registryPath(targetScope);
1735
+ const reg = targetScope === "project" ? projectReg : userReg;
1736
+ const entries = targetScope === "project" ? projectEntries! : userEntries!;
1737
+ const registryPath = this.#registryPath(targetScope);
643
1738
 
644
- const updated = {
645
- ...reg,
646
- plugins: {
647
- ...reg.plugins,
648
- [pluginId]: entries.map(e => ({ ...e, enabled })),
649
- },
650
- };
651
- await writeInstalledPluginsRegistry(registryPath, updated);
1739
+ const updated = {
1740
+ ...reg,
1741
+ plugins: {
1742
+ ...reg.plugins,
1743
+ [pluginId]: entries.map(e => ({ ...e, enabled })),
1744
+ },
1745
+ };
1746
+ await writeInstalledPluginsRegistry(registryPath, updated);
652
1747
 
653
- this.#clearCache();
1748
+ this.#clearCache();
654
1749
 
655
- logger.debug("Plugin enabled state changed", { pluginId, enabled, scope: targetScope });
1750
+ logger.debug("Plugin enabled state changed", { pluginId, enabled, scope: targetScope });
1751
+ };
1752
+ await withFileLock(this.#opts.installedRegistryPath, async () => {
1753
+ if (projectLockPath) await withFileLock(projectLockPath, runUnderProjectLock);
1754
+ else await runUnderProjectLock();
1755
+ });
656
1756
  }
657
1757
 
658
1758
  // ── Update / upgrade ─────────────────────────────────────────────────────