@cjhyy/code-shell-core 0.8.9 → 0.8.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/dist/automation/scheduler.js +49 -0
  2. package/dist/automation/store.d.ts +1 -1
  3. package/dist/automation/store.js +184 -10
  4. package/dist/cli/agent-server-stdio.js +7 -0
  5. package/dist/credentials/store.d.ts +14 -0
  6. package/dist/credentials/store.js +245 -42
  7. package/dist/engine/engine.js +45 -6
  8. package/dist/engine/file-history-hook.js +24 -5
  9. package/dist/engine/run-types.d.ts +9 -0
  10. package/dist/engine/turn-loop.js +9 -8
  11. package/dist/goal/lifecycle.d.ts +2 -0
  12. package/dist/goal/lifecycle.js +56 -33
  13. package/dist/index.d.ts +2 -3
  14. package/dist/index.internal.d.ts +1 -0
  15. package/dist/index.internal.js +1 -0
  16. package/dist/index.js +2 -2
  17. package/dist/links/cli.d.ts +2 -0
  18. package/dist/links/cli.js +11 -4
  19. package/dist/model-catalog/index.js +19 -4
  20. package/dist/model-catalog/save-entry.js +122 -61
  21. package/dist/model-catalog/types.js +27 -23
  22. package/dist/panel-apps/installer.js +27 -14
  23. package/dist/panel-apps/registry.js +60 -12
  24. package/dist/plugins/installedPlugins.d.ts +4 -0
  25. package/dist/plugins/installedPlugins.js +70 -30
  26. package/dist/plugins/installer/types.d.ts +12 -12
  27. package/dist/plugins/installer/update.js +37 -38
  28. package/dist/plugins/knownMarketplaces.d.ts +7 -3
  29. package/dist/plugins/knownMarketplaces.js +127 -23
  30. package/dist/plugins/pluginCatalog.js +18 -4
  31. package/dist/plugins/pluginHookApproval.js +56 -60
  32. package/dist/plugins/pluginMcpApproval.js +50 -52
  33. package/dist/profile/catalog-store.js +39 -4
  34. package/dist/profile/catalog.js +55 -15
  35. package/dist/profile/store.js +51 -21
  36. package/dist/protocol/chat-session-manager.d.ts +9 -0
  37. package/dist/protocol/chat-session-manager.js +13 -0
  38. package/dist/protocol/chat-session.d.ts +5 -0
  39. package/dist/protocol/chat-session.js +1 -0
  40. package/dist/protocol/server.d.ts +2 -0
  41. package/dist/protocol/server.js +75 -29
  42. package/dist/protocol/types.d.ts +8 -0
  43. package/dist/run/FileRunStore.d.ts +2 -0
  44. package/dist/run/FileRunStore.js +153 -18
  45. package/dist/run/Heartbeat.js +63 -4
  46. package/dist/services/auto-dream.js +39 -17
  47. package/dist/services/session-memory.js +107 -8
  48. package/dist/session/file-history.d.ts +63 -2
  49. package/dist/session/file-history.js +593 -86
  50. package/dist/session/session-manager.d.ts +1 -0
  51. package/dist/session/session-manager.js +52 -21
  52. package/dist/session/transcript.js +33 -3
  53. package/dist/session/undo-target.d.ts +15 -6
  54. package/dist/session/undo-target.js +26 -9
  55. package/dist/settings/manager.d.ts +22 -3
  56. package/dist/settings/manager.js +185 -50
  57. package/dist/settings/schema.d.ts +3 -3
  58. package/dist/sources/adapters/local-files.js +49 -4
  59. package/dist/sources/catalog.js +64 -18
  60. package/dist/sources/types.d.ts +3 -3
  61. package/dist/sources/types.js +7 -4
  62. package/dist/themes/installer.js +192 -28
  63. package/dist/tool-system/builtin/add-marketplace.js +21 -1
  64. package/dist/tool-system/builtin/cron.d.ts +2 -1
  65. package/dist/tool-system/builtin/cron.js +20 -6
  66. package/dist/tool-system/builtin/index.js +44 -0
  67. package/dist/tool-system/builtin/install-capability.d.ts +52 -0
  68. package/dist/tool-system/builtin/install-capability.js +1057 -0
  69. package/dist/tool-system/builtin/skill.js +3 -1
  70. package/dist/tool-system/executor.js +1 -0
  71. package/dist/tool-system/registry.js +5 -0
  72. package/dist/tool-system/sandbox/index.d.ts +1 -0
  73. package/dist/tool-system/sandbox/index.js +4 -1
  74. package/dist/utils/file-mutex.d.ts +2 -0
  75. package/dist/utils/file-mutex.js +29 -4
  76. package/package.json +2 -1
@@ -7,7 +7,7 @@ import { pluginInstallDir } from "./paths.js";
7
7
  import { installPluginFromPath } from "./install.js";
8
8
  import { installPluginFromSource } from "./installFromSource.js";
9
9
  import { parseSource } from "./parseSource.js";
10
- import { pluginInstallKey, readInstalledPlugins, removeInstallEntries, writeInstalledPlugins, } from "../installedPlugins.js";
10
+ import { pluginInstallKey, mutateInstalledPlugins, readInstalledPlugins, removeInstallEntries, } from "../installedPlugins.js";
11
11
  import { pluginHookInstallRecord } from "../pluginHookIntegrity.js";
12
12
  import { pluginMcpInstallRecord } from "../pluginMcpIntegrity.js";
13
13
  import { readPluginMcp } from "./loadPluginMcp.js";
@@ -40,37 +40,37 @@ export async function reinstallAtomic(name, source, doInstall) {
40
40
  try {
41
41
  await doInstall();
42
42
  if (savedEntries) {
43
- const data = readInstalledPlugins();
44
- const installed = data.plugins[key] ?? [];
45
- let changed = false;
46
- for (const entry of installed) {
47
- const next = pluginHookInstallRecord(entry.installPath, savedEntries);
48
- const nextMcp = pluginMcpInstallRecord(entry.installPath, Object.keys(readPluginMcp(entry.installPath, name)).length > 0, savedEntries);
49
- if (entry.hookDigest !== next.hookDigest ||
50
- entry.approvedHookDigest !== next.approvedHookDigest ||
51
- JSON.stringify(entry.approvedHookSnapshot) !==
52
- JSON.stringify(next.approvedHookSnapshot) ||
53
- entry.mcpDigest !== nextMcp.mcpDigest ||
54
- entry.approvedMcpDigest !== nextMcp.approvedMcpDigest) {
55
- entry.hookDigest = next.hookDigest;
56
- if (next.approvedHookDigest)
57
- entry.approvedHookDigest = next.approvedHookDigest;
58
- else
59
- delete entry.approvedHookDigest;
60
- if (next.approvedHookSnapshot)
61
- entry.approvedHookSnapshot = next.approvedHookSnapshot;
62
- else
63
- delete entry.approvedHookSnapshot;
64
- entry.mcpDigest = nextMcp.mcpDigest;
65
- if (nextMcp.approvedMcpDigest)
66
- entry.approvedMcpDigest = nextMcp.approvedMcpDigest;
67
- else
68
- delete entry.approvedMcpDigest;
69
- changed = true;
43
+ mutateInstalledPlugins((data) => {
44
+ const installed = data.plugins[key] ?? [];
45
+ let changed = false;
46
+ for (const entry of installed) {
47
+ const next = pluginHookInstallRecord(entry.installPath, savedEntries);
48
+ const nextMcp = pluginMcpInstallRecord(entry.installPath, Object.keys(readPluginMcp(entry.installPath, name)).length > 0, savedEntries);
49
+ if (entry.hookDigest !== next.hookDigest ||
50
+ entry.approvedHookDigest !== next.approvedHookDigest ||
51
+ JSON.stringify(entry.approvedHookSnapshot) !==
52
+ JSON.stringify(next.approvedHookSnapshot) ||
53
+ entry.mcpDigest !== nextMcp.mcpDigest ||
54
+ entry.approvedMcpDigest !== nextMcp.approvedMcpDigest) {
55
+ entry.hookDigest = next.hookDigest;
56
+ if (next.approvedHookDigest)
57
+ entry.approvedHookDigest = next.approvedHookDigest;
58
+ else
59
+ delete entry.approvedHookDigest;
60
+ if (next.approvedHookSnapshot)
61
+ entry.approvedHookSnapshot = next.approvedHookSnapshot;
62
+ else
63
+ delete entry.approvedHookSnapshot;
64
+ entry.mcpDigest = nextMcp.mcpDigest;
65
+ if (nextMcp.approvedMcpDigest)
66
+ entry.approvedMcpDigest = nextMcp.approvedMcpDigest;
67
+ else
68
+ delete entry.approvedMcpDigest;
69
+ changed = true;
70
+ }
70
71
  }
71
- }
72
- if (changed)
73
- writeInstalledPlugins(data);
72
+ return changed ? { value: data } : {};
73
+ });
74
74
  }
75
75
  }
76
76
  catch (err) {
@@ -78,13 +78,12 @@ export async function reinstallAtomic(name, source, doInstall) {
78
78
  await rm(dir, { recursive: true, force: true });
79
79
  await rename(backup, dir);
80
80
  if (savedEntries) {
81
- // Restore in a single read-modify-write rather than one append per entry,
82
- // which both narrows the race window against any concurrent writer and
83
- // avoids re-appending onto whatever partial state doInstall() may have
84
- // left for this key.
85
- const data = readInstalledPlugins();
86
- data.plugins[key] = savedEntries;
87
- writeInstalledPlugins(data);
81
+ // Restore under the registry lock so a concurrent install of a different
82
+ // plugin cannot be lost while this plugin rolls back.
83
+ mutateInstalledPlugins((data) => {
84
+ data.plugins[key] = savedEntries;
85
+ return { value: data };
86
+ });
88
87
  }
89
88
  throw new PluginInstallError(`update failed; the old version of '${name}' was kept: ` +
90
89
  `${err instanceof Error ? err.message : String(err)}. Source: ${source}.`);
@@ -1,8 +1,12 @@
1
+ /** Read/write ~/.code-shell/plugins/known_marketplaces.json. */
2
+ import type { KnownMarketplaces, KnownMarketplace } from "./types.js";
1
3
  /**
2
- * Read/write ~/.code-shell/plugins/known_marketplaces.json.
3
- * File format is byte-compatible with Claude Code's analogous file.
4
+ * The single source of truth for what a marketplace name may be.
5
+ *
6
+ * Exported so entry points (AddMarketplace) reject a bad name BEFORE cloning,
7
+ * rather than discovering it at persist time and orphaning the clone.
4
8
  */
5
- import type { KnownMarketplaces, KnownMarketplace } from "./types.js";
9
+ export declare function isValidMarketplaceName(name: string): boolean;
6
10
  export declare function knownMarketplacesPath(): string;
7
11
  export declare function readKnownMarketplaces(): KnownMarketplaces;
8
12
  export declare function writeKnownMarketplaces(data: KnownMarketplaces): void;
@@ -1,46 +1,150 @@
1
+ /** Read/write ~/.code-shell/plugins/known_marketplaces.json. */
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import { mutateJsonFile } from "../utils/file-mutex.js";
6
+ const MAX_MARKETPLACE_FILE_BYTES = 4 * 1024 * 1024;
7
+ const MAX_MARKETPLACES = 256;
8
+ const MAX_NAME_LENGTH = 128;
9
+ const MAX_PATH_LENGTH = 4_096;
10
+ const MAX_TIMESTAMP_LENGTH = 128;
11
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
12
+ const GITHUB_REPO_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,38}\/[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/;
1
13
  /**
2
- * Read/write ~/.code-shell/plugins/known_marketplaces.json.
3
- * File format is byte-compatible with Claude Code's analogous file.
14
+ * The single source of truth for what a marketplace name may be.
15
+ *
16
+ * Exported so entry points (AddMarketplace) reject a bad name BEFORE cloning,
17
+ * rather than discovering it at persist time and orphaning the clone.
4
18
  */
5
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
6
- import { dirname, join } from "node:path";
7
- import { homedir } from "node:os";
19
+ export function isValidMarketplaceName(name) {
20
+ return name.length <= MAX_NAME_LENGTH && NAME_RE.test(name);
21
+ }
8
22
  function userHome() {
9
23
  return process.env.HOME ?? homedir();
10
24
  }
11
25
  export function knownMarketplacesPath() {
12
26
  return join(userHome(), ".code-shell", "plugins", "known_marketplaces.json");
13
27
  }
28
+ function marketplaceOf(value) {
29
+ if (!value || typeof value !== "object" || Array.isArray(value))
30
+ return undefined;
31
+ const raw = value;
32
+ if (!raw.source || typeof raw.source !== "object" || Array.isArray(raw.source))
33
+ return undefined;
34
+ const source = raw.source;
35
+ const normalizedSource = source.source === "github" &&
36
+ typeof source.repo === "string" &&
37
+ GITHUB_REPO_RE.test(source.repo)
38
+ ? { source: "github", repo: source.repo }
39
+ : source.source === "git" &&
40
+ typeof source.url === "string" &&
41
+ source.url.length > 0 &&
42
+ source.url.length <= MAX_PATH_LENGTH &&
43
+ !source.url.includes("\0")
44
+ ? { source: "git", url: source.url }
45
+ : undefined;
46
+ if (!normalizedSource ||
47
+ typeof raw.installLocation !== "string" ||
48
+ raw.installLocation.length === 0 ||
49
+ raw.installLocation.length > MAX_PATH_LENGTH ||
50
+ raw.installLocation.includes("\0") ||
51
+ typeof raw.lastUpdated !== "string" ||
52
+ raw.lastUpdated.length === 0 ||
53
+ raw.lastUpdated.length > MAX_TIMESTAMP_LENGTH) {
54
+ return undefined;
55
+ }
56
+ const format = raw.format === "claude-code" || raw.format === "codex" || raw.format === "universal"
57
+ ? raw.format
58
+ : undefined;
59
+ if (raw.format !== undefined && !format)
60
+ return undefined;
61
+ return {
62
+ source: normalizedSource,
63
+ installLocation: raw.installLocation,
64
+ lastUpdated: raw.lastUpdated,
65
+ ...(format ? { format } : {}),
66
+ };
67
+ }
68
+ function parseKnownMarketplaces(raw) {
69
+ if (raw === undefined)
70
+ return Object.create(null);
71
+ const parsed = JSON.parse(raw);
72
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
73
+ throw new Error("known marketplaces registry must be an object");
74
+ }
75
+ const entries = Object.entries(parsed);
76
+ if (entries.length > MAX_MARKETPLACES)
77
+ throw new Error("too many known marketplaces");
78
+ const result = Object.create(null);
79
+ for (const [name, value] of entries) {
80
+ // ISOLATE, don't throw. NAME_RE is stricter than the writers that produced
81
+ // these keys (AddMarketplace accepts any non-empty string, and
82
+ // assertSafePluginName permits spaces/non-ASCII), so a single legacy name
83
+ // like "My Market" used to make the whole registry read as empty AND make
84
+ // every subsequent write throw — unrecoverable, because the read reported
85
+ // nothing for the user to fix. Skipping matches registryOf() in
86
+ // installedPlugins.ts, which is the correct shape for this file family.
87
+ if (name.length > MAX_NAME_LENGTH || !NAME_RE.test(name))
88
+ continue;
89
+ const marketplace = marketplaceOf(value);
90
+ if (!marketplace)
91
+ continue;
92
+ result[name] = marketplace;
93
+ }
94
+ return result;
95
+ }
14
96
  export function readKnownMarketplaces() {
15
97
  const path = knownMarketplacesPath();
16
- if (!existsSync(path))
17
- return {};
98
+ let descriptor;
18
99
  try {
19
- const raw = JSON.parse(readFileSync(path, "utf-8"));
20
- if (raw && typeof raw === "object" && !Array.isArray(raw)) {
21
- return raw;
100
+ const metadata = lstatSync(path);
101
+ if (metadata.isSymbolicLink() ||
102
+ !metadata.isFile() ||
103
+ metadata.size > MAX_MARKETPLACE_FILE_BYTES) {
104
+ return Object.create(null);
105
+ }
106
+ descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
107
+ const opened = fstatSync(descriptor);
108
+ if (!opened.isFile() || opened.size > MAX_MARKETPLACE_FILE_BYTES) {
109
+ return Object.create(null);
22
110
  }
111
+ return parseKnownMarketplaces(readFileSync(descriptor, "utf8"));
23
112
  }
24
113
  catch {
25
- // Corrupted file: treat as empty so the caller can recover by re-adding.
114
+ return Object.create(null);
26
115
  }
27
- return {};
116
+ finally {
117
+ if (descriptor !== undefined)
118
+ closeSync(descriptor);
119
+ }
120
+ }
121
+ function mutateKnownMarketplaces(change) {
122
+ return mutateJsonFile(knownMarketplacesPath(), {
123
+ parse: parseKnownMarketplaces,
124
+ serialize: (value) => `${JSON.stringify(value, null, 2)}\n`,
125
+ mutation: change,
126
+ mode: 0o600,
127
+ maxBytes: MAX_MARKETPLACE_FILE_BYTES,
128
+ });
28
129
  }
29
130
  export function writeKnownMarketplaces(data) {
30
- const path = knownMarketplacesPath();
31
- mkdirSync(dirname(path), { recursive: true });
32
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
131
+ const validated = parseKnownMarketplaces(JSON.stringify(data));
132
+ mutateKnownMarketplaces(() => ({ value: validated }));
33
133
  }
34
134
  export function upsertKnownMarketplace(name, entry) {
35
- const data = readKnownMarketplaces();
36
- data[name] = entry;
37
- writeKnownMarketplaces(data);
135
+ const marketplace = marketplaceOf(entry);
136
+ if (!NAME_RE.test(name) || !marketplace)
137
+ throw new Error("invalid known marketplace");
138
+ mutateKnownMarketplaces((current) => ({ value: { ...current, [name]: marketplace } }));
38
139
  }
39
140
  export function removeKnownMarketplace(name) {
40
- const data = readKnownMarketplaces();
41
- if (!(name in data))
141
+ if (!NAME_RE.test(name))
42
142
  return false;
43
- delete data[name];
44
- writeKnownMarketplaces(data);
45
- return true;
143
+ return (mutateKnownMarketplaces((current) => {
144
+ if (!Object.prototype.hasOwnProperty.call(current, name))
145
+ return { result: false };
146
+ const next = { ...current };
147
+ delete next[name];
148
+ return { value: next, result: true };
149
+ }) ?? false);
46
150
  }
@@ -1,10 +1,11 @@
1
- import { existsSync, readFileSync } from "node:fs";
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync } from "node:fs";
2
2
  import { createHash } from "node:crypto";
3
3
  import { join } from "node:path";
4
4
  import { readInstalledPlugins } from "./installedPlugins.js";
5
5
  import { pluginsRoot } from "./installer/paths.js";
6
6
  import { CANONICAL_PLUGIN_MANIFEST_FILE, CanonicalPluginManifest, } from "./installer/types.js";
7
7
  import { resolveSafePluginPath } from "./pluginInstaller.js";
8
+ const MAX_CANONICAL_MANIFEST_BYTES = 4 * 1024 * 1024;
8
9
  /**
9
10
  * Stable content revision used to bind a user review to the exact template
10
11
  * instantiated later. Install paths and timestamps are intentionally excluded.
@@ -20,14 +21,27 @@ export function pluginAutomationTemplateRevision(installKey, template) {
20
21
  }
21
22
  function readCanonicalManifest(installPath) {
22
23
  const file = join(installPath, CANONICAL_PLUGIN_MANIFEST_FILE);
23
- if (!existsSync(file))
24
- return null;
24
+ let descriptor;
25
25
  try {
26
- return CanonicalPluginManifest.parse(JSON.parse(readFileSync(file, "utf-8")));
26
+ const metadata = lstatSync(file);
27
+ if (metadata.isSymbolicLink() ||
28
+ !metadata.isFile() ||
29
+ metadata.size > MAX_CANONICAL_MANIFEST_BYTES) {
30
+ return null;
31
+ }
32
+ descriptor = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
33
+ const opened = fstatSync(descriptor);
34
+ if (!opened.isFile() || opened.size > MAX_CANONICAL_MANIFEST_BYTES)
35
+ return null;
36
+ return CanonicalPluginManifest.parse(JSON.parse(readFileSync(descriptor, "utf-8")));
27
37
  }
28
38
  catch {
29
39
  return null;
30
40
  }
41
+ finally {
42
+ if (descriptor !== undefined)
43
+ closeSync(descriptor);
44
+ }
31
45
  }
32
46
  function identityFromInstallKey(installKey, manifest) {
33
47
  const at = installKey.lastIndexOf("@");
@@ -1,4 +1,4 @@
1
- import { readInstalledPlugins, writeInstalledPlugins } from "./installedPlugins.js";
1
+ import { mutateInstalledPlugins, readInstalledPlugins } from "./installedPlugins.js";
2
2
  import { inspectPluginHooks, pluginHookReviewSnapshot, pluginHookApprovalState, } from "./pluginHookIntegrity.js";
3
3
  function pluginNameFromKey(key) {
4
4
  const at = key.lastIndexOf("@");
@@ -86,75 +86,71 @@ export function reviewPluginHooks(target) {
86
86
  }
87
87
  /** Approve the exact install-time digest; changed-on-disk hooks cannot be blessed. */
88
88
  export function approvePluginHooks(target) {
89
- const data = readInstalledPlugins();
90
- const keys = matchingInstallKeys(data, target);
91
- const out = [];
92
- let writeNeeded = false;
93
- for (const installKey of keys) {
94
- for (const entry of data.plugins[installKey] ?? []) {
95
- let entryChanged = false;
96
- const snapshot = inspectPluginHooks(entry.installPath);
97
- if (snapshot.state === "invalid") {
98
- throw new Error(`plugin "${installKey}" hooks definition is invalid and cannot be approved: ${snapshot.error ?? "unknown error"}`);
99
- }
100
- if (entry.hookDigest && snapshot.digest !== entry.hookDigest) {
101
- throw new Error(`plugin "${installKey}" hooks changed after install; reinstall or update before approving`);
102
- }
103
- if (!entry.hookDigest) {
104
- entry.hookDigest = snapshot.digest;
105
- entryChanged = true;
106
- writeNeeded = true;
107
- }
108
- if (entry.approvedHookDigest !== entry.hookDigest) {
109
- entry.approvedHookDigest = entry.hookDigest;
110
- entryChanged = true;
111
- writeNeeded = true;
112
- }
113
- const nextReview = pluginHookReviewSnapshot(snapshot);
114
- if (JSON.stringify(entry.approvedHookSnapshot) !== JSON.stringify(nextReview)) {
115
- entry.approvedHookSnapshot = nextReview;
116
- entryChanged = true;
117
- writeNeeded = true;
89
+ return (mutateInstalledPlugins((data) => {
90
+ const keys = matchingInstallKeys(data, target);
91
+ const out = [];
92
+ let writeNeeded = false;
93
+ for (const installKey of keys) {
94
+ for (const entry of data.plugins[installKey] ?? []) {
95
+ let entryChanged = false;
96
+ const snapshot = inspectPluginHooks(entry.installPath);
97
+ if (snapshot.state === "invalid") {
98
+ throw new Error(`plugin "${installKey}" hooks definition is invalid and cannot be approved: ${snapshot.error ?? "unknown error"}`);
99
+ }
100
+ if (entry.hookDigest && snapshot.digest !== entry.hookDigest) {
101
+ throw new Error(`plugin "${installKey}" hooks changed after install; reinstall or update before approving`);
102
+ }
103
+ if (!entry.hookDigest) {
104
+ entry.hookDigest = snapshot.digest;
105
+ entryChanged = true;
106
+ }
107
+ if (entry.approvedHookDigest !== entry.hookDigest) {
108
+ entry.approvedHookDigest = entry.hookDigest;
109
+ entryChanged = true;
110
+ }
111
+ const nextReview = pluginHookReviewSnapshot(snapshot);
112
+ if (JSON.stringify(entry.approvedHookSnapshot) !== JSON.stringify(nextReview)) {
113
+ entry.approvedHookSnapshot = nextReview;
114
+ entryChanged = true;
115
+ }
116
+ writeNeeded ||= entryChanged;
117
+ out.push(result(installKey, entry, entryChanged, snapshot));
118
118
  }
119
- out.push(result(installKey, entry, entryChanged, snapshot));
120
119
  }
121
- }
122
- if (writeNeeded)
123
- writeInstalledPlugins(data);
124
- return out;
120
+ return { ...(writeNeeded ? { value: data } : {}), result: out };
121
+ }) ?? []);
125
122
  }
126
123
  /**
127
124
  * Revoke hook execution. Legacy entries are first migrated to a recorded
128
125
  * digest so revocation fails closed instead of falling back to legacy trust.
129
126
  */
130
127
  export function revokePluginHooks(target) {
131
- const data = readInstalledPlugins();
132
- const keys = matchingInstallKeys(data, target);
133
- const out = [];
134
- let writeNeeded = false;
135
- for (const installKey of keys) {
136
- for (const entry of data.plugins[installKey] ?? []) {
137
- let entryChanged = false;
138
- const snapshot = inspectPluginHooks(entry.installPath);
139
- if (!entry.hookDigest) {
140
- entry.hookDigest = snapshot.digest;
141
- entryChanged = true;
142
- }
143
- if (snapshot.hasExecutableHooks || snapshot.state === "invalid") {
144
- if (entry.approvedHookDigest !== undefined) {
145
- delete entry.approvedHookDigest;
128
+ return (mutateInstalledPlugins((data) => {
129
+ const keys = matchingInstallKeys(data, target);
130
+ const out = [];
131
+ let writeNeeded = false;
132
+ for (const installKey of keys) {
133
+ for (const entry of data.plugins[installKey] ?? []) {
134
+ let entryChanged = false;
135
+ const snapshot = inspectPluginHooks(entry.installPath);
136
+ if (!entry.hookDigest) {
137
+ entry.hookDigest = snapshot.digest;
146
138
  entryChanged = true;
147
139
  }
140
+ if (snapshot.hasExecutableHooks || snapshot.state === "invalid") {
141
+ if (entry.approvedHookDigest !== undefined) {
142
+ delete entry.approvedHookDigest;
143
+ entryChanged = true;
144
+ }
145
+ }
146
+ else if (entry.approvedHookDigest !== entry.hookDigest) {
147
+ entry.approvedHookDigest = entry.hookDigest;
148
+ entryChanged = true;
149
+ }
150
+ writeNeeded ||= entryChanged;
151
+ out.push(result(installKey, entry, entryChanged, snapshot));
148
152
  }
149
- else if (entry.approvedHookDigest !== entry.hookDigest) {
150
- entry.approvedHookDigest = entry.hookDigest;
151
- entryChanged = true;
152
- }
153
- writeNeeded ||= entryChanged;
154
- out.push(result(installKey, entry, entryChanged, snapshot));
155
153
  }
156
- }
157
- if (writeNeeded)
158
- writeInstalledPlugins(data);
159
- return out;
154
+ return { ...(writeNeeded ? { value: data } : {}), result: out };
155
+ }) ?? []);
160
156
  }
@@ -1,4 +1,4 @@
1
- import { readInstalledPlugins, writeInstalledPlugins } from "./installedPlugins.js";
1
+ import { mutateInstalledPlugins, readInstalledPlugins } from "./installedPlugins.js";
2
2
  import { readPluginMcp } from "./installer/loadPluginMcp.js";
3
3
  import { pluginMcpApprovalState, pluginMcpDigest, } from "./pluginMcpIntegrity.js";
4
4
  function pluginNameFromKey(key) {
@@ -44,67 +44,65 @@ export function listPluginMcpTrust() {
44
44
  }
45
45
  /** Approve only the install-time MCP bytes; tampered bytes require update/reinstall. */
46
46
  export function approvePluginMcp(target) {
47
- const data = readInstalledPlugins();
48
- const keys = matchingInstallKeys(data, target);
49
- const out = [];
50
- let writeNeeded = false;
51
- for (const installKey of keys) {
52
- for (const entry of data.plugins[installKey] ?? []) {
53
- let entryChanged = false;
54
- const currentDigest = pluginMcpDigest(entry.installPath);
55
- if (entry.mcpDigest && currentDigest !== entry.mcpDigest) {
56
- throw new Error(`plugin "${installKey}" MCP config changed after install; reinstall or update before approving`);
57
- }
58
- if (!entry.mcpDigest) {
59
- entry.mcpDigest = currentDigest;
60
- entryChanged = true;
61
- }
62
- if (entry.approvedMcpDigest !== entry.mcpDigest) {
63
- entry.approvedMcpDigest = entry.mcpDigest;
64
- entryChanged = true;
47
+ return (mutateInstalledPlugins((data) => {
48
+ const keys = matchingInstallKeys(data, target);
49
+ const out = [];
50
+ let writeNeeded = false;
51
+ for (const installKey of keys) {
52
+ for (const entry of data.plugins[installKey] ?? []) {
53
+ let entryChanged = false;
54
+ const currentDigest = pluginMcpDigest(entry.installPath);
55
+ if (entry.mcpDigest && currentDigest !== entry.mcpDigest) {
56
+ throw new Error(`plugin "${installKey}" MCP config changed after install; reinstall or update before approving`);
57
+ }
58
+ if (!entry.mcpDigest) {
59
+ entry.mcpDigest = currentDigest;
60
+ entryChanged = true;
61
+ }
62
+ if (entry.approvedMcpDigest !== entry.mcpDigest) {
63
+ entry.approvedMcpDigest = entry.mcpDigest;
64
+ entryChanged = true;
65
+ }
66
+ writeNeeded ||= entryChanged;
67
+ out.push(trustEntry(installKey, entry, entryChanged));
65
68
  }
66
- writeNeeded ||= entryChanged;
67
- out.push(trustEntry(installKey, entry, entryChanged));
68
69
  }
69
- }
70
- if (writeNeeded)
71
- writeInstalledPlugins(data);
72
- return out;
70
+ return { ...(writeNeeded ? { value: data } : {}), result: out };
71
+ }) ?? []);
73
72
  }
74
73
  /**
75
74
  * Revoke plugin MCP execution. Legacy entries first record their current
76
75
  * digest, preventing revocation from falling back to legacy compatibility.
77
76
  */
78
77
  export function revokePluginMcp(target) {
79
- const data = readInstalledPlugins();
80
- const keys = matchingInstallKeys(data, target);
81
- const out = [];
82
- let writeNeeded = false;
83
- for (const installKey of keys) {
84
- const plugin = pluginNameFromKey(installKey);
85
- for (const entry of data.plugins[installKey] ?? []) {
86
- let entryChanged = false;
87
- const hasServers = Object.keys(readPluginMcp(entry.installPath, plugin)).length > 0;
88
- const currentDigest = pluginMcpDigest(entry.installPath);
89
- if (!entry.mcpDigest) {
90
- entry.mcpDigest = currentDigest;
91
- entryChanged = true;
92
- }
93
- if (hasServers || currentDigest !== entry.mcpDigest) {
94
- if (entry.approvedMcpDigest !== undefined) {
95
- delete entry.approvedMcpDigest;
78
+ return (mutateInstalledPlugins((data) => {
79
+ const keys = matchingInstallKeys(data, target);
80
+ const out = [];
81
+ let writeNeeded = false;
82
+ for (const installKey of keys) {
83
+ const plugin = pluginNameFromKey(installKey);
84
+ for (const entry of data.plugins[installKey] ?? []) {
85
+ let entryChanged = false;
86
+ const hasServers = Object.keys(readPluginMcp(entry.installPath, plugin)).length > 0;
87
+ const currentDigest = pluginMcpDigest(entry.installPath);
88
+ if (!entry.mcpDigest) {
89
+ entry.mcpDigest = currentDigest;
96
90
  entryChanged = true;
97
91
  }
92
+ if (hasServers || currentDigest !== entry.mcpDigest) {
93
+ if (entry.approvedMcpDigest !== undefined) {
94
+ delete entry.approvedMcpDigest;
95
+ entryChanged = true;
96
+ }
97
+ }
98
+ else if (entry.approvedMcpDigest !== entry.mcpDigest) {
99
+ entry.approvedMcpDigest = entry.mcpDigest;
100
+ entryChanged = true;
101
+ }
102
+ writeNeeded ||= entryChanged;
103
+ out.push(trustEntry(installKey, entry, entryChanged));
98
104
  }
99
- else if (entry.approvedMcpDigest !== entry.mcpDigest) {
100
- entry.approvedMcpDigest = entry.mcpDigest;
101
- entryChanged = true;
102
- }
103
- writeNeeded ||= entryChanged;
104
- out.push(trustEntry(installKey, entry, entryChanged));
105
105
  }
106
- }
107
- if (writeNeeded)
108
- writeInstalledPlugins(data);
109
- return out;
106
+ return { ...(writeNeeded ? { value: data } : {}), result: out };
107
+ }) ?? []);
110
108
  }