@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.
- package/CHANGELOG.md +37 -0
- package/README.md +4 -1
- package/SECURITY.md +7 -3
- package/dist/agent/loop.js +19 -14
- package/dist/agent/prompt.js +48 -0
- package/dist/index.js +13 -6
- package/dist/mcp/client.js +1 -0
- package/dist/plugins/manifest.js +317 -0
- package/dist/plugins/plugins.js +476 -139
- package/dist/providers/anthropic.js +31 -8
- package/dist/security/private-state.js +1 -1
- package/dist/serve/protocol.js +2 -0
- package/dist/serve/server.js +74 -7
- package/dist/serve/task-events.js +45 -0
- package/package.json +1 -1
package/dist/plugins/plugins.js
CHANGED
|
@@ -1,189 +1,526 @@
|
|
|
1
|
-
// Plugins
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
import {
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
if (
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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 =
|
|
77
|
-
if (!existsSync(dir))
|
|
78
|
-
return [];
|
|
220
|
+
const dir = pluginStorage();
|
|
79
221
|
const out = [];
|
|
80
222
|
for (const entry of readdirSync(dir)) {
|
|
81
|
-
|
|
82
|
-
const manifest = readManifest(root);
|
|
83
|
-
if (!manifest)
|
|
223
|
+
if (entry.startsWith(".") || entry.startsWith("_"))
|
|
84
224
|
continue;
|
|
85
|
-
|
|
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((
|
|
241
|
+
return listInstalled().filter((plugin) => enabled[plugin.name] !== false);
|
|
93
242
|
}
|
|
94
|
-
function resolveDirs(
|
|
95
|
-
return (entries ?? [])
|
|
96
|
-
|
|
97
|
-
|
|
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((
|
|
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((
|
|
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
|
|
112
|
-
Object.assign(out,
|
|
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
|
|
119
|
-
const
|
|
120
|
-
if (!
|
|
268
|
+
for (const plugin of enabledPlugins()) {
|
|
269
|
+
const hooks = plugin.manifest.hooks;
|
|
270
|
+
if (!hooks)
|
|
121
271
|
continue;
|
|
122
|
-
if (
|
|
123
|
-
out.PreToolUse.push(...
|
|
124
|
-
if (
|
|
125
|
-
out.PostToolUse.push(...
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
const
|
|
133
|
-
const
|
|
134
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
182
|
-
for (const panel of
|
|
518
|
+
for (const plugin of plugins) {
|
|
519
|
+
for (const panel of plugin.manifest.panels ?? []) {
|
|
183
520
|
if (!panel.detect?.length)
|
|
184
|
-
continue;
|
|
185
|
-
if (panel.detect.some((
|
|
186
|
-
out.push({ plugin:
|
|
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
|
-
|
|
195
|
-
|
|
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 };
|