@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.
- package/CHANGELOG.md +180 -1
- package/dist/types/capability/fs.d.ts +37 -4
- package/dist/types/capability/index.d.ts +19 -0
- package/dist/types/capability/types.d.ts +9 -0
- package/dist/types/cli/doctor/args.d.ts +36 -0
- package/dist/types/cli/doctor/checks.d.ts +18 -0
- package/dist/types/cli/doctor/config-repairs.d.ts +38 -0
- package/dist/types/cli/doctor/context.d.ts +89 -0
- package/dist/types/cli/doctor/files.d.ts +27 -0
- package/dist/types/cli/doctor/ids.d.ts +20 -0
- package/dist/types/cli/doctor/install-repairs.d.ts +94 -0
- package/dist/types/cli/doctor/journal.d.ts +43 -0
- package/dist/types/cli/doctor/managed-link.d.ts +39 -0
- package/dist/types/cli/doctor/native-probe.d.ts +2 -0
- package/dist/types/cli/doctor/permission-repairs.d.ts +25 -0
- package/dist/types/cli/doctor/plugin-quarantine.d.ts +77 -0
- package/dist/types/cli/doctor/plugin-restore.d.ts +40 -0
- package/dist/types/cli/doctor/plugin-targets.d.ts +11 -0
- package/dist/types/cli/doctor/probe-client.d.ts +9 -0
- package/dist/types/cli/doctor/probe-types.d.ts +18 -0
- package/dist/types/cli/doctor/probe.d.ts +3 -0
- package/dist/types/cli/doctor/projection-probe.d.ts +2 -0
- package/dist/types/cli/doctor/repairs.d.ts +28 -0
- package/dist/types/cli/doctor/report.d.ts +27 -0
- package/dist/types/cli/doctor/runner.d.ts +4 -0
- package/dist/types/cli/doctor/safety.d.ts +10 -0
- package/dist/types/cli/doctor/service-repairs.d.ts +8 -0
- package/dist/types/cli/doctor/service-targets.d.ts +41 -0
- package/dist/types/cli/doctor/types.d.ts +145 -0
- package/dist/types/cli/doctor-cli.d.ts +7 -0
- package/dist/types/cli/doctor-supervisor.d.ts +121 -0
- package/dist/types/cli/doctor-worker.d.ts +29 -0
- package/dist/types/cli/install-activation.d.ts +158 -0
- package/dist/types/cli/macos-community-app.d.ts +41 -0
- package/dist/types/cli/mcp-cli.d.ts +0 -11
- package/dist/types/cli/update-cli.d.ts +41 -4
- package/dist/types/cli-main.d.ts +174 -0
- package/dist/types/cli.d.ts +1 -171
- package/dist/types/commands/doctor.d.ts +7 -0
- package/dist/types/commands/sdk.d.ts +17 -8
- package/dist/types/config/autorouting-tier-map.d.ts +19 -0
- package/dist/types/config/file-lock.d.ts +27 -3
- package/dist/types/config/model-preset-registry.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +12 -5
- package/dist/types/config/model-resolver.d.ts +16 -3
- package/dist/types/config/models-config-schema.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-gate-codec.d.ts +1 -1
- package/dist/types/coordinator-mcp/session-reaper.d.ts +15 -5
- package/dist/types/cursor.d.ts +9 -9
- package/dist/types/discovery/agents-md.d.ts +6 -1
- package/dist/types/discovery/helpers.d.ts +36 -7
- package/dist/types/discovery/index.d.ts +1 -1
- package/dist/types/discovery/mcp-json.d.ts +2 -0
- package/dist/types/extensibility/extensions/loader.d.ts +22 -1
- package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +14 -1
- package/dist/types/extensibility/gjc-plugins/paths.d.ts +2 -1
- package/dist/types/extensibility/gjc-plugins/types.d.ts +25 -0
- package/dist/types/extensibility/plugins/manager.d.ts +33 -4
- package/dist/types/extensibility/plugins/marketplace/cache.d.ts +6 -0
- package/dist/types/extensibility/plugins/marketplace/manager.d.ts +186 -0
- package/dist/types/extensibility/plugins/marketplace/registry.d.ts +27 -0
- package/dist/types/extensibility/plugins/marketplace/source-resolver.d.ts +16 -1
- package/dist/types/extensibility/runtime-skill-discovery.d.ts +16 -3
- package/dist/types/gjc-runtime/autoresearch-runtime.d.ts +6 -0
- package/dist/types/gjc-runtime/launch-worktree.d.ts +53 -0
- package/dist/types/gjc-runtime/session-layout.d.ts +8 -0
- package/dist/types/gjc-runtime/session-state-lock.d.ts +15 -0
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +13 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +6 -0
- package/dist/types/main.d.ts +19 -1
- package/dist/types/modes/acp/acp-agent.d.ts +40 -0
- package/dist/types/modes/components/agent-dashboard.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/interactive-mode.d.ts +1 -1
- package/dist/types/runtime-mcp/config-writer.d.ts +47 -0
- package/dist/types/runtime-mcp/policy-json-edit.d.ts +60 -0
- package/dist/types/runtime-mcp/startup-policy.d.ts +4 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -2
- package/dist/types/sdk/acp/adapter.d.ts +5 -1
- package/dist/types/sdk/broker/broker.d.ts +51 -0
- package/dist/types/sdk/broker/daemon-entry.d.ts +39 -0
- package/dist/types/sdk/broker/discovery.d.ts +38 -0
- package/dist/types/sdk/broker/doctor-restart.d.ts +79 -0
- package/dist/types/sdk/broker/endpoint-authority.d.ts +7 -0
- package/dist/types/sdk/broker/ensure.d.ts +14 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/broker/process-incarnation.d.ts +50 -0
- package/dist/types/sdk/broker/runtime.d.ts +26 -0
- package/dist/types/sdk/broker/session-index.d.ts +13 -4
- package/dist/types/sdk/broker/worktree-occupancy.d.ts +14 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +16 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +101 -6
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +16 -0
- package/dist/types/sdk/bus/daemon-startup-exclusion.d.ts +43 -0
- package/dist/types/sdk/bus/doctor-daemon-restart-client.d.ts +51 -0
- package/dist/types/sdk/bus/doctor-daemon-restart.d.ts +39 -0
- package/dist/types/sdk/bus/index.d.ts +2 -0
- package/dist/types/sdk/bus/notification-service.d.ts +3 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +11 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +1 -1
- package/dist/types/sdk/bus/telegram-daemon-control.d.ts +5 -0
- package/dist/types/sdk/bus/telegram-daemon-owner-registry.d.ts +1 -0
- package/dist/types/sdk/bus/telegram-daemon.d.ts +36 -5
- package/dist/types/sdk/cli/session-cli.d.ts +1 -0
- package/dist/types/sdk/host/session-runtime.d.ts +4 -0
- package/dist/types/sdk/host/surface-policy.d.ts +3 -1
- package/dist/types/sdk/lifecycle/service.d.ts +44 -10
- package/dist/types/sdk/prompt-deadline-lease.d.ts +6 -3
- package/dist/types/sdk/prompt-deadline-manager.d.ts +2 -2
- package/dist/types/sdk/prompt-failure.d.ts +47 -0
- package/dist/types/sdk/prompt-status.d.ts +19 -0
- package/dist/types/sdk/router/session-router.d.ts +3 -4
- package/dist/types/sdk/service-artifact-paths.d.ts +16 -0
- package/dist/types/sdk/session-authority.d.ts +9 -0
- package/dist/types/sdk/session.d.ts +3 -1
- package/dist/types/session/agent-session.d.ts +1 -1
- package/dist/types/session/blob-store.d.ts +1 -1
- package/dist/types/session/fallback-chain-controller.d.ts +3 -6
- package/dist/types/session/internal/managed-session-scope.d.ts +44 -0
- package/dist/types/session/primary-control-surface.d.ts +4 -0
- package/dist/types/session/session-manager.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +6 -0
- package/dist/types/tools/ask.d.ts +25 -0
- package/dist/types/tools/image-gen.d.ts +0 -4
- package/dist/types/tools/skill-discovery.d.ts +7 -5
- package/dist/types/tools/ultragoal-ask-guard.d.ts +7 -4
- package/dist/types/utils/changelog.d.ts +0 -9
- package/dist/types/utils/mupdf-wasm.d.ts +1 -0
- package/package.json +9 -8
- package/scripts/compile-args.ts +8 -4
- package/src/capability/fs.ts +395 -17
- package/src/capability/index.ts +273 -10
- package/src/capability/types.ts +9 -0
- package/src/cli/customize-doctor.ts +18 -12
- package/src/cli/doctor/args.ts +236 -0
- package/src/cli/doctor/checks.ts +431 -0
- package/src/cli/doctor/config-repairs.ts +517 -0
- package/src/cli/doctor/context.ts +149 -0
- package/src/cli/doctor/files.ts +149 -0
- package/src/cli/doctor/ids.ts +115 -0
- package/src/cli/doctor/install-repairs.ts +335 -0
- package/src/cli/doctor/journal.ts +423 -0
- package/src/cli/doctor/managed-link.ts +514 -0
- package/src/cli/doctor/native-probe.ts +13 -0
- package/src/cli/doctor/permission-repairs.ts +242 -0
- package/src/cli/doctor/plugin-quarantine.ts +377 -0
- package/src/cli/doctor/plugin-restore.ts +59 -0
- package/src/cli/doctor/plugin-targets.ts +373 -0
- package/src/cli/doctor/probe-client.ts +195 -0
- package/src/cli/doctor/probe-types.ts +21 -0
- package/src/cli/doctor/probe.ts +42 -0
- package/src/cli/doctor/projection-probe.ts +249 -0
- package/src/cli/doctor/repairs.ts +1157 -0
- package/src/cli/doctor/report.ts +176 -0
- package/src/cli/doctor/runner.ts +342 -0
- package/src/cli/doctor/safety.ts +40 -0
- package/src/cli/doctor/service-repairs.ts +452 -0
- package/src/cli/doctor/service-targets.ts +274 -0
- package/src/cli/doctor/types.ts +164 -0
- package/src/cli/doctor-cli.ts +62 -0
- package/src/cli/doctor-supervisor.ts +571 -0
- package/src/cli/doctor-worker.ts +181 -0
- package/src/cli/install-activation.ts +666 -0
- package/src/cli/macos-community-app.ts +1449 -0
- package/src/cli/mcp-cli.ts +11 -21
- package/src/cli/nofile-limit.ts +5 -1
- package/src/cli/plugin-cli.ts +6 -1
- package/src/cli/update-cli.ts +362 -264
- package/src/cli-main.ts +517 -0
- package/src/cli.ts +64 -433
- package/src/commands/autoresearch.ts +11 -5
- package/src/commands/completion.ts +1 -1
- package/src/commands/doctor.ts +13 -0
- package/src/commands/sdk.ts +58 -10
- package/src/commands/ultragoal.ts +11 -5
- package/src/config/autorouting-tier-map.ts +7 -0
- package/src/config/file-lock.ts +736 -71
- package/src/config/mcp-schema.json +3 -3
- package/src/config/model-preset-registry.ts +1 -0
- package/src/config/model-registry.ts +80 -27
- package/src/config/model-resolver.ts +33 -4
- package/src/config/models-config-schema.ts +2 -0
- package/src/config/settings-schema.ts +6 -2
- package/src/coordinator-mcp/question-gate-codec.ts +1 -0
- package/src/coordinator-mcp/server.ts +534 -51
- package/src/coordinator-mcp/session-reaper.ts +64 -8
- package/src/cursor.ts +223 -53
- package/src/discovery/agents-md.ts +74 -8
- package/src/discovery/agents.ts +32 -7
- package/src/discovery/builtin.ts +184 -75
- package/src/discovery/claude-plugins.ts +85 -17
- package/src/discovery/cline.ts +38 -10
- package/src/discovery/cursor.ts +19 -6
- package/src/discovery/gemini.ts +62 -22
- package/src/discovery/github.ts +13 -3
- package/src/discovery/helpers.ts +483 -89
- package/src/discovery/index.ts +1 -0
- package/src/discovery/mcp-json.ts +36 -10
- package/src/discovery/opencode.ts +36 -19
- package/src/discovery/ssh.ts +11 -8
- package/src/discovery/windsurf.ts +12 -3
- package/src/exec/bash-executor.ts +10 -3
- package/src/exec/isolated-shell.ts +15 -2
- package/src/extensibility/extensions/loader.ts +88 -26
- package/src/extensibility/gjc-plugins/lifecycle.ts +189 -0
- package/src/extensibility/gjc-plugins/paths.ts +4 -1
- package/src/extensibility/gjc-plugins/types.ts +27 -0
- package/src/extensibility/plugins/manager.ts +200 -22
- package/src/extensibility/plugins/marketplace/cache.ts +55 -2
- package/src/extensibility/plugins/marketplace/manager.ts +1262 -162
- package/src/extensibility/plugins/marketplace/registry.ts +153 -0
- package/src/extensibility/plugins/marketplace/source-resolver.ts +116 -10
- package/src/extensibility/runtime-skill-discovery.ts +61 -9
- package/src/extensibility/skill-management.ts +7 -1
- package/src/gjc-runtime/autoresearch-runtime.ts +10 -1
- package/src/gjc-runtime/launch-worktree.test.ts +248 -0
- package/src/gjc-runtime/launch-worktree.ts +275 -38
- package/src/gjc-runtime/repository-binding.ts +26 -1
- package/src/gjc-runtime/session-layout.ts +10 -0
- package/src/gjc-runtime/session-state-lock.ts +330 -23
- package/src/gjc-runtime/session-state-sidecar.ts +328 -80
- package/src/gjc-runtime/ultragoal-runtime.ts +10 -1
- package/src/harness-control-plane/session-lease.ts +2 -2
- package/src/internal-urls/docs-index.generated.ts +28 -26
- package/src/main.ts +150 -29
- package/src/memories/index.ts +24 -2
- package/src/modes/acp/acp-agent.ts +522 -68
- package/src/modes/components/agent-dashboard.ts +3 -1
- package/src/modes/components/model-selector.ts +28 -19
- package/src/modes/components/plugin-settings.ts +2 -1
- package/src/modes/controllers/selector-controller.ts +15 -9
- package/src/modes/interactive-mode.ts +21 -3
- package/src/prompts/system/system-prompt.md +5 -1
- package/src/prompts/tools/browser.md +1 -1
- package/src/prompts/tools/move-session.md +4 -0
- package/src/prompts/tools/read.md +2 -1
- package/src/prompts/tools/skill-discovery.md +6 -5
- package/src/prompts/tools/task.md +1 -0
- package/src/runtime-mcp/config-writer.ts +228 -0
- package/src/runtime-mcp/oauth-discovery.ts +114 -14
- package/src/runtime-mcp/policy-json-edit.ts +129 -0
- package/src/runtime-mcp/startup-policy.ts +14 -0
- package/src/runtime-mcp/types.ts +2 -2
- package/src/sdk/acp/adapter.ts +27 -12
- package/src/sdk/broker/broker.ts +273 -44
- package/src/sdk/broker/daemon-entry.ts +119 -0
- package/src/sdk/broker/discovery.ts +173 -20
- package/src/sdk/broker/doctor-restart.ts +206 -0
- package/src/sdk/broker/endpoint-authority.ts +36 -0
- package/src/sdk/broker/ensure.ts +49 -14
- package/src/sdk/broker/lifecycle.ts +174 -42
- package/src/sdk/broker/process-incarnation.ts +86 -2
- package/src/sdk/broker/runtime.ts +145 -21
- package/src/sdk/broker/session-index.ts +136 -22
- package/src/sdk/broker/spawn-substrate.ts +4 -1
- package/src/sdk/broker/transport.ts +5 -0
- package/src/sdk/broker/worktree-occupancy.ts +21 -1
- package/src/sdk/bus/chat-daemon-cli.ts +63 -0
- package/src/sdk/bus/chat-daemon-control.ts +260 -64
- package/src/sdk/bus/chat-daemon-runtime.ts +141 -25
- package/src/sdk/bus/daemon-startup-exclusion.ts +119 -0
- package/src/sdk/bus/doctor-daemon-restart-client.ts +291 -0
- package/src/sdk/bus/doctor-daemon-restart.ts +92 -0
- package/src/sdk/bus/index.ts +252 -51
- package/src/sdk/bus/kind-aware-reconciliation.ts +76 -41
- package/src/sdk/bus/notification-service.ts +21 -3
- package/src/sdk/bus/reconciliation-store.ts +17 -11
- package/src/sdk/bus/telegram-daemon-cli.ts +91 -5
- package/src/sdk/bus/telegram-daemon-contract.ts +1 -1
- package/src/sdk/bus/telegram-daemon-control.ts +47 -0
- package/src/sdk/bus/telegram-daemon-owner-registry.ts +1 -1
- package/src/sdk/bus/telegram-daemon.ts +178 -13
- package/src/sdk/cli/session-cli.ts +100 -12
- package/src/sdk/host/host.ts +2 -7
- package/src/sdk/host/session-runtime.test.ts +329 -8
- package/src/sdk/host/session-runtime.ts +202 -50
- package/src/sdk/host/surface-policy.ts +8 -1
- package/src/sdk/lifecycle/service.ts +199 -22
- package/src/sdk/mcp/server.ts +1 -1
- package/src/sdk/prompt-deadline-lease.ts +12 -4
- package/src/sdk/prompt-deadline-manager.ts +5 -7
- package/src/sdk/prompt-failure.ts +148 -0
- package/src/sdk/prompt-status.ts +30 -1
- package/src/sdk/prompt-watchdog.ts +11 -3
- package/src/sdk/protocol/operation-inventory.generated.json +18 -0
- package/src/sdk/protocol/operation-registry.ts +20 -8
- package/src/sdk/router/session-router.ts +8 -3
- package/src/sdk/service-artifact-paths.ts +26 -0
- package/src/sdk/session-authority.ts +10 -0
- package/src/sdk/session.ts +361 -194
- package/src/session/agent-session.ts +398 -93
- package/src/session/blob-store.ts +9 -9
- package/src/session/fallback-chain-controller.ts +3 -7
- package/src/session/internal/managed-session-scope.ts +445 -29
- package/src/session/internal/managed-session-storage.ts +38 -6
- package/src/session/primary-control-surface.ts +12 -0
- package/src/session/session-manager.ts +11 -3
- package/src/session/session-storage.ts +1 -1
- package/src/session-import/codex.ts +6 -1
- package/src/session-import/redact.ts +7 -0
- package/src/skill-state/workflow-mutation-guard.ts +645 -39
- package/src/slash-commands/builtin-registry.ts +7 -3
- package/src/system-prompt.ts +8 -0
- package/src/task/executor.ts +36 -3
- package/src/task/types.ts +27 -5
- package/src/tools/ask.ts +114 -34
- package/src/tools/browser/actions.ts +2 -2
- package/src/tools/browser/tab-worker.ts +11 -8
- package/src/tools/descriptors.ts +6 -1
- package/src/tools/fetch.ts +7 -2
- package/src/tools/image-gen.ts +33 -34
- package/src/tools/read.ts +78 -32
- package/src/tools/skill-discovery.ts +17 -7
- package/src/tools/tool-catalog.generated.ts +8 -8
- package/src/tools/ultragoal-ask-guard.ts +16 -26
- package/src/utils/changelog.ts +7 -1
- package/src/utils/git.ts +3 -1
- package/src/utils/markit.ts +24 -2
- package/src/utils/mupdf-wasm.ts +26 -0
- 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
|
-
|
|
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
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
//
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
//
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
if (
|
|
323
|
-
await fs.rm(
|
|
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
|
-
|
|
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
|
-
|
|
348
|
-
|
|
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
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
const
|
|
548
|
-
|
|
549
|
-
const
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
1653
|
+
this.#clearCache();
|
|
568
1654
|
|
|
569
|
-
|
|
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
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
const
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
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
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
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
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1735
|
+
const reg = targetScope === "project" ? projectReg : userReg;
|
|
1736
|
+
const entries = targetScope === "project" ? projectEntries! : userEntries!;
|
|
1737
|
+
const registryPath = this.#registryPath(targetScope);
|
|
643
1738
|
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
1748
|
+
this.#clearCache();
|
|
654
1749
|
|
|
655
|
-
|
|
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 ─────────────────────────────────────────────────────
|