@nanhara/hara 0.126.0 → 0.127.0

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.
@@ -1,189 +1,526 @@
1
- // Plugins a distribution unit that drops skills / roles / MCP servers onto disk; it owns nothing at
2
- // runtime. The existing loaders pick the contents up (skillsDirs/loadRoles append the resolvers below;
3
- // index.ts merges pluginMcpServers into the MCP set). Manifest is Claude-Code-compatible: we read
4
- // .claude-plugin/plugin.json, .hara-plugin/plugin.json, or a bare plugin.json at the plugin root.
5
- import { readFileSync, existsSync, mkdirSync, readdirSync, rmSync, cpSync, symlinkSync, chmodSync } from "node:fs";
6
- import { join, resolve, isAbsolute } from "node:path";
1
+ // Plugins are untrusted distribution units. Installation validates every executable contribution,
2
+ // activates a same-filesystem staging directory atomically, and records the exact directory/bin ownership
3
+ // used by uninstall. Runtime loaders re-validate installed manifests instead of trusting installation time.
4
+ import { chmodSync, cpSync, existsSync, lstatSync, readlinkSync, readdirSync, realpathSync, renameSync, rmSync, symlinkSync, } from "node:fs";
5
+ import { randomUUID } from "node:crypto";
7
6
  import { homedir } from "node:os";
7
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
8
8
  import { execFileSync } from "node:child_process";
9
9
  import { readRawConfig, updateRawConfig } from "../config.js";
10
+ import { bindPrivateHaraStateFile, ensurePrivateStateSubdirectory, readPrivateStateFileSnapshotSync, removePrivateStateFile, writePrivateStateFileSync, } from "../security/private-state.js";
11
+ import { bindPluginMcpServers, readVerifiedPluginManifest, safePluginId, safePluginRelativePath, } from "./manifest.js";
10
12
  export function pluginsDir() {
11
13
  return join(homedir(), ".hara", "plugins");
12
14
  }
13
- /** Where plugin-contributed CLI commands are symlinked. Add to PATH to use them (e.g. `export PATH="$HOME/.hara/bin:$PATH"`). */
14
15
  export function haraBinDir() {
15
16
  return join(homedir(), ".hara", "bin");
16
17
  }
17
- /** Symlink a plugin's `bin` entries into ~/.hara/bin (chmod +x the targets). Returns the command names linked. */
18
- function linkPluginBins(root, manifest) {
19
- if (!manifest.bin)
20
- return [];
21
- const dir = haraBinDir();
22
- mkdirSync(dir, { recursive: true });
23
- const linked = [];
24
- for (const [name, rel] of Object.entries(manifest.bin)) {
25
- const target = join(root, rel);
26
- if (!existsSync(target))
27
- continue;
28
- try {
29
- chmodSync(target, 0o755);
30
- }
31
- catch {
32
- /* best-effort */
33
- }
34
- const link = join(dir, name);
35
- try {
36
- rmSync(link, { force: true });
37
- symlinkSync(target, link);
38
- linked.push(name);
39
- }
40
- catch {
41
- /* skip a bin we can't link */
42
- }
18
+ function pluginStorage() {
19
+ return ensurePrivateStateSubdirectory(homedir(), [".hara", "plugins"]).path;
20
+ }
21
+ function pluginBinStorage() {
22
+ return ensurePrivateStateSubdirectory(homedir(), [".hara", "bin"]).path;
23
+ }
24
+ function pluginRoot(name) {
25
+ const safe = safePluginId(name);
26
+ const root = join(pluginStorage(), safe);
27
+ if (resolve(root) !== join(pluginStorage(), safe))
28
+ throw new Error("plugin root escaped the Hara plugin directory");
29
+ return root;
30
+ }
31
+ function receiptBinding(name) {
32
+ return bindPrivateHaraStateFile(homedir(), ["plugin-receipts"], `${safePluginId(name)}.json`);
33
+ }
34
+ function rootIdentity(path) {
35
+ const info = lstatSync(path);
36
+ if (!info.isDirectory() || info.isSymbolicLink())
37
+ throw new Error(`plugin root '${path}' is not an owned real directory`);
38
+ return { rootDev: String(info.dev), rootIno: String(info.ino) };
39
+ }
40
+ function pluginBins(manifest) {
41
+ return { ...(manifest.bin ?? {}) };
42
+ }
43
+ function parseReceipt(raw, expectedName) {
44
+ let value;
45
+ try {
46
+ value = JSON.parse(raw);
47
+ }
48
+ catch {
49
+ throw new Error(`plugin '${expectedName}' ownership receipt is invalid JSON`);
50
+ }
51
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
52
+ throw new Error(`plugin '${expectedName}' ownership receipt is invalid`);
53
+ }
54
+ const input = value;
55
+ const allowed = new Set(["schemaVersion", "name", "root", "rootDev", "rootIno", "manifestSha256", "bins"]);
56
+ if (Object.keys(input).some((key) => !allowed.has(key)) || input.schemaVersion !== 1) {
57
+ throw new Error(`plugin '${expectedName}' ownership receipt has an unsupported schema`);
43
58
  }
44
- return linked;
59
+ const name = safePluginId(input.name, "receipt plugin name");
60
+ if (name !== expectedName)
61
+ throw new Error(`plugin '${expectedName}' ownership receipt belongs to '${name}'`);
62
+ if (typeof input.root !== "string"
63
+ || typeof input.rootDev !== "string"
64
+ || typeof input.rootIno !== "string"
65
+ || typeof input.manifestSha256 !== "string"
66
+ || !/^[a-f0-9]{64}$/u.test(input.manifestSha256)
67
+ || !input.bins
68
+ || typeof input.bins !== "object"
69
+ || Array.isArray(input.bins))
70
+ throw new Error(`plugin '${expectedName}' ownership receipt is incomplete`);
71
+ const bins = {};
72
+ for (const [rawName, rawPath] of Object.entries(input.bins)) {
73
+ const binName = safePluginId(rawName, "receipt command name");
74
+ bins[binName] = safePluginRelativePath(rawPath, `receipt command '${binName}'`);
75
+ }
76
+ return {
77
+ schemaVersion: 1,
78
+ name,
79
+ root: resolve(input.root),
80
+ rootDev: input.rootDev,
81
+ rootIno: input.rootIno,
82
+ manifestSha256: input.manifestSha256,
83
+ bins,
84
+ };
85
+ }
86
+ function readReceipt(name) {
87
+ const binding = receiptBinding(name);
88
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, 256 * 1024);
89
+ if (!snapshot)
90
+ return null;
91
+ return { binding, snapshot, raw: snapshot.text, receipt: parseReceipt(snapshot.text, name) };
92
+ }
93
+ function writeReceipt(name, root, verified) {
94
+ const identity = rootIdentity(root);
95
+ const receipt = {
96
+ schemaVersion: 1,
97
+ name,
98
+ root: resolve(root),
99
+ ...identity,
100
+ manifestSha256: verified.sha256,
101
+ bins: pluginBins(verified.manifest),
102
+ };
103
+ writePrivateStateFileSync(receiptBinding(name), `${JSON.stringify(receipt, null, 2)}\n`);
104
+ return receipt;
45
105
  }
46
- /** Remove a plugin's linked bins (on uninstall). */
47
- function unlinkPluginBins(manifest) {
48
- if (!manifest?.bin)
106
+ function restoreReceipt(name, raw) {
107
+ const binding = receiptBinding(name);
108
+ if (raw !== null) {
109
+ writePrivateStateFileSync(binding, raw);
49
110
  return;
50
- for (const name of Object.keys(manifest.bin)) {
51
- try {
52
- rmSync(join(haraBinDir(), name), { force: true });
53
- }
54
- catch {
55
- /* ignore */
56
- }
57
111
  }
112
+ const current = readPrivateStateFileSnapshotSync(binding.path, 256 * 1024);
113
+ if (current)
114
+ removePrivateStateFile(binding.path, current, binding.directory);
58
115
  }
59
- const MANIFEST_PATHS = [".claude-plugin/plugin.json", ".hara-plugin/plugin.json", "plugin.json"];
60
- function readManifest(root) {
61
- for (const rel of MANIFEST_PATHS) {
62
- const p = join(root, rel);
63
- if (!existsSync(p))
64
- continue;
65
- try {
66
- return JSON.parse(readFileSync(p, "utf8"));
67
- }
68
- catch {
69
- return null;
70
- }
116
+ function verifyReceipt(receipt, root, verified) {
117
+ const canonicalRoot = resolve(root);
118
+ const identity = rootIdentity(canonicalRoot);
119
+ if (receipt.root !== canonicalRoot
120
+ || receipt.rootDev !== identity.rootDev
121
+ || receipt.rootIno !== identity.rootIno
122
+ || receipt.manifestSha256 !== verified.sha256)
123
+ throw new Error(`plugin '${receipt.name}' changed after installation; refusing an ownership-sensitive operation`);
124
+ }
125
+ function resolvedLinkTarget(link) {
126
+ const target = readlinkSync(link);
127
+ return resolve(dirname(link), target);
128
+ }
129
+ function entryExists(path) {
130
+ try {
131
+ lstatSync(path);
132
+ return true;
133
+ }
134
+ catch (error) {
135
+ if (error?.code === "ENOENT")
136
+ return false;
137
+ throw error;
71
138
  }
72
- return null;
73
139
  }
74
- /** Every installed plugin under ~/.hara/plugins (regardless of enabled state). */
140
+ function expectedBinTarget(root, rel) {
141
+ const target = resolve(root, safePluginRelativePath(rel, "plugin command target"));
142
+ const relToRoot = relative(resolve(root), target);
143
+ if (relToRoot.startsWith("..") || isAbsolute(relToRoot))
144
+ throw new Error("plugin command target escaped its package root");
145
+ return target;
146
+ }
147
+ function preflightBinLink(root, name, rel, allowMissing = true) {
148
+ const link = join(pluginBinStorage(), safePluginId(name, "plugin command name"));
149
+ if (!entryExists(link)) {
150
+ if (allowMissing)
151
+ return;
152
+ throw new Error(`plugin command link '${name}' is missing`);
153
+ }
154
+ const info = lstatSync(link);
155
+ if (!info.isSymbolicLink() || resolvedLinkTarget(link) !== expectedBinTarget(root, rel)) {
156
+ throw new Error(`refusing to replace or remove foreign command entry '${link}'`);
157
+ }
158
+ }
159
+ function unlinkBinLink(root, name, rel) {
160
+ const link = join(pluginBinStorage(), safePluginId(name, "plugin command name"));
161
+ if (!entryExists(link))
162
+ return;
163
+ preflightBinLink(root, name, rel, false);
164
+ rmSync(link);
165
+ }
166
+ function ensureBinLink(root, name, rel) {
167
+ const dir = pluginBinStorage();
168
+ const link = join(dir, safePluginId(name, "plugin command name"));
169
+ const target = expectedBinTarget(root, rel);
170
+ const targetInfo = lstatSync(target);
171
+ if (!targetInfo.isFile() || targetInfo.isSymbolicLink() || targetInfo.nlink !== 1) {
172
+ throw new Error(`plugin command '${name}' is not a verified regular file`);
173
+ }
174
+ if (entryExists(link)) {
175
+ preflightBinLink(root, name, rel, false);
176
+ return;
177
+ }
178
+ try {
179
+ chmodSync(target, 0o755);
180
+ }
181
+ catch (error) {
182
+ if (process.platform !== "win32")
183
+ throw error;
184
+ }
185
+ symlinkSync(target, link, "file");
186
+ }
187
+ function restoreBinLinks(root, bins) {
188
+ for (const [name, rel] of Object.entries(bins))
189
+ ensureBinLink(root, name, rel);
190
+ }
191
+ function safeTemporaryRemove(path) {
192
+ if (!existsSync(path))
193
+ return;
194
+ const info = lstatSync(path);
195
+ if (info.isSymbolicLink()) {
196
+ rmSync(path);
197
+ return;
198
+ }
199
+ if (!info.isDirectory())
200
+ throw new Error(`refusing to recursively remove unexpected plugin staging entry '${path}'`);
201
+ rmSync(path, { recursive: true, force: true });
202
+ }
203
+ function readInstalled(root, expectedName, options = {}) {
204
+ const info = lstatSync(root);
205
+ if (!info.isDirectory() || info.isSymbolicLink())
206
+ throw new Error(`installed plugin root '${root}' is not a real directory`);
207
+ const verified = readVerifiedPluginManifest(root, options);
208
+ const name = safePluginId(verified.manifest.name);
209
+ if (expectedName && name !== expectedName)
210
+ throw new Error(`installed plugin '${expectedName}' claims the different name '${name}'`);
211
+ return {
212
+ plugin: { name, version: verified.manifest.version || "0.0.0", root: realpathSync.native(root), manifest: verified.manifest },
213
+ verified,
214
+ };
215
+ }
216
+ /** Every installed plugin under ~/.hara/plugins (regardless of enabled state). Invalid roots never execute.
217
+ * A structurally valid legacy root remains usable so an explicit reinstall can replace it and create a receipt;
218
+ * ownership-sensitive removal still refuses it until that migration happens. */
75
219
  export function listInstalled() {
76
- const dir = pluginsDir();
77
- if (!existsSync(dir))
78
- return [];
220
+ const dir = pluginStorage();
79
221
  const out = [];
80
222
  for (const entry of readdirSync(dir)) {
81
- const root = join(dir, entry);
82
- const manifest = readManifest(root);
83
- if (!manifest)
223
+ if (entry.startsWith(".") || entry.startsWith("_"))
84
224
  continue;
85
- out.push({ name: manifest.name || entry, version: manifest.version || "0.0.0", root, manifest });
225
+ try {
226
+ const name = safePluginId(entry, "installed plugin directory");
227
+ // Installation and ownership-sensitive operations scan the complete package. Hot-path discovery only
228
+ // revalidates the manifest and every declared skill/agent/bin/MCP entry; unrelated package files cannot
229
+ // become executable contributions and must not add O(package-size) latency to each prompt/turn.
230
+ const { plugin } = readInstalled(join(dir, name), name, { scanTree: false });
231
+ out.push(plugin);
232
+ }
233
+ catch {
234
+ // Fail closed: a malformed root contributes no code, paths, hooks, MCP servers, or panels.
235
+ }
86
236
  }
87
237
  return out;
88
238
  }
89
- /** A plugin is active unless explicitly disabled in config (`plugins.enabled[name] === false`). */
90
239
  export function enabledPlugins() {
91
240
  const enabled = (readRawConfig().plugins?.enabled ?? {});
92
- return listInstalled().filter((p) => enabled[p.name] !== false);
241
+ return listInstalled().filter((plugin) => enabled[plugin.name] !== false);
93
242
  }
94
- function resolveDirs(p, entries) {
95
- return (entries ?? [])
96
- .map((e) => (isAbsolute(e) ? e : resolve(p.root, e)))
97
- .filter((d) => existsSync(d));
243
+ function resolveDirs(plugin, entries) {
244
+ return (entries ?? []).map((entry) => {
245
+ const root = realpathSync.native(resolve(plugin.root));
246
+ const requested = resolve(root, safePluginRelativePath(entry, `plugin '${plugin.name}' contribution`));
247
+ const target = realpathSync.native(requested);
248
+ const rel = relative(root, target);
249
+ if (rel.startsWith("..") || isAbsolute(rel))
250
+ throw new Error(`plugin '${plugin.name}' contribution escaped its root`);
251
+ return target;
252
+ });
98
253
  }
99
- // --- Contribution resolvers (consumed by the existing loaders; lowest precedence) ---
100
- /** Skill search dirs from enabled plugins (each holds <name>/SKILL.md subdirs). */
101
254
  export function pluginSkillDirs() {
102
- return enabledPlugins().flatMap((p) => resolveDirs(p, p.manifest.skills));
255
+ return enabledPlugins().flatMap((plugin) => resolveDirs(plugin, plugin.manifest.skills));
103
256
  }
104
- /** Role/subagent dirs from enabled plugins. */
105
257
  export function pluginRoleDirs() {
106
- return enabledPlugins().flatMap((p) => resolveDirs(p, p.manifest.agents));
258
+ return enabledPlugins().flatMap((plugin) => resolveDirs(plugin, plugin.manifest.agents));
107
259
  }
108
- /** MCP servers contributed by enabled plugins (merged under user config, which wins). */
109
260
  export function pluginMcpServers() {
110
261
  const out = {};
111
- for (const p of enabledPlugins())
112
- Object.assign(out, p.manifest.mcpServers ?? {});
262
+ for (const plugin of enabledPlugins())
263
+ Object.assign(out, bindPluginMcpServers(plugin.root, plugin.manifest));
113
264
  return out;
114
265
  }
115
- /** Lifecycle hooks contributed by enabled plugins (appended after user-config hooks). */
116
266
  export function pluginHooks() {
117
267
  const out = { PreToolUse: [], PostToolUse: [] };
118
- for (const p of enabledPlugins()) {
119
- const h = p.manifest.hooks;
120
- if (!h || typeof h !== "object")
268
+ for (const plugin of enabledPlugins()) {
269
+ const hooks = plugin.manifest.hooks;
270
+ if (!hooks)
121
271
  continue;
122
- if (Array.isArray(h.PreToolUse))
123
- out.PreToolUse.push(...h.PreToolUse);
124
- if (Array.isArray(h.PostToolUse))
125
- out.PostToolUse.push(...h.PostToolUse);
272
+ if (hooks.PreToolUse)
273
+ out.PreToolUse.push(...hooks.PreToolUse);
274
+ if (hooks.PostToolUse)
275
+ out.PostToolUse.push(...hooks.PostToolUse);
126
276
  }
127
277
  return out;
128
278
  }
129
- /** Install a plugin from `file:<path>`, `github:<owner/repo>`, or `git:<url>` into ~/.hara/plugins/<name>. */
279
+ function validGitSource(source) {
280
+ const url = source.slice("git:".length);
281
+ if (!/^(?:https:\/\/|ssh:\/\/|git@)[^\s\0]+$/u.test(url)) {
282
+ throw new Error("git source must use an https://, ssh://, or git@ URL");
283
+ }
284
+ return url;
285
+ }
286
+ function populateStaging(source, staging) {
287
+ if (source.startsWith("file:")) {
288
+ const requested = resolve(source.slice("file:".length));
289
+ const info = lstatSync(requested);
290
+ if (!info.isDirectory() || info.isSymbolicLink())
291
+ throw new Error(`plugin source '${requested}' must be a real directory`);
292
+ const src = realpathSync.native(requested);
293
+ const store = realpathSync.native(pluginStorage());
294
+ const rel = relative(store, src);
295
+ if (rel === "" || (!rel.startsWith("..") && !isAbsolute(rel))) {
296
+ throw new Error("plugin source must not be inside Hara's installed plugin directory");
297
+ }
298
+ cpSync(src, staging, { recursive: true, dereference: false, errorOnExist: true, force: false });
299
+ }
300
+ else if (source.startsWith("github:")) {
301
+ const repo = source.slice("github:".length);
302
+ if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(repo))
303
+ throw new Error("github source must be owner/repository");
304
+ execFileSync("git", ["clone", "--depth", "1", `https://github.com/${repo}.git`, staging], { stdio: "ignore" });
305
+ }
306
+ else if (source.startsWith("git:")) {
307
+ execFileSync("git", ["clone", "--depth", "1", validGitSource(source), staging], { stdio: "ignore" });
308
+ }
309
+ else {
310
+ throw new Error("source must be file:<path>, github:<owner/repo>, or git:<url>");
311
+ }
312
+ safeTemporaryRemove(join(staging, ".git"));
313
+ safeTemporaryRemove(join(staging, ".learnings"));
314
+ }
315
+ /** Install through a validated same-filesystem stage. Replacements preserve the previous directory and
316
+ * receipt until the new manifest, bin links, and ownership receipt have all committed successfully. */
130
317
  export function installPlugin(source) {
131
- mkdirSync(pluginsDir(), { recursive: true });
132
- const tmpName = `_install-${process.pid}-${Date.now()}`;
133
- const tmp = join(pluginsDir(), tmpName);
134
- rmSync(tmp, { recursive: true, force: true });
318
+ const store = pluginStorage();
319
+ const staging = join(store, `_install-${process.pid}-${randomUUID()}`);
320
+ const backup = join(store, `_backup-${process.pid}-${randomUUID()}`);
321
+ let destination = "";
322
+ let newActivated = false;
323
+ let oldMoved = false;
324
+ let oldManifest = null;
325
+ let oldReceipt = null;
326
+ let oldBins = {};
327
+ let oldIdentity = null;
328
+ let newManifest = null;
135
329
  try {
136
- if (source.startsWith("file:")) {
137
- const src = resolve(source.slice("file:".length));
138
- if (!existsSync(src))
139
- throw new Error(`no such path: ${src}`);
140
- cpSync(src, tmp, { recursive: true });
141
- }
142
- else if (source.startsWith("github:")) {
143
- execFileSync("git", ["clone", "--depth", "1", `https://github.com/${source.slice("github:".length)}.git`, tmp], { stdio: "ignore" });
144
- }
145
- else if (source.startsWith("git:")) {
146
- execFileSync("git", ["clone", "--depth", "1", source.slice("git:".length), tmp], { stdio: "ignore" });
147
- }
148
- else {
149
- throw new Error("source must be file:<path>, github:<owner/repo>, or git:<url>");
150
- }
151
- const manifest = readManifest(tmp);
152
- if (!manifest || !manifest.name) {
153
- rmSync(tmp, { recursive: true, force: true });
154
- throw new Error("no valid plugin.json (need at least a name) at the source root");
155
- }
156
- const dest = join(pluginsDir(), manifest.name);
157
- rmSync(dest, { recursive: true, force: true });
158
- // move tmp → dest (rename within the same dir)
159
- cpSync(tmp, dest, { recursive: true });
160
- rmSync(tmp, { recursive: true, force: true });
161
- linkPluginBins(dest, manifest); // expose any plugin CLI commands in ~/.hara/bin
162
- return { name: manifest.name, version: manifest.version || "0.0.0", root: dest, manifest };
163
- }
164
- catch (e) {
165
- rmSync(tmp, { recursive: true, force: true });
166
- throw e;
167
- }
168
- }
169
- export function uninstallPlugin(name) {
170
- const dest = join(pluginsDir(), name);
171
- if (!existsSync(dest))
330
+ populateStaging(source, staging);
331
+ newManifest = readVerifiedPluginManifest(staging);
332
+ const name = newManifest.manifest.name;
333
+ destination = pluginRoot(name);
334
+ if (existsSync(destination)) {
335
+ const installed = readInstalled(destination, name);
336
+ oldManifest = installed.verified;
337
+ oldBins = pluginBins(oldManifest.manifest);
338
+ oldReceipt = readReceipt(name);
339
+ if (oldReceipt)
340
+ verifyReceipt(oldReceipt.receipt, destination, oldManifest);
341
+ oldIdentity = rootIdentity(destination);
342
+ }
343
+ const newBins = pluginBins(newManifest.manifest);
344
+ for (const [name, rel] of Object.entries(oldBins))
345
+ preflightBinLink(destination, name, rel);
346
+ for (const [name, rel] of Object.entries(newBins)) {
347
+ const link = join(pluginBinStorage(), name);
348
+ if (!entryExists(link))
349
+ continue;
350
+ if (!oldBins[name])
351
+ throw new Error(`refusing to overwrite foreign command entry '${link}'`);
352
+ preflightBinLink(destination, name, oldBins[name], false);
353
+ // The new target itself was verified in staging; changed relative targets are replaced after activation.
354
+ void rel;
355
+ }
356
+ if (existsSync(destination)) {
357
+ renameSync(destination, backup);
358
+ oldMoved = true;
359
+ const moved = rootIdentity(backup);
360
+ if (!oldIdentity || moved.rootDev !== oldIdentity.rootDev || moved.rootIno !== oldIdentity.rootIno) {
361
+ throw new Error(`plugin '${name}' root identity changed during update`);
362
+ }
363
+ }
364
+ renameSync(staging, destination);
365
+ newActivated = true;
366
+ newManifest = readVerifiedPluginManifest(destination);
367
+ for (const [name, rel] of Object.entries(oldBins)) {
368
+ if (newBins[name] !== rel)
369
+ unlinkBinLink(destination, name, rel);
370
+ }
371
+ for (const [name, rel] of Object.entries(newBins))
372
+ ensureBinLink(destination, name, rel);
373
+ writeReceipt(name, destination, newManifest);
374
+ const result = {
375
+ name,
376
+ version: newManifest.manifest.version || "0.0.0",
377
+ root: realpathSync.native(destination),
378
+ manifest: newManifest.manifest,
379
+ };
380
+ // Activation and its receipt are committed. Cleanup is deliberately outside rollback: recursive backup
381
+ // removal can partially succeed, so a cleanup error must leave an inert `_backup-*` entry rather than
382
+ // restore a partially removed old package over the verified new one.
383
+ if (oldMoved) {
384
+ try {
385
+ safeTemporaryRemove(backup);
386
+ }
387
+ catch {
388
+ // A later bounded maintenance pass may remove the skipped, non-loadable backup after inspection.
389
+ }
390
+ }
391
+ return result;
392
+ }
393
+ catch (error) {
394
+ const rollbackErrors = [];
395
+ const failedManifest = newManifest;
396
+ const name = failedManifest?.manifest.name;
397
+ if (failedManifest && name && destination) {
398
+ try {
399
+ if (newActivated) {
400
+ for (const [binName, rel] of Object.entries(pluginBins(failedManifest.manifest))) {
401
+ if (entryExists(join(pluginBinStorage(), binName)))
402
+ unlinkBinLink(destination, binName, rel);
403
+ }
404
+ }
405
+ }
406
+ catch (rollbackError) {
407
+ rollbackErrors.push(`new command cleanup: ${rollbackError?.message ?? String(rollbackError)}`);
408
+ }
409
+ try {
410
+ if (newActivated && existsSync(destination))
411
+ safeTemporaryRemove(destination);
412
+ if (oldMoved && existsSync(backup))
413
+ renameSync(backup, destination);
414
+ }
415
+ catch (rollbackError) {
416
+ rollbackErrors.push(`directory restore: ${rollbackError?.message ?? String(rollbackError)}`);
417
+ }
418
+ try {
419
+ if (oldMoved && existsSync(destination))
420
+ restoreBinLinks(destination, oldBins);
421
+ }
422
+ catch (rollbackError) {
423
+ rollbackErrors.push(`old command restore: ${rollbackError?.message ?? String(rollbackError)}`);
424
+ }
425
+ try {
426
+ restoreReceipt(name, oldReceipt?.raw ?? null);
427
+ }
428
+ catch (rollbackError) {
429
+ rollbackErrors.push(`receipt restore: ${rollbackError?.message ?? String(rollbackError)}`);
430
+ }
431
+ }
432
+ try {
433
+ safeTemporaryRemove(staging);
434
+ safeTemporaryRemove(backup);
435
+ }
436
+ catch (rollbackError) {
437
+ rollbackErrors.push(`staging cleanup: ${rollbackError?.message ?? String(rollbackError)}`);
438
+ }
439
+ const detail = error instanceof Error ? error.message : String(error);
440
+ throw new Error(rollbackErrors.length ? `${detail}; rollback warning: ${rollbackErrors.join("; ")}` : detail);
441
+ }
442
+ }
443
+ export function uninstallPlugin(rawName) {
444
+ const name = safePluginId(rawName);
445
+ const destination = pluginRoot(name);
446
+ if (!existsSync(destination))
172
447
  return false;
173
- unlinkPluginBins(readManifest(dest)); // remove any linked CLI commands first
174
- rmSync(dest, { recursive: true, force: true });
448
+ const installed = readInstalled(destination, name);
449
+ const stored = readReceipt(name);
450
+ if (!stored) {
451
+ throw new Error(`refusing to uninstall legacy plugin '${name}' without an ownership receipt; ` +
452
+ "reinstall the same source with this Hara version, then remove it");
453
+ }
454
+ verifyReceipt(stored.receipt, destination, installed.verified);
455
+ for (const [binName, rel] of Object.entries(stored.receipt.bins))
456
+ preflightBinLink(destination, binName, rel);
457
+ const quarantine = join(pluginStorage(), `_remove-${process.pid}-${randomUUID()}`);
458
+ renameSync(destination, quarantine);
459
+ const moved = rootIdentity(quarantine);
460
+ if (moved.rootDev !== stored.receipt.rootDev || moved.rootIno !== stored.receipt.rootIno) {
461
+ renameSync(quarantine, destination);
462
+ throw new Error(`plugin '${name}' root identity changed during uninstall`);
463
+ }
464
+ try {
465
+ for (const [binName, rel] of Object.entries(stored.receipt.bins))
466
+ unlinkBinLink(destination, binName, rel);
467
+ removePrivateStateFile(stored.binding.path, stored.snapshot, stored.binding.directory);
468
+ }
469
+ catch (error) {
470
+ const rollbackErrors = [];
471
+ try {
472
+ if (existsSync(quarantine) && !existsSync(destination))
473
+ renameSync(quarantine, destination);
474
+ restoreBinLinks(destination, stored.receipt.bins);
475
+ }
476
+ catch (rollbackError) {
477
+ rollbackErrors.push(rollbackError?.message ?? String(rollbackError));
478
+ }
479
+ try {
480
+ restoreReceipt(name, stored.raw);
481
+ }
482
+ catch (rollbackError) {
483
+ rollbackErrors.push(rollbackError?.message ?? String(rollbackError));
484
+ }
485
+ const detail = error instanceof Error ? error.message : String(error);
486
+ throw new Error(rollbackErrors.length ? `${detail}; rollback warning: ${rollbackErrors.join("; ")}` : detail);
487
+ }
488
+ // The namespace, executable links, and receipt are already removed. A cleanup failure leaves only an
489
+ // inert unpredictable quarantine directory and must not reactivate unreceipted code.
490
+ try {
491
+ safeTemporaryRemove(quarantine);
492
+ }
493
+ catch {
494
+ // A later bounded maintenance pass may remove it after inspecting ownership.
495
+ }
175
496
  return true;
176
497
  }
177
- /** Panels applicable to a project cwd a panel matches when any of its `detect` markers exists under
178
- * the cwd. Pure over the given plugin list (testable); `panelsForProject` binds it to enabled plugins. */
498
+ function safeMarkerExists(cwd, marker) {
499
+ try {
500
+ const rel = safePluginRelativePath(marker, "plugin panel detect marker");
501
+ const canonicalRoot = realpathSync.native(resolve(cwd));
502
+ let current = canonicalRoot;
503
+ for (const part of rel.split("/")) {
504
+ current = join(current, part);
505
+ const info = lstatSync(current);
506
+ if (info.isSymbolicLink())
507
+ return false;
508
+ }
509
+ const relToRoot = relative(canonicalRoot, realpathSync.native(current));
510
+ return !relToRoot.startsWith("..") && !isAbsolute(relToRoot);
511
+ }
512
+ catch {
513
+ return false;
514
+ }
515
+ }
179
516
  export function matchPanels(plugins, cwd) {
180
517
  const out = [];
181
- for (const pl of plugins) {
182
- for (const panel of pl.manifest.panels ?? []) {
518
+ for (const plugin of plugins) {
519
+ for (const panel of plugin.manifest.panels ?? []) {
183
520
  if (!panel.detect?.length)
184
- continue; // global-only panels stay off project surfaces
185
- if (panel.detect.some((m) => existsSync(join(cwd, m))))
186
- out.push({ plugin: pl.name, panel });
521
+ continue;
522
+ if (panel.detect.some((marker) => safeMarkerExists(cwd, marker)))
523
+ out.push({ plugin: plugin.name, panel });
187
524
  }
188
525
  }
189
526
  return out;
@@ -191,8 +528,8 @@ export function matchPanels(plugins, cwd) {
191
528
  export function panelsForProject(cwd) {
192
529
  return matchPanels(enabledPlugins(), cwd);
193
530
  }
194
- /** Persist a plugin's enabled flag in ~/.hara/config.json (`plugins.enabled[name]`). */
195
- export function setPluginEnabled(name, on) {
531
+ export function setPluginEnabled(rawName, on) {
532
+ const name = safePluginId(rawName);
196
533
  updateRawConfig((config) => {
197
534
  const plugins = (config.plugins && typeof config.plugins === "object" ? config.plugins : {});
198
535
  plugins.enabled = { ...(plugins.enabled ?? {}), [name]: on };