@alfe.ai/integrations 0.6.0 → 0.6.2
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/README.md +13 -11
- package/dist/index.d.ts +39 -23
- package/dist/index.js +714 -270
- package/package.json +3 -3
package/dist/index.js
CHANGED
|
@@ -1,19 +1,122 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
import { closeSync, copyFileSync, cpSync, existsSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
1
4
|
import { execFile, spawn } from "node:child_process";
|
|
2
5
|
import { promisify } from "node:util";
|
|
3
|
-
import { randomBytes } from "node:crypto";
|
|
4
|
-
import { basename, dirname, join } from "node:path";
|
|
5
6
|
import { homedir, platform, tmpdir } from "node:os";
|
|
6
|
-
import { chmodSync, closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { buildConfigValidationSchema, parseManifestFile } from "@alfe.ai/integration-manifest";
|
|
8
8
|
import { createLogger } from "@auriclabs/logger";
|
|
9
9
|
import { parseDocument } from "yaml";
|
|
10
10
|
import { toServerConfig } from "@alfe.ai/mcp-bundler";
|
|
11
|
+
//#region src/filesystem.ts
|
|
12
|
+
const UNSAFE_RECORD_KEYS = new Set([
|
|
13
|
+
"__proto__",
|
|
14
|
+
"prototype",
|
|
15
|
+
"constructor"
|
|
16
|
+
]);
|
|
17
|
+
function assertSafeRecordKey(value, label) {
|
|
18
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 1e3 || value.includes("\0") || UNSAFE_RECORD_KEYS.has(value)) throw new Error(`${label} must be a safe record key`);
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
/** Integration ids become directory and record keys, so keep them one safe segment. */
|
|
22
|
+
function assertSafePathSegment(value, label) {
|
|
23
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 240 || value === "." || value === ".." || value.startsWith(".") || value.includes("/") || value.includes("\\") || value.includes("\0") || value.includes("..") || UNSAFE_RECORD_KEYS.has(value)) throw new Error(`${label} must be a safe path segment`);
|
|
24
|
+
return value;
|
|
25
|
+
}
|
|
26
|
+
/** Reject absolute and lexically escaping repository-owned paths. */
|
|
27
|
+
function assertSafeRelativePath(value, label) {
|
|
28
|
+
if (typeof value !== "string" || value.length === 0 || value.includes("\0") || isAbsolute(value)) throw new Error(`${label} must be a non-empty relative path`);
|
|
29
|
+
const normalized = value.replaceAll("\\", "/");
|
|
30
|
+
if (normalized.split("/").some((part) => part === "" || part === "." || part === "..")) throw new Error(`${label} must stay within the integration checkout`);
|
|
31
|
+
return normalized;
|
|
32
|
+
}
|
|
33
|
+
function isWithin(root, candidate) {
|
|
34
|
+
const rel = relative(root, candidate);
|
|
35
|
+
return rel === "" || !rel.startsWith(`..${sep}`) && rel !== ".." && !isAbsolute(rel);
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve an existing repository-owned path and prove its real target remains
|
|
39
|
+
* beneath the checkout. This closes the symlink escape left by lexical schema
|
|
40
|
+
* validation alone.
|
|
41
|
+
*/
|
|
42
|
+
function resolveExistingWithin(rootPath, relativePath, label, expected = "either") {
|
|
43
|
+
const safeRelative = assertSafeRelativePath(relativePath, label);
|
|
44
|
+
const root = realpathSync(rootPath);
|
|
45
|
+
const lexical = resolve(root, safeRelative);
|
|
46
|
+
if (!isWithin(root, lexical)) throw new Error(`${label} escapes the integration checkout`);
|
|
47
|
+
const actual = realpathSync(lexical);
|
|
48
|
+
if (!isWithin(root, actual)) throw new Error(`${label} resolves outside the integration checkout`);
|
|
49
|
+
const stat = lstatSync(actual);
|
|
50
|
+
if (expected === "file" && !stat.isFile()) throw new Error(`${label} must resolve to a regular file`);
|
|
51
|
+
if (expected === "directory" && !stat.isDirectory()) throw new Error(`${label} must resolve to a directory`);
|
|
52
|
+
return actual;
|
|
53
|
+
}
|
|
54
|
+
/** Crash-safe, owner-only replacement for local state/config files. */
|
|
55
|
+
function atomicWriteFileSync(filePath, contents, mode = 384) {
|
|
56
|
+
const dir = dirname(filePath);
|
|
57
|
+
mkdirSync(dir, {
|
|
58
|
+
recursive: true,
|
|
59
|
+
mode: 448
|
|
60
|
+
});
|
|
61
|
+
const tempPath = resolve(dir, `.${basename(filePath)}.tmp-${String(process.pid)}-${randomBytes(8).toString("hex")}`);
|
|
62
|
+
let fd;
|
|
63
|
+
try {
|
|
64
|
+
fd = openSync(tempPath, "wx", mode);
|
|
65
|
+
writeFileSync(fd, contents, { encoding: "utf-8" });
|
|
66
|
+
fsyncSync(fd);
|
|
67
|
+
closeSync(fd);
|
|
68
|
+
fd = void 0;
|
|
69
|
+
renameSync(tempPath, filePath);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (fd !== void 0) closeSync(fd);
|
|
72
|
+
rmSync(tempPath, { force: true });
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Read an ownership/accounting JSON file without converting corruption into
|
|
78
|
+
* an empty ledger. An empty fallback would make the next reconciliation forget
|
|
79
|
+
* what Alfe owns and can leak runtime config or credentials indefinitely.
|
|
80
|
+
*/
|
|
81
|
+
function readJsonObjectFileSync(filePath, label) {
|
|
82
|
+
if (!existsSync(filePath)) return {};
|
|
83
|
+
let parsed;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
throw new Error(`${label} is unreadable; refusing an empty-ledger fallback: ${err instanceof Error ? err.message : String(err)}`);
|
|
88
|
+
}
|
|
89
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${label} must contain a JSON object`);
|
|
90
|
+
return parsed;
|
|
91
|
+
}
|
|
92
|
+
function getOptionalJsonObjectField(record, key, label) {
|
|
93
|
+
const value = record[key];
|
|
94
|
+
if (value === void 0) return {};
|
|
95
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must contain a JSON object`);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
11
99
|
//#region src/registry.ts
|
|
100
|
+
/**
|
|
101
|
+
* Registry — fetches the integration registry index from the integrations service.
|
|
102
|
+
*
|
|
103
|
+
* Calls GET /integrations/registry (public, no auth required) and caches the result.
|
|
104
|
+
* The API URL can be passed explicitly or set via ALFE_API_URL env var.
|
|
105
|
+
*
|
|
106
|
+
* The cache is time-bounded by a TTL (default 60s). Long-running consumers — chiefly
|
|
107
|
+
* the agent daemon, which constructs the Registry once at startup and never restarts —
|
|
108
|
+
* would otherwise be pinned to the registry snapshot taken at process boot, and could
|
|
109
|
+
* never resolve a version published after the daemon came up (the "stale cache" bug).
|
|
110
|
+
* With a TTL, every consumer self-heals within a bounded window. The resolve-for-install
|
|
111
|
+
* path additionally forces a fresh read (see `load({ fresh: true })`) because installing
|
|
112
|
+
* a specific version is a rare, correctness-critical action that must never race the TTL.
|
|
113
|
+
*/
|
|
12
114
|
/** Default cache TTL — refetch the registry index after this many ms. */
|
|
13
115
|
const DEFAULT_REGISTRY_TTL_MS = 6e4;
|
|
14
116
|
var Registry = class {
|
|
15
117
|
index = null;
|
|
16
118
|
loadedAt = 0;
|
|
119
|
+
loadPromise = null;
|
|
17
120
|
fetcher;
|
|
18
121
|
ttlMs;
|
|
19
122
|
/**
|
|
@@ -36,19 +139,31 @@ var Registry = class {
|
|
|
36
139
|
* TTL; refetches once the cache is stale (or when `{ fresh: true }` is passed).
|
|
37
140
|
*/
|
|
38
141
|
async load(options = {}) {
|
|
39
|
-
if (!options.fresh && this.index && !this.isStale()) return this.index;
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
integrations
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
142
|
+
if (!options.fresh && this.index && !this.isStale()) return cloneRegistryIndex(this.index);
|
|
143
|
+
if (this.loadPromise) return this.loadPromise;
|
|
144
|
+
const load = async () => {
|
|
145
|
+
const raw = await this.fetcher();
|
|
146
|
+
if (!Array.isArray(raw)) throw new Error("Integration registry response must be an array");
|
|
147
|
+
const integrations = Object.create(null);
|
|
148
|
+
for (const unvalidated of raw) {
|
|
149
|
+
const { id, ...rest } = validateRegistryEntry(unvalidated);
|
|
150
|
+
if (Object.hasOwn(integrations, id)) throw new Error(`Integration registry contains duplicate id "${id}"`);
|
|
151
|
+
integrations[id] = cloneRegistryEntry(rest);
|
|
152
|
+
}
|
|
153
|
+
this.index = {
|
|
154
|
+
version: 1,
|
|
155
|
+
integrations
|
|
156
|
+
};
|
|
157
|
+
this.loadedAt = Date.now();
|
|
158
|
+
return cloneRegistryIndex(this.index);
|
|
49
159
|
};
|
|
50
|
-
|
|
51
|
-
|
|
160
|
+
const promise = load();
|
|
161
|
+
this.loadPromise = promise;
|
|
162
|
+
try {
|
|
163
|
+
return await promise;
|
|
164
|
+
} finally {
|
|
165
|
+
if (this.loadPromise === promise) this.loadPromise = null;
|
|
166
|
+
}
|
|
52
167
|
}
|
|
53
168
|
/**
|
|
54
169
|
* Force reload the index (bypass cache). Equivalent to `load({ fresh: true })`.
|
|
@@ -65,7 +180,9 @@ var Registry = class {
|
|
|
65
180
|
* a just-published version).
|
|
66
181
|
*/
|
|
67
182
|
async get(id, fresh = false) {
|
|
68
|
-
|
|
183
|
+
const index = await this.load({ fresh });
|
|
184
|
+
if (!Object.hasOwn(index.integrations, id)) return void 0;
|
|
185
|
+
return cloneRegistryEntry(index.integrations[id]);
|
|
69
186
|
}
|
|
70
187
|
/**
|
|
71
188
|
* List all integrations in the registry.
|
|
@@ -74,7 +191,7 @@ var Registry = class {
|
|
|
74
191
|
const index = await this.load();
|
|
75
192
|
return Object.entries(index.integrations).map(([id, entry]) => ({
|
|
76
193
|
id,
|
|
77
|
-
...entry
|
|
194
|
+
...cloneRegistryEntry(entry)
|
|
78
195
|
}));
|
|
79
196
|
}
|
|
80
197
|
/**
|
|
@@ -86,6 +203,80 @@ var Registry = class {
|
|
|
86
203
|
return all.filter((entry) => entry.id.toLowerCase().includes(q) || (entry.name?.toLowerCase().includes(q) ?? false) || entry.description.toLowerCase().includes(q));
|
|
87
204
|
}
|
|
88
205
|
};
|
|
206
|
+
function validateRegistryEntry(value) {
|
|
207
|
+
const unknownValue = value;
|
|
208
|
+
if (typeof unknownValue !== "object" || unknownValue === null) throw new Error("Integration registry entry must be an object");
|
|
209
|
+
const entry = unknownValue;
|
|
210
|
+
assertSafePathSegment(entry.id, "Integration registry id");
|
|
211
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(entry.id)) throw new Error(`Integration registry id "${entry.id}" is invalid`);
|
|
212
|
+
let repository;
|
|
213
|
+
try {
|
|
214
|
+
repository = new URL(entry.repository);
|
|
215
|
+
} catch {
|
|
216
|
+
throw new Error(`Integration "${entry.id}" repository must be a valid HTTPS URL`);
|
|
217
|
+
}
|
|
218
|
+
if (repository.protocol !== "https:" || repository.username !== "" || repository.password !== "") throw new Error(`Integration "${entry.id}" repository must be HTTPS without embedded credentials`);
|
|
219
|
+
if (!/^[0-9a-f]{40}$/i.test(entry.commit)) throw new Error(`Integration "${entry.id}" commit must be a full 40-character SHA-1`);
|
|
220
|
+
if (!Array.isArray(entry.versions) || entry.versions.length === 0 || entry.versions.some((version) => typeof version !== "string" || version.length === 0) || new Set(entry.versions).size !== entry.versions.length) throw new Error(`Integration "${entry.id}" versions must be a non-empty unique string array`);
|
|
221
|
+
if (typeof entry.latest !== "string" || !entry.versions.includes(entry.latest)) throw new Error(`Integration "${entry.id}" latest version must appear in versions`);
|
|
222
|
+
if (typeof entry.description !== "string") throw new Error(`Integration "${entry.id}" description must be a string`);
|
|
223
|
+
if (entry.name !== void 0 && typeof entry.name !== "string") throw new Error(`Integration "${entry.id}" name must be a string`);
|
|
224
|
+
if (entry.subdir !== void 0) assertSafeRelativePath(entry.subdir, `Integration "${entry.id}" subdir`);
|
|
225
|
+
if (entry.supported_agents !== void 0) {
|
|
226
|
+
if (!Array.isArray(entry.supported_agents) || entry.supported_agents.some((runtime) => typeof runtime !== "string")) throw new Error(`Integration "${entry.id}" supported_agents must be a string array`);
|
|
227
|
+
for (const runtime of entry.supported_agents) assertSafePathSegment(runtime, `Integration "${entry.id}" runtime id`);
|
|
228
|
+
}
|
|
229
|
+
if (entry.icon !== void 0 && typeof entry.icon !== "string") throw new Error(`Integration "${entry.id}" icon must be a string`);
|
|
230
|
+
if (entry.author !== void 0 && typeof entry.author !== "string" && (!isRecord$2(entry.author) || typeof entry.author.name !== "string" || entry.author.url !== void 0 && typeof entry.author.url !== "string")) throw new Error(`Integration "${entry.id}" author is invalid`);
|
|
231
|
+
assertOptionalStringArray(entry.features, `Integration "${entry.id}" features`);
|
|
232
|
+
assertOptionalStringArray(entry.preview_images, `Integration "${entry.id}" preview_images`);
|
|
233
|
+
if (entry.pricing !== void 0) validatePricing(entry.id, entry.pricing);
|
|
234
|
+
if (entry.config_schema !== void 0) {
|
|
235
|
+
if (!Array.isArray(entry.config_schema) || entry.config_schema.some((field) => !isRecord$2(field) || typeof field.key !== "string" || typeof field.label !== "string" || typeof field.type !== "string")) throw new Error(`Integration "${entry.id}" config_schema is invalid`);
|
|
236
|
+
}
|
|
237
|
+
return entry;
|
|
238
|
+
}
|
|
239
|
+
function isRecord$2(value) {
|
|
240
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
241
|
+
}
|
|
242
|
+
function assertOptionalStringArray(value, label) {
|
|
243
|
+
if (value !== void 0 && (!Array.isArray(value) || value.some((entry) => typeof entry !== "string"))) throw new Error(`${label} must be a string array`);
|
|
244
|
+
}
|
|
245
|
+
function validatePricing(id, value) {
|
|
246
|
+
if (!isRecord$2(value) || ![
|
|
247
|
+
"free",
|
|
248
|
+
"paid",
|
|
249
|
+
"usage"
|
|
250
|
+
].includes(String(value.type))) throw new Error(`Integration "${id}" pricing is invalid`);
|
|
251
|
+
if (value.price !== void 0 && (typeof value.price !== "number" || !Number.isFinite(value.price) || value.price < 0)) throw new Error(`Integration "${id}" pricing.price is invalid`);
|
|
252
|
+
if (value.currency !== void 0 && typeof value.currency !== "string") throw new Error(`Integration "${id}" pricing.currency is invalid`);
|
|
253
|
+
if (value.interval !== void 0 && value.interval !== "month" && value.interval !== "year") throw new Error(`Integration "${id}" pricing.interval is invalid`);
|
|
254
|
+
if (value.description !== void 0 && typeof value.description !== "string") throw new Error(`Integration "${id}" pricing.description is invalid`);
|
|
255
|
+
}
|
|
256
|
+
function cloneRegistryEntry(entry) {
|
|
257
|
+
return {
|
|
258
|
+
...entry,
|
|
259
|
+
versions: [...entry.versions],
|
|
260
|
+
...entry.supported_agents ? { supported_agents: [...entry.supported_agents] } : {},
|
|
261
|
+
...entry.features ? { features: [...entry.features] } : {},
|
|
262
|
+
...entry.preview_images ? { preview_images: [...entry.preview_images] } : {},
|
|
263
|
+
...entry.author && typeof entry.author === "object" ? { author: { ...entry.author } } : {},
|
|
264
|
+
...entry.pricing ? { pricing: { ...entry.pricing } } : {},
|
|
265
|
+
...entry.config_schema ? { config_schema: entry.config_schema.map((field) => ({
|
|
266
|
+
...field,
|
|
267
|
+
...field.options ? { options: [...field.options] } : {},
|
|
268
|
+
...field.select_options ? { select_options: field.select_options.map((option) => ({ ...option })) } : {}
|
|
269
|
+
})) } : {}
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
function cloneRegistryIndex(index) {
|
|
273
|
+
const integrations = {};
|
|
274
|
+
for (const [id, entry] of Object.entries(index.integrations)) integrations[id] = cloneRegistryEntry(entry);
|
|
275
|
+
return {
|
|
276
|
+
version: index.version,
|
|
277
|
+
integrations
|
|
278
|
+
};
|
|
279
|
+
}
|
|
89
280
|
//#endregion
|
|
90
281
|
//#region src/resolver.ts
|
|
91
282
|
var RegistryResolveError = class extends Error {
|
|
@@ -165,6 +356,7 @@ const NPX_PREWARM_TIMEOUT_MS = 12e4;
|
|
|
165
356
|
* staging dir for a real integration install.
|
|
166
357
|
*/
|
|
167
358
|
const STAGING_PREFIX = ".staging-";
|
|
359
|
+
const BACKUP_PREFIX = ".backup-";
|
|
168
360
|
/** Shared @alfe.ai packages available to all integration hooks */
|
|
169
361
|
const SHARED_PACKAGES = {
|
|
170
362
|
"@alfe.ai/config": "latest",
|
|
@@ -191,7 +383,10 @@ var Installer = class {
|
|
|
191
383
|
* Get the path where an integration's content lives.
|
|
192
384
|
*/
|
|
193
385
|
getInstallPath(name) {
|
|
194
|
-
|
|
386
|
+
const safeName = assertSafePathSegment(name, "Integration id");
|
|
387
|
+
const installPath = join(this.basePath, safeName);
|
|
388
|
+
this.recoverInterruptedSwap(safeName, installPath);
|
|
389
|
+
return installPath;
|
|
195
390
|
}
|
|
196
391
|
/**
|
|
197
392
|
* Install an integration by cloning its git repo and checking out the pinned commit.
|
|
@@ -205,6 +400,7 @@ var Installer = class {
|
|
|
205
400
|
* it is cleaned up before retrying.
|
|
206
401
|
*/
|
|
207
402
|
async install(resolved) {
|
|
403
|
+
this.validateResolved(resolved);
|
|
208
404
|
const installPath = this.getInstallPath(resolved.id);
|
|
209
405
|
if (existsSync(installPath)) rmSync(installPath, {
|
|
210
406
|
recursive: true,
|
|
@@ -225,6 +421,7 @@ var Installer = class {
|
|
|
225
421
|
try {
|
|
226
422
|
await execFileAsync$2("git", [
|
|
227
423
|
"clone",
|
|
424
|
+
"--",
|
|
228
425
|
resolved.repository,
|
|
229
426
|
installPath
|
|
230
427
|
], { timeout: GIT_TIMEOUT_MS });
|
|
@@ -246,11 +443,12 @@ var Installer = class {
|
|
|
246
443
|
*/
|
|
247
444
|
async cloneAndExtractSubdir(resolved, installPath) {
|
|
248
445
|
if (!resolved.subdir) throw new InstallerError("subdir is required for monorepo extraction");
|
|
249
|
-
const subdir = resolved.subdir;
|
|
446
|
+
const subdir = assertSafeRelativePath(resolved.subdir, "Integration subdir");
|
|
250
447
|
const tempDir = mkdtempSync(join(tmpdir(), `alfe-clone-${resolved.id}-`));
|
|
251
448
|
try {
|
|
252
449
|
await execFileAsync$2("git", [
|
|
253
450
|
"clone",
|
|
451
|
+
"--",
|
|
254
452
|
resolved.repository,
|
|
255
453
|
tempDir
|
|
256
454
|
], { timeout: GIT_TIMEOUT_MS });
|
|
@@ -258,9 +456,8 @@ var Installer = class {
|
|
|
258
456
|
cwd: tempDir,
|
|
259
457
|
timeout: GIT_TIMEOUT_MS
|
|
260
458
|
});
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
cpSync(subdirPath, installPath, { recursive: true });
|
|
459
|
+
if (!existsSync(join(tempDir, subdir))) throw new InstallerError(`Subdir "${subdir}" not found in repo after checkout`);
|
|
460
|
+
cpSync(resolveExistingWithin(tempDir, subdir, "Integration subdir", "directory"), installPath, { recursive: true });
|
|
264
461
|
} catch (err) {
|
|
265
462
|
if (existsSync(installPath)) rmSync(installPath, {
|
|
266
463
|
recursive: true,
|
|
@@ -294,6 +491,7 @@ var Installer = class {
|
|
|
294
491
|
* cleaned up (try/finally) so a failed prepare leaves no partial clone.
|
|
295
492
|
*/
|
|
296
493
|
async stage(resolved) {
|
|
494
|
+
this.validateResolved(resolved);
|
|
297
495
|
mkdirSync(this.basePath, { recursive: true });
|
|
298
496
|
this.sweepStagingDirs();
|
|
299
497
|
const stagingPath = join(this.basePath, `${STAGING_PREFIX}${resolved.id}-${randomBytes(6).toString("hex")}`);
|
|
@@ -317,18 +515,55 @@ var Installer = class {
|
|
|
317
515
|
}
|
|
318
516
|
}
|
|
319
517
|
/**
|
|
320
|
-
* Commit a previously-staged clone
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
518
|
+
* Commit a previously-staged clone with a recoverable same-filesystem swap.
|
|
519
|
+
* The live dir first moves to a deterministic backup, then the staged dir
|
|
520
|
+
* moves into place. A failed second rename restores the prior install; a
|
|
521
|
+
* daemon crash is recovered on the next `getInstallPath()` call.
|
|
324
522
|
*/
|
|
325
523
|
commitStaged(name, stagedPath) {
|
|
326
524
|
const installPath = this.getInstallPath(name);
|
|
327
|
-
|
|
525
|
+
const base = realpathSync(this.basePath);
|
|
526
|
+
const staged = realpathSync(stagedPath);
|
|
527
|
+
const backupPath = join(this.basePath, `${BACKUP_PREFIX}${name}`);
|
|
528
|
+
if (dirname(staged) !== base || !basename(staged).startsWith(`${STAGING_PREFIX}${name}-`)) throw new InstallerError("Refusing to commit a staging directory outside the integrations root");
|
|
529
|
+
if (existsSync(backupPath)) rmSync(backupPath, {
|
|
530
|
+
recursive: true,
|
|
531
|
+
force: true
|
|
532
|
+
});
|
|
533
|
+
const hadLiveInstall = existsSync(installPath);
|
|
534
|
+
if (hadLiveInstall) renameSync(installPath, backupPath);
|
|
535
|
+
try {
|
|
536
|
+
renameSync(staged, installPath);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
if (hadLiveInstall && existsSync(backupPath) && !existsSync(installPath)) renameSync(backupPath, installPath);
|
|
539
|
+
throw err;
|
|
540
|
+
}
|
|
541
|
+
if (existsSync(backupPath)) try {
|
|
542
|
+
rmSync(backupPath, {
|
|
543
|
+
recursive: true,
|
|
544
|
+
force: true
|
|
545
|
+
});
|
|
546
|
+
} catch (err) {
|
|
547
|
+
log$6.warn({
|
|
548
|
+
integrationId: name,
|
|
549
|
+
err: err instanceof Error ? err.message : String(err)
|
|
550
|
+
}, "Upgrade committed but prior install backup could not be removed");
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/** Restore a live install left in the deterministic backup by a killed swap. */
|
|
554
|
+
recoverInterruptedSwap(name, installPath) {
|
|
555
|
+
if (!existsSync(this.basePath)) return;
|
|
556
|
+
const backupPath = join(this.basePath, `${BACKUP_PREFIX}${name}`);
|
|
557
|
+
if (!existsSync(backupPath)) return;
|
|
558
|
+
if (!existsSync(installPath)) {
|
|
559
|
+
renameSync(backupPath, installPath);
|
|
560
|
+
log$6.warn({ integrationId: name }, "Recovered integration install from interrupted upgrade swap");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
rmSync(backupPath, {
|
|
328
564
|
recursive: true,
|
|
329
565
|
force: true
|
|
330
566
|
});
|
|
331
|
-
renameSync(stagedPath, installPath);
|
|
332
567
|
}
|
|
333
568
|
/**
|
|
334
569
|
* Remove any orphaned `.staging-*` directories under the base path. Called at
|
|
@@ -359,11 +594,10 @@ var Installer = class {
|
|
|
359
594
|
async update(name, resolved) {
|
|
360
595
|
const installPath = this.getInstallPath(name);
|
|
361
596
|
if (!existsSync(installPath)) throw new InstallerError(`Integration "${name}" is not installed — cannot update`);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
return this.install(resolved);
|
|
597
|
+
if (resolved.id !== name) throw new InstallerError("Resolved integration id must match the installed integration id");
|
|
598
|
+
const stagedPath = await this.stage(resolved);
|
|
599
|
+
this.commitStaged(name, stagedPath);
|
|
600
|
+
return installPath;
|
|
367
601
|
}
|
|
368
602
|
/**
|
|
369
603
|
* Remove an installed integration.
|
|
@@ -399,7 +633,7 @@ var Installer = class {
|
|
|
399
633
|
type: "module",
|
|
400
634
|
dependencies: { ...SHARED_PACKAGES }
|
|
401
635
|
};
|
|
402
|
-
|
|
636
|
+
atomicWriteFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n");
|
|
403
637
|
log$6.info("Installing shared @alfe.ai packages for integration hooks");
|
|
404
638
|
await this.runNpmInstall(this.basePath);
|
|
405
639
|
this.sharedPackagesReady = true;
|
|
@@ -522,14 +756,20 @@ var Installer = class {
|
|
|
522
756
|
isInstalled(name) {
|
|
523
757
|
return existsSync(this.getInstallPath(name));
|
|
524
758
|
}
|
|
759
|
+
validateResolved(resolved) {
|
|
760
|
+
assertSafePathSegment(resolved.id, "Integration id");
|
|
761
|
+
if (resolved.repository.length === 0 || resolved.repository.startsWith("-") || resolved.repository.includes("\0")) throw new InstallerError("Integration repository is invalid");
|
|
762
|
+
if (!/^[0-9a-f]{6,64}$/i.test(resolved.commit)) throw new InstallerError("Integration commit must be a hexadecimal git object id");
|
|
763
|
+
if (resolved.subdir) assertSafeRelativePath(resolved.subdir, "Integration subdir");
|
|
764
|
+
}
|
|
525
765
|
};
|
|
526
766
|
//#endregion
|
|
527
767
|
//#region src/state.ts
|
|
528
768
|
/**
|
|
529
769
|
* State Manager — manages ~/.alfe/integrations.json.
|
|
530
770
|
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
771
|
+
* Daemon-owned read-modify-write state with crash-safe atomic replacement.
|
|
772
|
+
* Secrets are NEVER written to this file.
|
|
533
773
|
*/
|
|
534
774
|
const DEFAULT_STATE_PATH = join(homedir(), ".alfe", "integrations.json");
|
|
535
775
|
const EMPTY_STATE = {
|
|
@@ -538,7 +778,6 @@ const EMPTY_STATE = {
|
|
|
538
778
|
};
|
|
539
779
|
var StateManager = class {
|
|
540
780
|
filePath;
|
|
541
|
-
lockHeld = false;
|
|
542
781
|
constructor(filePath) {
|
|
543
782
|
this.filePath = filePath ?? DEFAULT_STATE_PATH;
|
|
544
783
|
}
|
|
@@ -550,27 +789,19 @@ var StateManager = class {
|
|
|
550
789
|
...EMPTY_STATE,
|
|
551
790
|
integrations: {}
|
|
552
791
|
};
|
|
792
|
+
let parsed;
|
|
553
793
|
try {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
...EMPTY_STATE,
|
|
558
|
-
integrations: {}
|
|
559
|
-
};
|
|
560
|
-
return parsed;
|
|
561
|
-
} catch {
|
|
562
|
-
return {
|
|
563
|
-
...EMPTY_STATE,
|
|
564
|
-
integrations: {}
|
|
565
|
-
};
|
|
794
|
+
parsed = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
795
|
+
} catch (err) {
|
|
796
|
+
throw new Error(`Integration state file is unreadable; refusing an empty-state fallback: ${err instanceof Error ? err.message : String(err)}`);
|
|
566
797
|
}
|
|
798
|
+
return validateStateFile(parsed);
|
|
567
799
|
}
|
|
568
800
|
/**
|
|
569
801
|
* Write the state file atomically.
|
|
570
802
|
*/
|
|
571
803
|
write(state) {
|
|
572
|
-
|
|
573
|
-
writeFileSync(this.filePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
804
|
+
atomicWriteFileSync(this.filePath, JSON.stringify(state, null, 2) + "\n");
|
|
574
805
|
}
|
|
575
806
|
/**
|
|
576
807
|
* Get a specific integration's state.
|
|
@@ -582,6 +813,7 @@ var StateManager = class {
|
|
|
582
813
|
* Set an integration's state (read-modify-write).
|
|
583
814
|
*/
|
|
584
815
|
set(name, entry) {
|
|
816
|
+
assertSafePathSegment(name, "Integration id");
|
|
585
817
|
const state = this.read();
|
|
586
818
|
state.integrations[name] = entry;
|
|
587
819
|
this.write(state);
|
|
@@ -591,7 +823,8 @@ var StateManager = class {
|
|
|
591
823
|
*/
|
|
592
824
|
update(name, partial) {
|
|
593
825
|
const state = this.read();
|
|
594
|
-
|
|
826
|
+
assertSafePathSegment(name, "Integration id");
|
|
827
|
+
if (!Object.hasOwn(state.integrations, name)) throw new Error(`Integration "${name}" not found in state`);
|
|
595
828
|
state.integrations[name] = {
|
|
596
829
|
...state.integrations[name],
|
|
597
830
|
...partial
|
|
@@ -603,7 +836,8 @@ var StateManager = class {
|
|
|
603
836
|
*/
|
|
604
837
|
setStatus(name, status, error) {
|
|
605
838
|
const state = this.read();
|
|
606
|
-
|
|
839
|
+
assertSafePathSegment(name, "Integration id");
|
|
840
|
+
if (!Object.hasOwn(state.integrations, name)) return;
|
|
607
841
|
const existing = state.integrations[name];
|
|
608
842
|
existing.status = status;
|
|
609
843
|
if (error !== void 0) existing.error = error;
|
|
@@ -614,6 +848,7 @@ var StateManager = class {
|
|
|
614
848
|
* Remove an integration from the state file.
|
|
615
849
|
*/
|
|
616
850
|
remove(name) {
|
|
851
|
+
assertSafePathSegment(name, "Integration id");
|
|
617
852
|
const state = this.read();
|
|
618
853
|
state.integrations = Object.fromEntries(Object.entries(state.integrations).filter(([key]) => key !== name));
|
|
619
854
|
this.write(state);
|
|
@@ -624,8 +859,8 @@ var StateManager = class {
|
|
|
624
859
|
list() {
|
|
625
860
|
const state = this.read();
|
|
626
861
|
return Object.entries(state.integrations).map(([id, entry]) => ({
|
|
627
|
-
|
|
628
|
-
|
|
862
|
+
...entry,
|
|
863
|
+
id
|
|
629
864
|
}));
|
|
630
865
|
}
|
|
631
866
|
/**
|
|
@@ -641,7 +876,8 @@ var StateManager = class {
|
|
|
641
876
|
*/
|
|
642
877
|
incrementReinstallAttempts(name) {
|
|
643
878
|
const state = this.read();
|
|
644
|
-
|
|
879
|
+
assertSafePathSegment(name, "Integration id");
|
|
880
|
+
if (!Object.hasOwn(state.integrations, name)) return;
|
|
645
881
|
state.integrations[name].reinstallAttempts = (state.integrations[name].reinstallAttempts ?? 0) + 1;
|
|
646
882
|
this.write(state);
|
|
647
883
|
}
|
|
@@ -650,11 +886,35 @@ var StateManager = class {
|
|
|
650
886
|
*/
|
|
651
887
|
resetReinstallAttempts(name) {
|
|
652
888
|
const state = this.read();
|
|
653
|
-
|
|
889
|
+
assertSafePathSegment(name, "Integration id");
|
|
890
|
+
if (!Object.hasOwn(state.integrations, name)) return;
|
|
654
891
|
state.integrations[name].reinstallAttempts = 0;
|
|
655
892
|
this.write(state);
|
|
656
893
|
}
|
|
657
894
|
};
|
|
895
|
+
const VALID_STATUSES = new Set([
|
|
896
|
+
"installing",
|
|
897
|
+
"installed",
|
|
898
|
+
"configured",
|
|
899
|
+
"active",
|
|
900
|
+
"error"
|
|
901
|
+
]);
|
|
902
|
+
function isRecord$1(value) {
|
|
903
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
904
|
+
}
|
|
905
|
+
function validateStateFile(value) {
|
|
906
|
+
if (!isRecord$1(value) || value.version !== 1 || !isRecord$1(value.integrations)) throw new Error("Integration state file has an unsupported or invalid shape");
|
|
907
|
+
const integrations = Object.create(null);
|
|
908
|
+
for (const [id, rawEntry] of Object.entries(value.integrations)) {
|
|
909
|
+
assertSafePathSegment(id, "Persisted integration id");
|
|
910
|
+
if (!isRecord$1(rawEntry) || typeof rawEntry.status !== "string" || !VALID_STATUSES.has(rawEntry.status) || typeof rawEntry.version !== "string" || typeof rawEntry.installedAt !== "string" || !isRecord$1(rawEntry.config) || rawEntry.error !== void 0 && typeof rawEntry.error !== "string" || rawEntry.reinstallAttempts !== void 0 && (typeof rawEntry.reinstallAttempts !== "number" || !Number.isSafeInteger(rawEntry.reinstallAttempts) || rawEntry.reinstallAttempts < 0) || rawEntry.customConnectionId !== void 0 && typeof rawEntry.customConnectionId !== "string") throw new Error(`Integration state entry "${id}" has an invalid shape`);
|
|
911
|
+
integrations[id] = rawEntry;
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
version: 1,
|
|
915
|
+
integrations
|
|
916
|
+
};
|
|
917
|
+
}
|
|
658
918
|
//#endregion
|
|
659
919
|
//#region src/lock.ts
|
|
660
920
|
/**
|
|
@@ -683,30 +943,20 @@ var LockManager = class {
|
|
|
683
943
|
runtimes: {},
|
|
684
944
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
685
945
|
};
|
|
946
|
+
let parsed;
|
|
686
947
|
try {
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
...EMPTY_LOCK,
|
|
691
|
-
runtimes: {},
|
|
692
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
693
|
-
};
|
|
694
|
-
return parsed;
|
|
695
|
-
} catch {
|
|
696
|
-
return {
|
|
697
|
-
...EMPTY_LOCK,
|
|
698
|
-
runtimes: {},
|
|
699
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
700
|
-
};
|
|
948
|
+
parsed = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
949
|
+
} catch (err) {
|
|
950
|
+
throw new Error(`Runtime lock file is unreadable; refusing an empty-lock fallback: ${err instanceof Error ? err.message : String(err)}`);
|
|
701
951
|
}
|
|
952
|
+
return validateRuntimeLock(parsed);
|
|
702
953
|
}
|
|
703
954
|
/**
|
|
704
955
|
* Write the lock file atomically.
|
|
705
956
|
*/
|
|
706
957
|
write(lock) {
|
|
707
|
-
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
708
958
|
lock.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
709
|
-
|
|
959
|
+
atomicWriteFileSync(this.filePath, JSON.stringify(lock, null, 2) + "\n");
|
|
710
960
|
}
|
|
711
961
|
/**
|
|
712
962
|
* Add entries for an integration activation in a specific runtime.
|
|
@@ -716,6 +966,8 @@ var LockManager = class {
|
|
|
716
966
|
* what lets a config-only integration be torn down on deactivate.
|
|
717
967
|
*/
|
|
718
968
|
addEntries(runtime, integrationId, version, plugins, skills, installPath, opts) {
|
|
969
|
+
assertSafePathSegment(runtime, "Runtime id");
|
|
970
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
719
971
|
const lock = this.read();
|
|
720
972
|
if (!(runtime in lock.runtimes)) lock.runtimes[runtime] = {
|
|
721
973
|
plugins: [],
|
|
@@ -748,6 +1000,27 @@ var LockManager = class {
|
|
|
748
1000
|
this.write(lock);
|
|
749
1001
|
}
|
|
750
1002
|
/**
|
|
1003
|
+
* Snapshot an integration's entries without changing the lock. Teardown uses
|
|
1004
|
+
* this first and only clears the entries after every physical removal
|
|
1005
|
+
* succeeds, making a failed cleanup retryable.
|
|
1006
|
+
*/
|
|
1007
|
+
getEntriesForIntegration(integrationId) {
|
|
1008
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
1009
|
+
const lock = this.read();
|
|
1010
|
+
const entries = {};
|
|
1011
|
+
for (const [runtime, state] of Object.entries(lock.runtimes)) {
|
|
1012
|
+
const plugins = state.plugins.filter((entry) => entry.sourceIntegration === integrationId);
|
|
1013
|
+
const skills = state.skills.filter((entry) => entry.sourceIntegration === integrationId);
|
|
1014
|
+
const config = (state.config ?? []).filter((entry) => entry.sourceIntegration === integrationId);
|
|
1015
|
+
if (plugins.length > 0 || skills.length > 0 || config.length > 0) entries[runtime] = {
|
|
1016
|
+
plugins,
|
|
1017
|
+
skills,
|
|
1018
|
+
config
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
return entries;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
751
1024
|
* Remove all entries for a given integration across all runtimes.
|
|
752
1025
|
* Returns what was removed, keyed by runtime.
|
|
753
1026
|
*
|
|
@@ -756,6 +1029,7 @@ var LockManager = class {
|
|
|
756
1029
|
* `applier.removeConfig` even for a config-only integration.
|
|
757
1030
|
*/
|
|
758
1031
|
removeEntries(integrationId) {
|
|
1032
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
759
1033
|
const lock = this.read();
|
|
760
1034
|
const removed = {};
|
|
761
1035
|
for (const [runtime, state] of Object.entries(lock.runtimes)) {
|
|
@@ -784,6 +1058,49 @@ var LockManager = class {
|
|
|
784
1058
|
};
|
|
785
1059
|
}
|
|
786
1060
|
};
|
|
1061
|
+
function isRecord(value) {
|
|
1062
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1063
|
+
}
|
|
1064
|
+
function hasStringFields(value, fields) {
|
|
1065
|
+
return isRecord(value) && fields.every((field) => typeof value[field] === "string");
|
|
1066
|
+
}
|
|
1067
|
+
function validateRuntimeLock(value) {
|
|
1068
|
+
if (!isRecord(value) || value.version !== 1 || !isRecord(value.runtimes) || typeof value.updatedAt !== "string") throw new Error("Runtime lock file has an unsupported or invalid shape");
|
|
1069
|
+
const runtimes = Object.create(null);
|
|
1070
|
+
for (const [runtime, rawState] of Object.entries(value.runtimes)) {
|
|
1071
|
+
assertSafePathSegment(runtime, "Persisted runtime id");
|
|
1072
|
+
if (!isRecord(rawState) || !Array.isArray(rawState.plugins) || !rawState.plugins.every((entry) => hasStringFields(entry, [
|
|
1073
|
+
"package",
|
|
1074
|
+
"sourceIntegration",
|
|
1075
|
+
"integrationVersion"
|
|
1076
|
+
])) || !Array.isArray(rawState.skills) || !rawState.skills.every((entry) => hasStringFields(entry, [
|
|
1077
|
+
"name",
|
|
1078
|
+
"sourcePath",
|
|
1079
|
+
"sourceIntegration",
|
|
1080
|
+
"integrationVersion"
|
|
1081
|
+
])) || rawState.config !== void 0 && (!Array.isArray(rawState.config) || !rawState.config.every((entry) => hasStringFields(entry, ["sourceIntegration", "integrationVersion"])))) throw new Error(`Runtime lock entry "${runtime}" has an invalid shape`);
|
|
1082
|
+
const state = rawState;
|
|
1083
|
+
for (const plugin of state.plugins) {
|
|
1084
|
+
assertSafePathSegment(plugin.sourceIntegration, "Persisted integration id");
|
|
1085
|
+
if (plugin.package.length === 0 || plugin.package.startsWith("-") || plugin.package.includes("\0") || plugin.integrationVersion.length === 0) throw new Error(`Runtime lock plugin entry in "${runtime}" is invalid`);
|
|
1086
|
+
}
|
|
1087
|
+
for (const skill of state.skills) {
|
|
1088
|
+
assertSafePathSegment(skill.sourceIntegration, "Persisted integration id");
|
|
1089
|
+
assertSafePathSegment(skill.name, "Persisted skill name");
|
|
1090
|
+
if (skill.integrationVersion.length === 0 || skill.sourcePath.includes("\0")) throw new Error(`Runtime lock skill entry in "${runtime}" is invalid`);
|
|
1091
|
+
}
|
|
1092
|
+
for (const config of state.config ?? []) {
|
|
1093
|
+
assertSafePathSegment(config.sourceIntegration, "Persisted integration id");
|
|
1094
|
+
if (config.integrationVersion.length === 0) throw new Error(`Runtime lock config entry in "${runtime}" is invalid`);
|
|
1095
|
+
}
|
|
1096
|
+
runtimes[runtime] = state;
|
|
1097
|
+
}
|
|
1098
|
+
return {
|
|
1099
|
+
version: 1,
|
|
1100
|
+
runtimes,
|
|
1101
|
+
updatedAt: value.updatedAt
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
787
1104
|
//#endregion
|
|
788
1105
|
//#region src/hooks.ts
|
|
789
1106
|
/**
|
|
@@ -821,6 +1138,7 @@ const HOOK_TIMEOUT_MS = 3e4;
|
|
|
821
1138
|
const INSTALL_HOOK_TIMEOUT_MS = 6e5;
|
|
822
1139
|
const INTEGRATIONS_BASE_DIR = join(homedir(), ".alfe", "integrations");
|
|
823
1140
|
const STATE_BASE_DIR = join(homedir(), ".alfe", "state");
|
|
1141
|
+
const IS_WINDOWS = platform() === "win32";
|
|
824
1142
|
/**
|
|
825
1143
|
* Build the environment variables for a hook script execution.
|
|
826
1144
|
*
|
|
@@ -833,6 +1151,7 @@ const STATE_BASE_DIR = join(homedir(), ".alfe", "state");
|
|
|
833
1151
|
*/
|
|
834
1152
|
function buildHookEnv(options, additionalEnv) {
|
|
835
1153
|
const { integrationName, config, secrets, runtimes } = options;
|
|
1154
|
+
assertSafePathSegment(integrationName, "Integration id");
|
|
836
1155
|
const nameUpper = integrationName.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
837
1156
|
const integrationDir = join(INTEGRATIONS_BASE_DIR, integrationName);
|
|
838
1157
|
const stateDir = join(STATE_BASE_DIR, integrationName);
|
|
@@ -871,12 +1190,13 @@ function buildHookEnv(options, additionalEnv) {
|
|
|
871
1190
|
* 3. Default → "bash"
|
|
872
1191
|
*/
|
|
873
1192
|
function resolveInterpreter(scriptPath) {
|
|
874
|
-
|
|
1193
|
+
let fd;
|
|
875
1194
|
try {
|
|
876
|
-
|
|
1195
|
+
fd = openSync(scriptPath, "r");
|
|
877
1196
|
const buf = Buffer.alloc(256);
|
|
878
1197
|
readSync(fd, buf, 0, 256, 0);
|
|
879
1198
|
closeSync(fd);
|
|
1199
|
+
fd = void 0;
|
|
880
1200
|
const firstLine = buf.toString("utf-8").split("\n")[0];
|
|
881
1201
|
if (firstLine.startsWith("#!")) {
|
|
882
1202
|
const shebang = firstLine.slice(2).trim();
|
|
@@ -888,7 +1208,7 @@ function resolveInterpreter(scriptPath) {
|
|
|
888
1208
|
};
|
|
889
1209
|
}
|
|
890
1210
|
const cmd = shebang.split(/\s+/)[0];
|
|
891
|
-
if (
|
|
1211
|
+
if (IS_WINDOWS && cmd.startsWith("/")) return {
|
|
892
1212
|
command: cmd.split("/").pop() ?? cmd,
|
|
893
1213
|
args: [scriptPath]
|
|
894
1214
|
};
|
|
@@ -897,13 +1217,15 @@ function resolveInterpreter(scriptPath) {
|
|
|
897
1217
|
args: [scriptPath]
|
|
898
1218
|
};
|
|
899
1219
|
}
|
|
900
|
-
} catch {}
|
|
1220
|
+
} catch {} finally {
|
|
1221
|
+
if (fd !== void 0) closeSync(fd);
|
|
1222
|
+
}
|
|
901
1223
|
if (scriptPath.endsWith(".js") || scriptPath.endsWith(".mjs")) return {
|
|
902
1224
|
command: "node",
|
|
903
1225
|
args: [scriptPath]
|
|
904
1226
|
};
|
|
905
1227
|
if (scriptPath.endsWith(".py")) return {
|
|
906
|
-
command:
|
|
1228
|
+
command: IS_WINDOWS ? "python" : "python3",
|
|
907
1229
|
args: [scriptPath]
|
|
908
1230
|
};
|
|
909
1231
|
return {
|
|
@@ -926,13 +1248,17 @@ function resolveInterpreter(scriptPath) {
|
|
|
926
1248
|
* @returns Hook execution result
|
|
927
1249
|
*/
|
|
928
1250
|
async function runHook(integrationPath, hookScript, env, timeoutMs = HOOK_TIMEOUT_MS) {
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1251
|
+
let scriptPath;
|
|
1252
|
+
try {
|
|
1253
|
+
scriptPath = resolveExistingWithin(integrationPath, hookScript, "Integration hook", "file");
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
return {
|
|
1256
|
+
exitCode: 1,
|
|
1257
|
+
stdout: "",
|
|
1258
|
+
stderr: `Invalid integration hook: ${err instanceof Error ? err.message : String(err)}`,
|
|
1259
|
+
timedOut: false
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
936
1262
|
const { command, args } = resolveInterpreter(scriptPath);
|
|
937
1263
|
return new Promise((resolve) => {
|
|
938
1264
|
let stdout = "";
|
|
@@ -948,10 +1274,29 @@ async function runHook(integrationPath, hookScript, env, timeoutMs = HOOK_TIMEOU
|
|
|
948
1274
|
"ignore",
|
|
949
1275
|
"pipe",
|
|
950
1276
|
"pipe"
|
|
951
|
-
]
|
|
1277
|
+
],
|
|
1278
|
+
detached: !IS_WINDOWS
|
|
952
1279
|
});
|
|
953
1280
|
const timer = setTimeout(() => {
|
|
954
1281
|
timedOut = true;
|
|
1282
|
+
if (IS_WINDOWS && proc.pid !== void 0) {
|
|
1283
|
+
spawn("taskkill", [
|
|
1284
|
+
"/PID",
|
|
1285
|
+
String(proc.pid),
|
|
1286
|
+
"/T",
|
|
1287
|
+
"/F"
|
|
1288
|
+
], {
|
|
1289
|
+
stdio: "ignore",
|
|
1290
|
+
windowsHide: true
|
|
1291
|
+
}).once("error", () => {
|
|
1292
|
+
proc.kill("SIGKILL");
|
|
1293
|
+
});
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
if (!IS_WINDOWS && proc.pid !== void 0) try {
|
|
1297
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
1298
|
+
return;
|
|
1299
|
+
} catch {}
|
|
955
1300
|
proc.kill("SIGKILL");
|
|
956
1301
|
}, timeoutMs);
|
|
957
1302
|
proc.stdout.on("data", (data) => {
|
|
@@ -1063,20 +1408,50 @@ function pluginSpecVersion(spec) {
|
|
|
1063
1408
|
const FULL_PLACEHOLDER = /^\{\{config\.([a-zA-Z0-9_]+)\}\}$/;
|
|
1064
1409
|
function interpolateSelfConfig(obj, config) {
|
|
1065
1410
|
const result = {};
|
|
1066
|
-
for (const [key, value] of Object.entries(obj))
|
|
1411
|
+
for (const [key, value] of Object.entries(obj)) result[key] = interpolateSelfConfigValue(value, config);
|
|
1412
|
+
return result;
|
|
1413
|
+
}
|
|
1414
|
+
function interpolateSelfConfigValue(value, config) {
|
|
1415
|
+
if (typeof value === "string") {
|
|
1067
1416
|
const wholeMatch = FULL_PLACEHOLDER.exec(value);
|
|
1068
1417
|
if (wholeMatch) {
|
|
1069
1418
|
const raw = config[wholeMatch[1]];
|
|
1070
|
-
|
|
1071
|
-
continue;
|
|
1419
|
+
return raw !== void 0 ? raw : value;
|
|
1072
1420
|
}
|
|
1073
|
-
|
|
1074
|
-
const
|
|
1075
|
-
return typeof
|
|
1421
|
+
return value.replace(/\{\{config\.([a-zA-Z0-9_]+)\}\}/g, (_match, configKey) => {
|
|
1422
|
+
const candidate = config[configKey];
|
|
1423
|
+
return typeof candidate === "string" || typeof candidate === "number" ? String(candidate) : _match;
|
|
1076
1424
|
});
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
return
|
|
1425
|
+
}
|
|
1426
|
+
if (Array.isArray(value)) return value.map((item) => interpolateSelfConfigValue(item, config));
|
|
1427
|
+
if (value && typeof value === "object") return interpolateSelfConfig(value, config);
|
|
1428
|
+
return value;
|
|
1429
|
+
}
|
|
1430
|
+
var IntegrationConfigValidationError = class extends Error {
|
|
1431
|
+
constructor(code, message) {
|
|
1432
|
+
super(message);
|
|
1433
|
+
this.code = code;
|
|
1434
|
+
this.name = "IntegrationConfigValidationError";
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
function validateAndSplitConfig(manifest, config, options = {}) {
|
|
1438
|
+
if (options.allowEmpty && Object.keys(config).length === 0) return {
|
|
1439
|
+
nonSecretConfig: {},
|
|
1440
|
+
secretConfig: /* @__PURE__ */ new Map()
|
|
1441
|
+
};
|
|
1442
|
+
const result = buildConfigValidationSchema(manifest.config_schema).safeParse(config);
|
|
1443
|
+
if (!result.success) throw new IntegrationConfigValidationError("INVALID_CONFIG", `Config validation failed: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
|
|
1444
|
+
const validatedConfig = result.data;
|
|
1445
|
+
const missingRequired = manifest.config_schema.filter((field) => field.required && validatedConfig[field.key] === void 0).map((field) => field.key);
|
|
1446
|
+
if (missingRequired.length > 0) throw new IntegrationConfigValidationError("MISSING_CONFIG", `Missing required config fields: ${missingRequired.join(", ")}`);
|
|
1447
|
+
const nonSecretConfig = {};
|
|
1448
|
+
const secretConfig = /* @__PURE__ */ new Map();
|
|
1449
|
+
for (const [key, value] of Object.entries(validatedConfig)) if (manifest.config_schema.find((candidate) => candidate.key === key)?.type === "secret") secretConfig.set(key, value);
|
|
1450
|
+
else nonSecretConfig[key] = value;
|
|
1451
|
+
return {
|
|
1452
|
+
nonSecretConfig,
|
|
1453
|
+
secretConfig
|
|
1454
|
+
};
|
|
1080
1455
|
}
|
|
1081
1456
|
/**
|
|
1082
1457
|
* Merge universal installs with runtime-specific installs from the manifest.
|
|
@@ -1097,8 +1472,17 @@ function resolveInstallsForRuntime(manifest, runtime) {
|
|
|
1097
1472
|
* checkout-able `manifestCommit` pins the clone.
|
|
1098
1473
|
*/
|
|
1099
1474
|
function buildCustomResolved(id, cs) {
|
|
1100
|
-
|
|
1101
|
-
const
|
|
1475
|
+
assertSafePathSegment(id, "Custom integration id");
|
|
1476
|
+
const manifestPath = assertSafeRelativePath(cs.manifestPath, "Custom integration manifest path");
|
|
1477
|
+
let repositoryUrl;
|
|
1478
|
+
try {
|
|
1479
|
+
repositoryUrl = new URL(cs.manifestRepo);
|
|
1480
|
+
} catch {
|
|
1481
|
+
throw new Error("Custom integration repository must be a valid HTTPS URL");
|
|
1482
|
+
}
|
|
1483
|
+
if (repositoryUrl.protocol !== "https:" || repositoryUrl.username !== "" || repositoryUrl.password !== "") throw new Error("Custom integration repository must be an HTTPS URL without embedded credentials");
|
|
1484
|
+
if (!/^[0-9a-f]{40}$/i.test(cs.manifestCommit)) throw new Error("Custom integration commit must be a full 40-character SHA-1");
|
|
1485
|
+
const dir = dirname(manifestPath);
|
|
1102
1486
|
const subdir = dir === "." || dir === "" ? void 0 : dir;
|
|
1103
1487
|
const manifestVersion = cs.manifest?.version;
|
|
1104
1488
|
return {
|
|
@@ -1117,9 +1501,15 @@ function buildCustomResolved(id, cs) {
|
|
|
1117
1501
|
*/
|
|
1118
1502
|
function ensureCanonicalManifestName(installPath, manifestPath) {
|
|
1119
1503
|
const canonical = join(installPath, "alfe-integration.yaml");
|
|
1120
|
-
if (existsSync(canonical))
|
|
1121
|
-
|
|
1122
|
-
|
|
1504
|
+
if (existsSync(canonical)) {
|
|
1505
|
+
resolveExistingWithin(installPath, "alfe-integration.yaml", "Canonical integration manifest", "file");
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
rmSync(canonical, { force: true });
|
|
1509
|
+
copyFileSync(resolveExistingWithin(installPath, basename(manifestPath), "Custom integration manifest", "file"), canonical);
|
|
1510
|
+
}
|
|
1511
|
+
function assertManifestIdentity(installId, manifest, isCustom) {
|
|
1512
|
+
if (!(isCustom ? installId.startsWith(`custom:${manifest.id}@`) : manifest.id === installId)) throw new Error(`Manifest id "${manifest.id}" does not match install id "${installId}"`);
|
|
1123
1513
|
}
|
|
1124
1514
|
var IntegrationManager = class {
|
|
1125
1515
|
log = createLogger("IntegrationManager");
|
|
@@ -1158,6 +1548,11 @@ var IntegrationManager = class {
|
|
|
1158
1548
|
async install(params) {
|
|
1159
1549
|
const { name, version, config, customSource } = params;
|
|
1160
1550
|
if (!name) return this.err("INVALID_PARAMS", "Integration name is required");
|
|
1551
|
+
try {
|
|
1552
|
+
assertSafePathSegment(name, "Integration id");
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
return this.err("INVALID_PARAMS", err instanceof Error ? err.message : String(err));
|
|
1555
|
+
}
|
|
1161
1556
|
const existing = this.state.get(name);
|
|
1162
1557
|
if (existing) {
|
|
1163
1558
|
if (existing.status === "installed" || existing.status === "configured" || existing.status === "active") {
|
|
@@ -1197,7 +1592,10 @@ var IntegrationManager = class {
|
|
|
1197
1592
|
const manifestPath = join(installPath, "alfe-integration.yaml");
|
|
1198
1593
|
if (!existsSync(manifestPath)) throw new Error(`No alfe-integration.yaml found in ${installPath}`);
|
|
1199
1594
|
const manifest = parseManifestFile(manifestPath);
|
|
1595
|
+
assertManifestIdentity(name, manifest, customSource !== void 0);
|
|
1200
1596
|
this.log.info(`Manifest validated: ${manifest.id}@${manifest.version}`);
|
|
1597
|
+
const preparedConfig = validateAndSplitConfig(manifest, config ?? {}, { allowEmpty: true });
|
|
1598
|
+
this.secrets.set(name, preparedConfig.secretConfig);
|
|
1201
1599
|
for (const dep of manifest.depends_on) {
|
|
1202
1600
|
const depState = this.state.get(dep);
|
|
1203
1601
|
if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
|
|
@@ -1213,8 +1611,8 @@ var IntegrationManager = class {
|
|
|
1213
1611
|
this.log.info(`Running post_install hook: ${manifest.hooks.post_install}`);
|
|
1214
1612
|
const hookResult = await runHookWithContext(installPath, manifest.hooks.post_install, {
|
|
1215
1613
|
integrationName: name,
|
|
1216
|
-
config:
|
|
1217
|
-
secrets:
|
|
1614
|
+
config: preparedConfig.nonSecretConfig,
|
|
1615
|
+
secrets: preparedConfig.secretConfig,
|
|
1218
1616
|
runtimes: [...this.runtimeAppliers.keys()]
|
|
1219
1617
|
}, INSTALL_HOOK_TIMEOUT_MS);
|
|
1220
1618
|
if (hookResult.exitCode !== 0) throw new Error(this.hookFailureMessage("post_install hook failed", hookResult));
|
|
@@ -1223,7 +1621,7 @@ var IntegrationManager = class {
|
|
|
1223
1621
|
status: "installed",
|
|
1224
1622
|
version: manifest.version,
|
|
1225
1623
|
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1226
|
-
config:
|
|
1624
|
+
config: preparedConfig.nonSecretConfig,
|
|
1227
1625
|
customConnectionId: customSource?.connectionId
|
|
1228
1626
|
});
|
|
1229
1627
|
this.log.info(`Integration "${name}" installed successfully`);
|
|
@@ -1243,6 +1641,7 @@ var IntegrationManager = class {
|
|
|
1243
1641
|
try {
|
|
1244
1642
|
await this.installer.remove(name);
|
|
1245
1643
|
} catch {}
|
|
1644
|
+
this.secrets.delete(name);
|
|
1246
1645
|
this.state.setStatus(name, "error", message);
|
|
1247
1646
|
return this.err("INSTALL_FAILED", message);
|
|
1248
1647
|
}
|
|
@@ -1263,20 +1662,7 @@ var IntegrationManager = class {
|
|
|
1263
1662
|
if (entry.status !== "installed" && entry.status !== "configured" && entry.status !== "active") return this.err("INVALID_STATE", `Cannot configure integration in "${entry.status}" state`);
|
|
1264
1663
|
this.log.info(`Configuring integration: ${name}`);
|
|
1265
1664
|
try {
|
|
1266
|
-
const
|
|
1267
|
-
if (manifest.config_schema.length > 0) {
|
|
1268
|
-
const result = buildConfigValidationSchema(manifest.config_schema).safeParse(config);
|
|
1269
|
-
if (!result.success) {
|
|
1270
|
-
const issues = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
1271
|
-
return this.err("INVALID_CONFIG", `Config validation failed: ${issues}`);
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
const missingRequired = manifest.config_schema.filter((f) => f.required && config[f.key] === void 0).map((f) => f.key);
|
|
1275
|
-
if (missingRequired.length > 0) return this.err("MISSING_CONFIG", `Missing required config fields: ${missingRequired.join(", ")}`);
|
|
1276
|
-
const nonSecretConfig = {};
|
|
1277
|
-
const secretConfig = /* @__PURE__ */ new Map();
|
|
1278
|
-
for (const [key, value] of Object.entries(config)) if (manifest.config_schema.find((f) => f.key === key)?.type === "secret") secretConfig.set(key, value);
|
|
1279
|
-
else nonSecretConfig[key] = value;
|
|
1665
|
+
const { nonSecretConfig, secretConfig } = validateAndSplitConfig(parseManifestFile(join(this.installer.getInstallPath(name), "alfe-integration.yaml")), config);
|
|
1280
1666
|
this.state.update(name, {
|
|
1281
1667
|
status: "configured",
|
|
1282
1668
|
config: nonSecretConfig
|
|
@@ -1293,6 +1679,7 @@ var IntegrationManager = class {
|
|
|
1293
1679
|
}
|
|
1294
1680
|
};
|
|
1295
1681
|
} catch (err) {
|
|
1682
|
+
if (err instanceof IntegrationConfigValidationError) return this.err(err.code, err.message);
|
|
1296
1683
|
const message = err instanceof Error ? err.message : String(err);
|
|
1297
1684
|
this.log.error(`Failed to configure "${name}": ${message}`);
|
|
1298
1685
|
return this.err("CONFIGURE_FAILED", message);
|
|
@@ -1371,7 +1758,7 @@ var IntegrationManager = class {
|
|
|
1371
1758
|
await applier.applyClawHubSkill(skill.clawhub);
|
|
1372
1759
|
} else if (skill.path) {
|
|
1373
1760
|
const skillName = skill.path.split("/").pop() ?? skill.path;
|
|
1374
|
-
const srcPath =
|
|
1761
|
+
const srcPath = resolveExistingWithin(installPath, skill.path, `Skill path for ${skillLabel}`, "directory");
|
|
1375
1762
|
this.log.info(`Applying skill ${skillName} to ${runtimeName}`);
|
|
1376
1763
|
await applier.applySkill(skillName, srcPath);
|
|
1377
1764
|
}
|
|
@@ -1385,8 +1772,10 @@ var IntegrationManager = class {
|
|
|
1385
1772
|
if (skillFailures.length > 0) this.log.warn(`Continuing activation with ${String(skillFailures.length)} failed skill(s): ${skillFailures.join(", ")}`);
|
|
1386
1773
|
let runtimeConfigApplied = false;
|
|
1387
1774
|
if (runtimeConfig && Object.keys(runtimeConfig).length > 0) {
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1775
|
+
const interpolatedConfig = interpolateSelfConfig(runtimeConfig, {
|
|
1776
|
+
...entry.config,
|
|
1777
|
+
...Object.fromEntries(this.secrets.get(integrationId) ?? [])
|
|
1778
|
+
});
|
|
1390
1779
|
this.log.info(`Applying config for ${integrationId} to ${runtimeName}`);
|
|
1391
1780
|
await applier.applyConfig(integrationId, interpolatedConfig);
|
|
1392
1781
|
configApplied = true;
|
|
@@ -1466,7 +1855,8 @@ var IntegrationManager = class {
|
|
|
1466
1855
|
async deactivate(integrationId) {
|
|
1467
1856
|
const entry = this.state.get(integrationId);
|
|
1468
1857
|
if (!entry) return this.err("NOT_FOUND", `Integration "${integrationId}" is not installed`);
|
|
1469
|
-
|
|
1858
|
+
const ownedEntries = this.lockManager.getEntriesForIntegration(integrationId);
|
|
1859
|
+
if (entry.status !== "active" && entry.status !== "error" && Object.keys(ownedEntries).length === 0) return {
|
|
1470
1860
|
ok: true,
|
|
1471
1861
|
payload: {
|
|
1472
1862
|
name: integrationId,
|
|
@@ -1476,16 +1866,18 @@ var IntegrationManager = class {
|
|
|
1476
1866
|
};
|
|
1477
1867
|
this.log.info(`Deactivating integration: ${integrationId}`);
|
|
1478
1868
|
try {
|
|
1479
|
-
const
|
|
1480
|
-
const
|
|
1481
|
-
for (const [runtimeName, entries] of Object.entries(
|
|
1869
|
+
const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime(integrationId);
|
|
1870
|
+
const cleanupFailures = [];
|
|
1871
|
+
for (const [runtimeName, entries] of Object.entries(ownedEntries)) {
|
|
1482
1872
|
const applier = this.runtimeAppliers.get(runtimeName);
|
|
1483
1873
|
if (!applier) {
|
|
1484
1874
|
this.log.warn(`No applier for runtime "${runtimeName}" — cannot remove entries`);
|
|
1875
|
+
cleanupFailures.push(`runtime:${runtimeName}:missing-applier`);
|
|
1485
1876
|
continue;
|
|
1486
1877
|
}
|
|
1487
1878
|
if (!await applier.isAvailable()) {
|
|
1488
1879
|
this.log.warn(`Runtime "${runtimeName}" is not available — skipping removal`);
|
|
1880
|
+
cleanupFailures.push(`runtime:${runtimeName}:unavailable`);
|
|
1489
1881
|
continue;
|
|
1490
1882
|
}
|
|
1491
1883
|
const keepPlugins = claimedPluginsByRuntime.get(runtimeName) ?? /* @__PURE__ */ new Set();
|
|
@@ -1500,6 +1892,7 @@ var IntegrationManager = class {
|
|
|
1500
1892
|
await applier.removePlugin(plugin.package);
|
|
1501
1893
|
} catch (err) {
|
|
1502
1894
|
this.log.warn(`Failed to remove plugin ${plugin.package} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1895
|
+
cleanupFailures.push(`runtime:${runtimeName}:plugin`);
|
|
1503
1896
|
}
|
|
1504
1897
|
}
|
|
1505
1898
|
for (const skill of entries.skills) {
|
|
@@ -1513,13 +1906,17 @@ var IntegrationManager = class {
|
|
|
1513
1906
|
else await applier.removeSkill(skill.name);
|
|
1514
1907
|
} catch (err) {
|
|
1515
1908
|
this.log.warn(`Failed to remove skill ${skill.name} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1909
|
+
cleanupFailures.push(`runtime:${runtimeName}:skill`);
|
|
1516
1910
|
}
|
|
1517
1911
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1912
|
+
if (entries.config.length > 0) {
|
|
1913
|
+
this.log.info(`Removing config for ${integrationId} from ${runtimeName}`);
|
|
1914
|
+
try {
|
|
1915
|
+
await applier.removeConfig(integrationId);
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
this.log.warn(`Failed to remove config for ${integrationId} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1918
|
+
cleanupFailures.push(`runtime:${runtimeName}:config`);
|
|
1919
|
+
}
|
|
1523
1920
|
}
|
|
1524
1921
|
}
|
|
1525
1922
|
if (this.mcpApplier) {
|
|
@@ -1528,8 +1925,11 @@ var IntegrationManager = class {
|
|
|
1528
1925
|
await this.mcpApplier.removeForIntegration(integrationId);
|
|
1529
1926
|
} catch (err) {
|
|
1530
1927
|
this.log.warn(`Failed to remove MCP servers for ${integrationId} via bundler manager: ${err instanceof Error ? err.message : String(err)}`);
|
|
1928
|
+
cleanupFailures.push("mcp");
|
|
1531
1929
|
}
|
|
1532
1930
|
}
|
|
1931
|
+
if (cleanupFailures.length > 0) return this.err("DEACTIVATE_FAILED", `Failed to remove ${String(cleanupFailures.length)} integration contribution(s); ownership lock retained for retry`);
|
|
1932
|
+
this.lockManager.removeEntries(integrationId);
|
|
1533
1933
|
this.state.setStatus(integrationId, "configured");
|
|
1534
1934
|
return {
|
|
1535
1935
|
ok: true,
|
|
@@ -1560,7 +1960,10 @@ var IntegrationManager = class {
|
|
|
1560
1960
|
if (!entry) return this.err("NOT_FOUND", `Integration "${name}" is not installed`);
|
|
1561
1961
|
this.log.info(`Uninstalling integration: ${name}`);
|
|
1562
1962
|
try {
|
|
1563
|
-
if (entry.status === "active"
|
|
1963
|
+
if (entry.status === "active" || entry.status === "error" || Object.keys(this.lockManager.getEntriesForIntegration(name)).length > 0) {
|
|
1964
|
+
const deactivateResult = await this.deactivate(name);
|
|
1965
|
+
if (!deactivateResult.ok) return this.err("UNINSTALL_FAILED", `Could not safely deactivate "${name}": ${deactivateResult.error?.message ?? "unknown cleanup failure"}`);
|
|
1966
|
+
}
|
|
1564
1967
|
const installPath = this.installer.getInstallPath(name);
|
|
1565
1968
|
let manifest = null;
|
|
1566
1969
|
const manifestPath = join(installPath, "alfe-integration.yaml");
|
|
@@ -1623,15 +2026,42 @@ var IntegrationManager = class {
|
|
|
1623
2026
|
async reinstall(params) {
|
|
1624
2027
|
const { name } = params;
|
|
1625
2028
|
if (!name) return this.err("INVALID_PARAMS", "Integration name is required");
|
|
1626
|
-
|
|
2029
|
+
const entry = this.state.get(name);
|
|
2030
|
+
if (!entry) {
|
|
1627
2031
|
const installResult = await this.install(params);
|
|
1628
2032
|
if (!installResult.ok) return installResult;
|
|
1629
2033
|
return this.activate(name);
|
|
1630
2034
|
}
|
|
1631
2035
|
this.log.info(`Reinstalling integration: ${name}`);
|
|
1632
2036
|
try {
|
|
2037
|
+
let effectiveVersion = params.version;
|
|
2038
|
+
if (!effectiveVersion && params.customSource === void 0) try {
|
|
2039
|
+
effectiveVersion = (await this.resolver.resolve(name, void 0, { fresh: true })).version;
|
|
2040
|
+
} catch {
|
|
2041
|
+
effectiveVersion = void 0;
|
|
2042
|
+
}
|
|
2043
|
+
if (effectiveVersion !== void 0 && effectiveVersion === entry.version && params.customSource === void 0) {
|
|
2044
|
+
const localManifestPath = join(this.installer.getInstallPath(name), "alfe-integration.yaml");
|
|
2045
|
+
if (existsSync(localManifestPath)) {
|
|
2046
|
+
let localManifest = null;
|
|
2047
|
+
try {
|
|
2048
|
+
localManifest = parseManifestFile(localManifestPath);
|
|
2049
|
+
} catch {
|
|
2050
|
+
localManifest = null;
|
|
2051
|
+
}
|
|
2052
|
+
if (localManifest) try {
|
|
2053
|
+
validateAndSplitConfig(localManifest, params.config ?? {}, { allowEmpty: true });
|
|
2054
|
+
} catch (err) {
|
|
2055
|
+
if (err instanceof IntegrationConfigValidationError) {
|
|
2056
|
+
this.log.error(`Refusing to reinstall "${name}" — config is invalid and the reinstall would fail after teardown: ${err.message}`);
|
|
2057
|
+
return this.err(err.code, err.message);
|
|
2058
|
+
}
|
|
2059
|
+
throw err;
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
}
|
|
1633
2063
|
const uninstallResult = await this.uninstall({ name });
|
|
1634
|
-
if (!uninstallResult.ok) this.
|
|
2064
|
+
if (!uninstallResult.ok) return this.err("REINSTALL_FAILED", `Could not safely remove the existing integration: ${uninstallResult.error?.message ?? "unknown cleanup failure"}`);
|
|
1635
2065
|
const installResult = await this.install(params);
|
|
1636
2066
|
if (!installResult.ok) return installResult;
|
|
1637
2067
|
return await this.activate(name, { forcePlugins: true });
|
|
@@ -1702,6 +2132,8 @@ var IntegrationManager = class {
|
|
|
1702
2132
|
}
|
|
1703
2133
|
this.log.info(`Upgrading integration: ${name}${version ? `@${version}` : ""}`);
|
|
1704
2134
|
let stagedPath;
|
|
2135
|
+
let newManifest;
|
|
2136
|
+
let preparedUpgradeConfig;
|
|
1705
2137
|
try {
|
|
1706
2138
|
let resolved;
|
|
1707
2139
|
if (customSource) {
|
|
@@ -1713,6 +2145,16 @@ var IntegrationManager = class {
|
|
|
1713
2145
|
}
|
|
1714
2146
|
stagedPath = await this.installer.stage(resolved);
|
|
1715
2147
|
if (customSource) ensureCanonicalManifestName(stagedPath, customSource.manifestPath);
|
|
2148
|
+
const stagedManifestPath = join(stagedPath, "alfe-integration.yaml");
|
|
2149
|
+
if (!existsSync(stagedManifestPath)) throw new Error(`No alfe-integration.yaml found in staged upgrade for "${name}"`);
|
|
2150
|
+
newManifest = parseManifestFile(stagedManifestPath);
|
|
2151
|
+
assertManifestIdentity(name, newManifest, customSource !== void 0);
|
|
2152
|
+
if (config !== void 0) preparedUpgradeConfig = validateAndSplitConfig(newManifest, config, { allowEmpty: true });
|
|
2153
|
+
for (const dep of newManifest.depends_on) {
|
|
2154
|
+
const depState = this.state.get(dep);
|
|
2155
|
+
if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
|
|
2156
|
+
}
|
|
2157
|
+
this.log.info(`Staged upgrade manifest validated: ${newManifest.id}@${newManifest.version}`);
|
|
1716
2158
|
} catch (err) {
|
|
1717
2159
|
const message = err instanceof Error ? err.message : String(err);
|
|
1718
2160
|
this.log.error(`Failed to prepare upgrade for "${name}": ${message}`);
|
|
@@ -1721,21 +2163,7 @@ var IntegrationManager = class {
|
|
|
1721
2163
|
try {
|
|
1722
2164
|
await opts?.onBeforeRuntimeMutation?.();
|
|
1723
2165
|
this.installer.commitStaged(name, stagedPath);
|
|
1724
|
-
|
|
1725
|
-
if (!existsSync(newManifestPath)) throw new Error(`No alfe-integration.yaml found after commit for "${name}"`);
|
|
1726
|
-
const newManifest = parseManifestFile(newManifestPath);
|
|
1727
|
-
this.log.info(`Upgrade manifest validated: ${newManifest.id}@${newManifest.version}`);
|
|
1728
|
-
for (const dep of newManifest.depends_on) {
|
|
1729
|
-
const depState = this.state.get(dep);
|
|
1730
|
-
if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
|
|
1731
|
-
}
|
|
1732
|
-
this.state.update(name, {
|
|
1733
|
-
status: "installed",
|
|
1734
|
-
version: newManifest.version,
|
|
1735
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1736
|
-
config: config ?? existing.config,
|
|
1737
|
-
customConnectionId: customSource?.connectionId ?? existing.customConnectionId
|
|
1738
|
-
});
|
|
2166
|
+
this.log.info(`Upgrade committed: ${newManifest.id}@${newManifest.version}`);
|
|
1739
2167
|
const installHooksSupported = this.manifestSupportsRegisteredRuntime(newManifest);
|
|
1740
2168
|
if (!installHooksSupported && (newManifest.hooks.pre_install || newManifest.hooks.post_install)) this.log.warn(`Integration "${name}" upgrade install hooks skipped — no registered runtime (${[...this.runtimeAppliers.keys()].join(", ")}) is in supported_agents (${(newManifest.supported_agents ?? []).join(", ")})`);
|
|
1741
2169
|
if (installHooksSupported && newManifest.hooks.pre_install) {
|
|
@@ -1747,13 +2175,21 @@ var IntegrationManager = class {
|
|
|
1747
2175
|
this.log.info(`Running post_install hook: ${newManifest.hooks.post_install}`);
|
|
1748
2176
|
const hookResult = await runHookWithContext(this.installer.getInstallPath(name), newManifest.hooks.post_install, {
|
|
1749
2177
|
integrationName: name,
|
|
1750
|
-
config:
|
|
1751
|
-
secrets: this.secrets.get(name),
|
|
2178
|
+
config: preparedUpgradeConfig?.nonSecretConfig ?? existing.config,
|
|
2179
|
+
secrets: preparedUpgradeConfig?.secretConfig ?? this.secrets.get(name),
|
|
1752
2180
|
runtimes: [...this.runtimeAppliers.keys()]
|
|
1753
2181
|
}, INSTALL_HOOK_TIMEOUT_MS);
|
|
1754
2182
|
if (hookResult.exitCode !== 0) throw new Error(this.hookFailureMessage("post_install hook failed", hookResult));
|
|
1755
2183
|
}
|
|
1756
|
-
await this.applyUpgradeDiffRemovals(name,
|
|
2184
|
+
await this.applyUpgradeDiffRemovals(name, newManifest);
|
|
2185
|
+
this.state.update(name, {
|
|
2186
|
+
status: "installed",
|
|
2187
|
+
version: newManifest.version,
|
|
2188
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2189
|
+
config: preparedUpgradeConfig?.nonSecretConfig ?? existing.config,
|
|
2190
|
+
customConnectionId: customSource?.connectionId ?? existing.customConnectionId
|
|
2191
|
+
});
|
|
2192
|
+
if (preparedUpgradeConfig) this.secrets.set(name, preparedUpgradeConfig.secretConfig);
|
|
1757
2193
|
return await this.activate(name, { forcePlugins: true });
|
|
1758
2194
|
} catch (err) {
|
|
1759
2195
|
try {
|
|
@@ -1865,7 +2301,7 @@ var IntegrationManager = class {
|
|
|
1865
2301
|
commands: commands.map((cmd) => ({
|
|
1866
2302
|
name: cmd.name,
|
|
1867
2303
|
handler: cmd.handler,
|
|
1868
|
-
resolvedPath:
|
|
2304
|
+
resolvedPath: resolveExistingWithin(installPath, cmd.handler, `Command handler ${cmd.name}`, "file"),
|
|
1869
2305
|
method: cmd.method ?? "handle",
|
|
1870
2306
|
timeoutMs: cmd.timeout_ms ?? 3e4,
|
|
1871
2307
|
description: cmd.description
|
|
@@ -1930,9 +2366,7 @@ var IntegrationManager = class {
|
|
|
1930
2366
|
healthy: hookResult.exitCode === 0,
|
|
1931
2367
|
status: entry.status,
|
|
1932
2368
|
version: manifest.version,
|
|
1933
|
-
message: hookResult.exitCode === 0 ? "Healthy" : hookResult.timedOut ? `Health check failed (timed out after ${String(hookResult.timedOutAfterMs ?? "?")}ms)` : `Health check failed (exit ${String(hookResult.exitCode)})
|
|
1934
|
-
stdout: hookResult.stdout || void 0,
|
|
1935
|
-
stderr: hookResult.stderr || void 0
|
|
2369
|
+
message: hookResult.exitCode === 0 ? "Healthy" : hookResult.timedOut ? `Health check failed (timed out after ${String(hookResult.timedOutAfterMs ?? "?")}ms)` : `Health check failed (exit ${String(hookResult.exitCode)})`
|
|
1936
2370
|
};
|
|
1937
2371
|
} catch (err) {
|
|
1938
2372
|
return {
|
|
@@ -1956,13 +2390,13 @@ var IntegrationManager = class {
|
|
|
1956
2390
|
/**
|
|
1957
2391
|
* Build a hook-failure message. A hook we SIGKILLed at its timeout is rendered
|
|
1958
2392
|
* as `(timed out after <ms>ms)` so it is visually distinguishable in Sentry
|
|
1959
|
-
* from a genuine non-zero exit `(exit <code>)
|
|
1960
|
-
*
|
|
2393
|
+
* from a genuine non-zero exit `(exit <code>)`. Captured output is deliberately
|
|
2394
|
+
* excluded: hooks inherit daemon credentials and receive integration secrets,
|
|
2395
|
+
* so stdout/stderr is a secret-bearing channel that must not enter durable
|
|
2396
|
+
* state, logs, or user-facing error messages.
|
|
1961
2397
|
*/
|
|
1962
2398
|
hookFailureMessage(label, hookResult) {
|
|
1963
|
-
|
|
1964
|
-
const output = hookResult.stderr || hookResult.stdout;
|
|
1965
|
-
return output ? `${label} (${reason}): ${output}` : `${label} (${reason})`;
|
|
2399
|
+
return `${label} (${hookResult.timedOut ? `timed out after ${String(hookResult.timedOutAfterMs ?? "?")}ms` : `exit ${String(hookResult.exitCode)}`})`;
|
|
1966
2400
|
}
|
|
1967
2401
|
/**
|
|
1968
2402
|
* Compute the plugin (bare package) names and skill names still claimed by
|
|
@@ -1976,13 +2410,13 @@ var IntegrationManager = class {
|
|
|
1976
2410
|
* integrations pinning the same plugin at different versions still keep the
|
|
1977
2411
|
* one file-system install alive when only one is removed.
|
|
1978
2412
|
*/
|
|
1979
|
-
claimedEntriesByRuntime() {
|
|
2413
|
+
claimedEntriesByRuntime(excludeIntegrationId) {
|
|
1980
2414
|
const remaining = this.lockManager.read();
|
|
1981
2415
|
const plugins = /* @__PURE__ */ new Map();
|
|
1982
2416
|
const skills = /* @__PURE__ */ new Map();
|
|
1983
2417
|
for (const [rtName, state] of Object.entries(remaining.runtimes)) {
|
|
1984
|
-
plugins.set(rtName, new Set(state.plugins.map((
|
|
1985
|
-
skills.set(rtName, new Set(state.skills.map((
|
|
2418
|
+
plugins.set(rtName, new Set(state.plugins.filter((entry) => entry.sourceIntegration !== excludeIntegrationId).map((entry) => stripPluginVersion(entry.package))));
|
|
2419
|
+
skills.set(rtName, new Set(state.skills.filter((entry) => entry.sourceIntegration !== excludeIntegrationId).map((entry) => entry.name)));
|
|
1986
2420
|
}
|
|
1987
2421
|
return {
|
|
1988
2422
|
plugins,
|
|
@@ -1995,24 +2429,27 @@ var IntegrationManager = class {
|
|
|
1995
2429
|
* (and everything a sibling integration still claims) in place. Phase 4's
|
|
1996
2430
|
* `activate` re-applies and re-locks the new set immediately after.
|
|
1997
2431
|
*
|
|
1998
|
-
*
|
|
1999
|
-
*
|
|
2000
|
-
*
|
|
2432
|
+
* The existing lock remains intact until every physical removal and MCP prune
|
|
2433
|
+
* succeeds. This makes partial cleanup retryable; only then are the old rows
|
|
2434
|
+
* cleared so activate() can record the new manifest's contributions.
|
|
2001
2435
|
*/
|
|
2002
|
-
async applyUpgradeDiffRemovals(integrationId,
|
|
2003
|
-
const
|
|
2004
|
-
const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime();
|
|
2005
|
-
|
|
2436
|
+
async applyUpgradeDiffRemovals(integrationId, newManifest) {
|
|
2437
|
+
const ownedEntries = this.lockManager.getEntriesForIntegration(integrationId);
|
|
2438
|
+
const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime(integrationId);
|
|
2439
|
+
const cleanupFailures = [];
|
|
2440
|
+
for (const [runtimeName, entries] of Object.entries(ownedEntries)) {
|
|
2006
2441
|
const applier = this.runtimeAppliers.get(runtimeName);
|
|
2007
2442
|
if (!applier) {
|
|
2008
2443
|
this.log.warn(`No applier for runtime "${runtimeName}" — cannot diff-remove entries`);
|
|
2444
|
+
cleanupFailures.push(`runtime:${runtimeName}:missing-applier`);
|
|
2009
2445
|
continue;
|
|
2010
2446
|
}
|
|
2011
2447
|
if (!await applier.isAvailable()) {
|
|
2012
2448
|
this.log.warn(`Runtime "${runtimeName}" is not available — skipping upgrade diff removal`);
|
|
2449
|
+
cleanupFailures.push(`runtime:${runtimeName}:unavailable`);
|
|
2013
2450
|
continue;
|
|
2014
2451
|
}
|
|
2015
|
-
const { plugins: newPlugins, skills: newSkills
|
|
2452
|
+
const { plugins: newPlugins, skills: newSkills } = resolveInstallsForRuntime(newManifest, runtimeName);
|
|
2016
2453
|
const keepPluginsNew = new Set(newPlugins.map((p) => stripPluginVersion(p.package)));
|
|
2017
2454
|
const keepSkillsNew = new Set(newSkills.map((s) => s.clawhub ?? s.path?.split("/").pop() ?? "unknown"));
|
|
2018
2455
|
const keepPluginsOther = claimedPluginsByRuntime.get(runtimeName) ?? /* @__PURE__ */ new Set();
|
|
@@ -2025,6 +2462,7 @@ var IntegrationManager = class {
|
|
|
2025
2462
|
await applier.removePlugin(plugin.package);
|
|
2026
2463
|
} catch (err) {
|
|
2027
2464
|
this.log.warn(`Failed to remove dropped plugin ${plugin.package} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2465
|
+
cleanupFailures.push(`runtime:${runtimeName}:plugin`);
|
|
2028
2466
|
}
|
|
2029
2467
|
}
|
|
2030
2468
|
for (const skill of entries.skills) {
|
|
@@ -2035,17 +2473,16 @@ var IntegrationManager = class {
|
|
|
2035
2473
|
else await applier.removeSkill(skill.name);
|
|
2036
2474
|
} catch (err) {
|
|
2037
2475
|
this.log.warn(`Failed to remove dropped skill ${skill.name} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2476
|
+
cleanupFailures.push(`runtime:${runtimeName}:skill`);
|
|
2038
2477
|
}
|
|
2039
2478
|
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
const newHasConfig = Boolean(newRuntimeConfig && Object.keys(newRuntimeConfig).length > 0);
|
|
2043
|
-
if (oldHadConfig && !newHasConfig) {
|
|
2044
|
-
this.log.info(`Upgrade: removing gone config for ${integrationId} from ${runtimeName}`);
|
|
2479
|
+
if (entries.config.length > 0) {
|
|
2480
|
+
this.log.info(`Upgrade: removing prior config for ${integrationId} from ${runtimeName}`);
|
|
2045
2481
|
try {
|
|
2046
2482
|
await applier.removeConfig(integrationId);
|
|
2047
2483
|
} catch (err) {
|
|
2048
|
-
this.log.warn(`Failed to remove
|
|
2484
|
+
this.log.warn(`Failed to remove prior config for ${integrationId} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2485
|
+
cleanupFailures.push(`runtime:${runtimeName}:config`);
|
|
2049
2486
|
}
|
|
2050
2487
|
}
|
|
2051
2488
|
}
|
|
@@ -2056,8 +2493,11 @@ var IntegrationManager = class {
|
|
|
2056
2493
|
await this.mcpApplier.pruneForIntegration(integrationId, keepIds);
|
|
2057
2494
|
} catch (err) {
|
|
2058
2495
|
this.log.warn(`Failed to prune MCP servers for ${integrationId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2496
|
+
cleanupFailures.push("mcp");
|
|
2059
2497
|
}
|
|
2060
2498
|
}
|
|
2499
|
+
if (cleanupFailures.length > 0) throw new Error(`Failed to remove ${String(cleanupFailures.length)} superseded integration contribution(s); ownership lock retained for retry`);
|
|
2500
|
+
this.lockManager.removeEntries(integrationId);
|
|
2061
2501
|
}
|
|
2062
2502
|
/**
|
|
2063
2503
|
* True when the manifest's `supported_agents` (if declared) intersects the
|
|
@@ -2283,6 +2723,11 @@ const INVALID_PARTIAL_PROVIDER_PHRASE = "custom model providers must declare";
|
|
|
2283
2723
|
function isInvalidPartialProviderUnset(err) {
|
|
2284
2724
|
return (err instanceof Error ? err.message : String(err)).toLowerCase().includes(INVALID_PARTIAL_PROVIDER_PHRASE);
|
|
2285
2725
|
}
|
|
2726
|
+
/** An already-absent config path is the only safe idempotent unset failure. */
|
|
2727
|
+
function isMissingConfigPathError(err) {
|
|
2728
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2729
|
+
return /config path not found/i.test(message);
|
|
2730
|
+
}
|
|
2286
2731
|
const delay$1 = (ms) => new Promise((resolve) => {
|
|
2287
2732
|
setTimeout(resolve, ms);
|
|
2288
2733
|
});
|
|
@@ -2419,16 +2864,26 @@ function configSetErrorMessage(err, args) {
|
|
|
2419
2864
|
}
|
|
2420
2865
|
return `${target} failed: ${String(err)}`;
|
|
2421
2866
|
}
|
|
2422
|
-
async function readParentObject(parentPath) {
|
|
2867
|
+
async function readParentObject(parentPath, options = {}) {
|
|
2423
2868
|
try {
|
|
2424
2869
|
const { stdout } = await execFileAsync$1("openclaw", [
|
|
2425
2870
|
"config",
|
|
2426
2871
|
"get",
|
|
2427
2872
|
parentPath
|
|
2428
2873
|
], { timeout: 1e4 });
|
|
2429
|
-
const
|
|
2874
|
+
const trimmed = stdout.trim();
|
|
2875
|
+
if (trimmed === "") return {};
|
|
2876
|
+
const parsed = JSON.parse(trimmed);
|
|
2430
2877
|
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
2431
|
-
|
|
2878
|
+
if (parsed === null) return {};
|
|
2879
|
+
if (options.failClosed) throw new Error(`OpenClaw config path "${parentPath}" is not an object`);
|
|
2880
|
+
} catch (err) {
|
|
2881
|
+
if (isMissingConfigPathError(err)) return {};
|
|
2882
|
+
if (options.failClosed) {
|
|
2883
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2884
|
+
throw new Error(`Unable to read OpenClaw config path "${parentPath}" for cleanup: ${message}`);
|
|
2885
|
+
}
|
|
2886
|
+
}
|
|
2432
2887
|
return {};
|
|
2433
2888
|
}
|
|
2434
2889
|
var OpenClawApplier = class {
|
|
@@ -2920,12 +3375,14 @@ var OpenClawApplier = class {
|
|
|
2920
3375
|
], { timeout: 3e4 });
|
|
2921
3376
|
}
|
|
2922
3377
|
applySkill(name, srcPath) {
|
|
3378
|
+
assertSafePathSegment(name, "Skill name");
|
|
2923
3379
|
if (!existsSync(srcPath)) throw new Error(`Skill source path not found: ${srcPath}`);
|
|
2924
3380
|
mkdirSync(this.skillsDir, { recursive: true });
|
|
2925
3381
|
cpSync(srcPath, join(this.skillsDir, name), { recursive: true });
|
|
2926
3382
|
return Promise.resolve();
|
|
2927
3383
|
}
|
|
2928
3384
|
applyClawHubSkill(slug) {
|
|
3385
|
+
assertSafePathSegment(slug, "ClawHub skill name");
|
|
2929
3386
|
return this.cliLock.run(async () => {
|
|
2930
3387
|
log$5.info({ slug }, "Installing skill from ClawHub");
|
|
2931
3388
|
try {
|
|
@@ -2950,6 +3407,7 @@ var OpenClawApplier = class {
|
|
|
2950
3407
|
});
|
|
2951
3408
|
}
|
|
2952
3409
|
removeClawHubSkill(slug) {
|
|
3410
|
+
assertSafePathSegment(slug, "ClawHub skill name");
|
|
2953
3411
|
const workspaceSkillsDir = join(this.agentWorkspace, "skills", slug);
|
|
2954
3412
|
if (existsSync(workspaceSkillsDir)) {
|
|
2955
3413
|
rmSync(workspaceSkillsDir, {
|
|
@@ -2961,6 +3419,7 @@ var OpenClawApplier = class {
|
|
|
2961
3419
|
return Promise.resolve();
|
|
2962
3420
|
}
|
|
2963
3421
|
removeSkill(name) {
|
|
3422
|
+
assertSafePathSegment(name, "Skill name");
|
|
2964
3423
|
const skillPath = join(this.skillsDir, name);
|
|
2965
3424
|
if (existsSync(skillPath)) rmSync(skillPath, {
|
|
2966
3425
|
recursive: true,
|
|
@@ -2978,8 +3437,9 @@ var OpenClawApplier = class {
|
|
|
2978
3437
|
return this.cliLock.run(() => this.applyConfigLocked(integrationId, config));
|
|
2979
3438
|
}
|
|
2980
3439
|
async applyConfigLocked(integrationId, config) {
|
|
3440
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
2981
3441
|
const tracking = this.readTracking();
|
|
2982
|
-
const integrations = tracking
|
|
3442
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "OpenClaw integration tracking field");
|
|
2983
3443
|
const previous = integrations[integrationId];
|
|
2984
3444
|
const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
|
|
2985
3445
|
if (previous && typeof previous === "object" && !Array.isArray(previous)) {
|
|
@@ -3050,8 +3510,9 @@ var OpenClawApplier = class {
|
|
|
3050
3510
|
return this.cliLock.run(() => this.removeConfigLocked(integrationId));
|
|
3051
3511
|
}
|
|
3052
3512
|
async removeConfigLocked(integrationId) {
|
|
3513
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3053
3514
|
const tracking = this.readTracking();
|
|
3054
|
-
const integrations = tracking
|
|
3515
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "OpenClaw integration tracking field");
|
|
3055
3516
|
if (!(integrationId in integrations)) return;
|
|
3056
3517
|
const integrationConfig = integrations[integrationId];
|
|
3057
3518
|
const { leaves, subtreesByParent } = partitionEntries(flattenConfig(integrationConfig));
|
|
@@ -3065,15 +3526,16 @@ var OpenClawApplier = class {
|
|
|
3065
3526
|
* Drop a set of dotted keys from a dot-free parent object via read-drop-write,
|
|
3066
3527
|
* UNLOCKED. If the parent becomes empty, `config unset` it; otherwise
|
|
3067
3528
|
* `--replace` the shrunk map (siblings survive because they remain in
|
|
3068
|
-
* `remaining`).
|
|
3069
|
-
*
|
|
3529
|
+
* `remaining`). A proven already-absent path is idempotent success; every
|
|
3530
|
+
* other write failure propagates so callers retain the ownership record for a
|
|
3531
|
+
* later retry. Shared by `removeConfig` (whole-integration teardown) and
|
|
3070
3532
|
* `applyConfig`'s stale-key diff (per-key removal between manifest versions).
|
|
3071
3533
|
*
|
|
3072
3534
|
* Assumes the shared CLI lock is already held by the calling public method —
|
|
3073
3535
|
* the lock is NOT re-entrant, so this stays an `*Unlocked` internal.
|
|
3074
3536
|
*/
|
|
3075
3537
|
async dropSubtreeKeysUnlocked(parentPath, dottedKeys) {
|
|
3076
|
-
const existing = await readParentObject(parentPath);
|
|
3538
|
+
const existing = await readParentObject(parentPath, { failClosed: true });
|
|
3077
3539
|
if (Object.keys(existing).length === 0) return;
|
|
3078
3540
|
const remaining = Object.fromEntries(Object.entries(existing).filter(([k]) => !dottedKeys.has(k)));
|
|
3079
3541
|
try {
|
|
@@ -3084,10 +3546,8 @@ var OpenClawApplier = class {
|
|
|
3084
3546
|
"--replace"
|
|
3085
3547
|
]);
|
|
3086
3548
|
} catch (err) {
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
parentPath
|
|
3090
|
-
}, "Failed to update parent config during subtree key drop");
|
|
3549
|
+
if (isMissingConfigPathError(err)) return;
|
|
3550
|
+
throw err;
|
|
3091
3551
|
}
|
|
3092
3552
|
}
|
|
3093
3553
|
/**
|
|
@@ -3107,15 +3567,17 @@ var OpenClawApplier = class {
|
|
|
3107
3567
|
* the provider subtree is unset for `models.providers.zhipu.baseUrl`, the
|
|
3108
3568
|
* follow-up `.apiKey` / `.models` leaves skip re-issuing the parent unset.
|
|
3109
3569
|
*
|
|
3110
|
-
*
|
|
3111
|
-
*
|
|
3112
|
-
*
|
|
3113
|
-
* (whole-integration teardown) and
|
|
3570
|
+
* A proven already-absent path is idempotent success; every other failure
|
|
3571
|
+
* propagates so the ownership ledger remains a retry record. Assumes the
|
|
3572
|
+
* shared CLI lock is held — stays an `*Unlocked` internal since the lock is
|
|
3573
|
+
* NOT re-entrant. Shared by `removeConfig` (whole-integration teardown) and
|
|
3574
|
+
* `applyConfig`'s stale-leaf diff.
|
|
3114
3575
|
*/
|
|
3115
3576
|
async unsetLeafPathUnlocked(path, unsetParents) {
|
|
3116
3577
|
try {
|
|
3117
3578
|
await this.runConfigCommandUnlocked(["unset", path]);
|
|
3118
3579
|
} catch (err) {
|
|
3580
|
+
if (isMissingConfigPathError(err)) return;
|
|
3119
3581
|
const parentPath = path.includes(".") ? path.slice(0, path.lastIndexOf(".")) : void 0;
|
|
3120
3582
|
if (parentPath && isInvalidPartialProviderUnset(err)) {
|
|
3121
3583
|
if (unsetParents.has(parentPath)) return;
|
|
@@ -3127,17 +3589,12 @@ var OpenClawApplier = class {
|
|
|
3127
3589
|
parentPath
|
|
3128
3590
|
}, "Leaf unset rejected as an invalid partial custom provider — unset the parent subtree instead");
|
|
3129
3591
|
} catch (parentErr) {
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
parentPath
|
|
3133
|
-
}, "Failed to unset parent subtree after an invalid-partial leaf unset");
|
|
3592
|
+
if (isMissingConfigPathError(parentErr)) return;
|
|
3593
|
+
throw parentErr;
|
|
3134
3594
|
}
|
|
3135
3595
|
return;
|
|
3136
3596
|
}
|
|
3137
|
-
|
|
3138
|
-
err: err instanceof Error ? err.message : String(err),
|
|
3139
|
-
path
|
|
3140
|
-
}, "Failed to unset config via openclaw config unset");
|
|
3597
|
+
throw err;
|
|
3141
3598
|
}
|
|
3142
3599
|
}
|
|
3143
3600
|
/**
|
|
@@ -3197,16 +3654,10 @@ var OpenClawApplier = class {
|
|
|
3197
3654
|
return Promise.resolve(existsSync(this.home));
|
|
3198
3655
|
}
|
|
3199
3656
|
readTracking() {
|
|
3200
|
-
|
|
3201
|
-
try {
|
|
3202
|
-
return JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
3203
|
-
} catch {
|
|
3204
|
-
return {};
|
|
3205
|
-
}
|
|
3657
|
+
return readJsonObjectFileSync(this.trackingPath, "OpenClaw integration tracking file");
|
|
3206
3658
|
}
|
|
3207
3659
|
writeTracking(config) {
|
|
3208
|
-
|
|
3209
|
-
writeFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
3660
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n");
|
|
3210
3661
|
}
|
|
3211
3662
|
};
|
|
3212
3663
|
//#endregion
|
|
@@ -3319,22 +3770,16 @@ var HermesApplier = class {
|
|
|
3319
3770
|
* be cleanly removed later (mirrors OpenClawApplier's `_integrations`).
|
|
3320
3771
|
*/
|
|
3321
3772
|
async applyConfig(integrationId, config) {
|
|
3773
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3322
3774
|
const tracking = this.readTracking();
|
|
3323
|
-
const integrations = tracking
|
|
3775
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "Hermes integration tracking field");
|
|
3324
3776
|
const previous = integrations[integrationId];
|
|
3325
3777
|
const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
|
|
3326
3778
|
if (previous && typeof previous === "object" && !Array.isArray(previous)) {
|
|
3327
3779
|
const prev = partitionEntries(flattenConfig(previous));
|
|
3328
3780
|
const nextLeafPaths = new Set(leaves.map((l) => l.path));
|
|
3329
3781
|
const staleLeafPaths = prev.leaves.filter((l) => !nextLeafPaths.has(l.path)).map((l) => l.path);
|
|
3330
|
-
if (staleLeafPaths.length > 0)
|
|
3331
|
-
await this.deleteConfigKeys(staleLeafPaths);
|
|
3332
|
-
} catch (err) {
|
|
3333
|
-
log$4.warn({
|
|
3334
|
-
err: err instanceof Error ? err.message : String(err),
|
|
3335
|
-
integrationId
|
|
3336
|
-
}, "Failed to delete stale Hermes config keys during applyConfig diff");
|
|
3337
|
-
}
|
|
3782
|
+
if (staleLeafPaths.length > 0) await this.deleteConfigKeys(staleLeafPaths);
|
|
3338
3783
|
const staleSubtreeParents = [...prev.subtreesByParent].filter(([parent, prevKvs]) => {
|
|
3339
3784
|
const nextKvs = subtreesByParent.get(parent);
|
|
3340
3785
|
return [...prevKvs.keys()].some((k) => !nextKvs?.has(k));
|
|
@@ -3369,8 +3814,9 @@ var HermesApplier = class {
|
|
|
3369
3814
|
* `hermes config unset` verb — see the file header). Clears the tracking entry.
|
|
3370
3815
|
*/
|
|
3371
3816
|
async removeConfig(integrationId) {
|
|
3817
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3372
3818
|
const tracking = this.readTracking();
|
|
3373
|
-
const integrations = tracking
|
|
3819
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "Hermes integration tracking field");
|
|
3374
3820
|
if (!(integrationId in integrations)) return;
|
|
3375
3821
|
const integrationConfig = integrations[integrationId];
|
|
3376
3822
|
const { leaves } = partitionEntries(flattenConfig(integrationConfig));
|
|
@@ -3381,6 +3827,7 @@ var HermesApplier = class {
|
|
|
3381
3827
|
err: err instanceof Error ? err.message : String(err),
|
|
3382
3828
|
integrationId
|
|
3383
3829
|
}, "Failed to delete Alfe config keys from ~/.hermes/config.yaml");
|
|
3830
|
+
throw err;
|
|
3384
3831
|
}
|
|
3385
3832
|
tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
|
|
3386
3833
|
this.writeTracking(tracking);
|
|
@@ -3521,10 +3968,11 @@ var HermesApplier = class {
|
|
|
3521
3968
|
deleteConfigKeysSync(paths) {
|
|
3522
3969
|
if (paths.length === 0 || !existsSync(this.configYamlPath)) return;
|
|
3523
3970
|
const doc = parseDocument(readFileSync(this.configYamlPath, "utf-8"));
|
|
3971
|
+
if (doc.errors.length > 0) throw new Error("Hermes config.yaml is invalid; refusing destructive config cleanup");
|
|
3524
3972
|
const before = doc.toString();
|
|
3525
3973
|
for (const path of paths) doc.deleteIn(path.split("."));
|
|
3526
3974
|
const after = doc.toString();
|
|
3527
|
-
if (before !== after)
|
|
3975
|
+
if (before !== after) atomicWriteFileSync(this.configYamlPath, after);
|
|
3528
3976
|
}
|
|
3529
3977
|
/**
|
|
3530
3978
|
* Run `hermes config <args>` (only `set` is used — removal goes via
|
|
@@ -3550,16 +3998,10 @@ var HermesApplier = class {
|
|
|
3550
3998
|
return result;
|
|
3551
3999
|
}
|
|
3552
4000
|
readTracking() {
|
|
3553
|
-
|
|
3554
|
-
try {
|
|
3555
|
-
return JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
3556
|
-
} catch {
|
|
3557
|
-
return {};
|
|
3558
|
-
}
|
|
4001
|
+
return readJsonObjectFileSync(this.trackingPath, "Hermes integration tracking file");
|
|
3559
4002
|
}
|
|
3560
4003
|
writeTracking(config) {
|
|
3561
|
-
|
|
3562
|
-
writeFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
4004
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n");
|
|
3563
4005
|
}
|
|
3564
4006
|
};
|
|
3565
4007
|
//#endregion
|
|
@@ -3662,8 +4104,8 @@ var HermesMcpSync = class {
|
|
|
3662
4104
|
*/
|
|
3663
4105
|
start() {
|
|
3664
4106
|
if (this.started) return;
|
|
3665
|
-
this.started = true;
|
|
3666
4107
|
this.loadSyncedIds();
|
|
4108
|
+
this.started = true;
|
|
3667
4109
|
this.syncOnce().catch((err) => {
|
|
3668
4110
|
log$3.warn({ err: errMsg$2(err) }, "Hermes MCP sync: initial sync failed");
|
|
3669
4111
|
});
|
|
@@ -3718,14 +4160,14 @@ var HermesMcpSync = class {
|
|
|
3718
4160
|
const desired = this.computeDesired();
|
|
3719
4161
|
const desiredIds = new Set(desired.keys());
|
|
3720
4162
|
const doc = parseDocument(existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "");
|
|
4163
|
+
if (doc.errors.length > 0) throw new Error("Hermes config.yaml is invalid; refusing MCP reconciliation");
|
|
3721
4164
|
const before = doc.toString();
|
|
3722
4165
|
for (const [id, entry] of desired) doc.setIn(["mcp_servers", id], entry);
|
|
3723
4166
|
for (const id of this.syncedIds) if (!desiredIds.has(id)) doc.deleteIn(["mcp_servers", id]);
|
|
3724
4167
|
const after = doc.toString();
|
|
3725
4168
|
let changed = before !== after;
|
|
3726
4169
|
if (changed) {
|
|
3727
|
-
|
|
3728
|
-
writeFileSync(this.configPath, after, "utf-8");
|
|
4170
|
+
atomicWriteFileSync(this.configPath, after);
|
|
3729
4171
|
log$3.info({
|
|
3730
4172
|
added: [...desiredIds],
|
|
3731
4173
|
removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
|
|
@@ -3762,7 +4204,7 @@ var HermesMcpSync = class {
|
|
|
3762
4204
|
}
|
|
3763
4205
|
withAlfeApiKey(env) {
|
|
3764
4206
|
const merged = { ...env ?? {} };
|
|
3765
|
-
|
|
4207
|
+
merged.ALFE_API_KEY = ALFE_API_KEY_ENV_REF;
|
|
3766
4208
|
return merged;
|
|
3767
4209
|
}
|
|
3768
4210
|
/** Read-merge-write `~/.hermes/.env` to set ALFE_API_KEY without clobbering other keys. */
|
|
@@ -3777,17 +4219,15 @@ var HermesMcpSync = class {
|
|
|
3777
4219
|
if (!existsSync(this.trackingPath)) return;
|
|
3778
4220
|
try {
|
|
3779
4221
|
const data = JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
3780
|
-
if (Array.isArray(data.syncedIds)
|
|
3781
|
-
|
|
3782
|
-
}
|
|
3783
|
-
persistSyncedIds() {
|
|
3784
|
-
try {
|
|
3785
|
-
mkdirSync(dirname(this.trackingPath), { recursive: true });
|
|
3786
|
-
writeFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n", "utf-8");
|
|
4222
|
+
if (!Array.isArray(data.syncedIds) || data.syncedIds.some((value) => typeof value !== "string")) throw new Error("invalid syncedIds shape");
|
|
4223
|
+
this.syncedIds = new Set(data.syncedIds);
|
|
3787
4224
|
} catch (err) {
|
|
3788
|
-
|
|
4225
|
+
throw new Error(`Hermes MCP ownership sidecar is unreadable; refusing reconciliation: ${errMsg$2(err)}`);
|
|
3789
4226
|
}
|
|
3790
4227
|
}
|
|
4228
|
+
persistSyncedIds() {
|
|
4229
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n");
|
|
4230
|
+
}
|
|
3791
4231
|
};
|
|
3792
4232
|
/**
|
|
3793
4233
|
* Set `KEY=value` in a `.env`-style file, preserving every other line (comments,
|
|
@@ -3796,6 +4236,8 @@ var HermesMcpSync = class {
|
|
|
3796
4236
|
* `~/.hermes/.env` ever holds.
|
|
3797
4237
|
*/
|
|
3798
4238
|
function upsertEnvVar(envPath, key, value) {
|
|
4239
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error("Environment variable key is invalid");
|
|
4240
|
+
if (value.includes("\n") || value.includes("\r") || value.includes("\0")) throw new Error(`Environment variable ${key} contains an invalid control character`);
|
|
3799
4241
|
const desiredLine = `${key}=${value}`;
|
|
3800
4242
|
const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
|
|
3801
4243
|
const lines = existing.length > 0 ? existing.replace(/\n$/, "").split("\n") : [];
|
|
@@ -3810,10 +4252,7 @@ function upsertEnvVar(envPath, key, value) {
|
|
|
3810
4252
|
changed = lines[idx] !== desiredLine;
|
|
3811
4253
|
out = lines.map((line, i) => i === idx ? desiredLine : line);
|
|
3812
4254
|
}
|
|
3813
|
-
if (changed)
|
|
3814
|
-
mkdirSync(dirname(envPath), { recursive: true });
|
|
3815
|
-
writeFileSync(envPath, out.join("\n") + "\n", "utf-8");
|
|
3816
|
-
}
|
|
4255
|
+
if (changed) atomicWriteFileSync(envPath, out.join("\n") + "\n");
|
|
3817
4256
|
return changed;
|
|
3818
4257
|
}
|
|
3819
4258
|
function errMsg$2(err) {
|
|
@@ -3883,8 +4322,9 @@ var ClaudeCodeApplier = class {
|
|
|
3883
4322
|
* replace, no stale-key diff needed since nothing is applied to a runtime).
|
|
3884
4323
|
*/
|
|
3885
4324
|
applyConfig(integrationId, config) {
|
|
4325
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3886
4326
|
const ledger = this.readLedger();
|
|
3887
|
-
const integrations = ledger
|
|
4327
|
+
const integrations = getOptionalJsonObjectField(ledger, "_integrations", "Claude Code integration ledger field");
|
|
3888
4328
|
integrations[integrationId] = config;
|
|
3889
4329
|
ledger._integrations = integrations;
|
|
3890
4330
|
this.writeLedger(ledger);
|
|
@@ -3893,8 +4333,9 @@ var ClaudeCodeApplier = class {
|
|
|
3893
4333
|
}
|
|
3894
4334
|
/** Clear an integration's config contribution from the ledger. */
|
|
3895
4335
|
removeConfig(integrationId) {
|
|
4336
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3896
4337
|
const ledger = this.readLedger();
|
|
3897
|
-
const integrations = ledger
|
|
4338
|
+
const integrations = getOptionalJsonObjectField(ledger, "_integrations", "Claude Code integration ledger field");
|
|
3898
4339
|
if (!(integrationId in integrations)) return Promise.resolve();
|
|
3899
4340
|
ledger._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
|
|
3900
4341
|
this.writeLedger(ledger);
|
|
@@ -3912,8 +4353,9 @@ var ClaudeCodeApplier = class {
|
|
|
3912
4353
|
* `getConfigRaw`.
|
|
3913
4354
|
*/
|
|
3914
4355
|
setConfigRaw(key, value) {
|
|
4356
|
+
assertSafeRecordKey(key, "Raw config key");
|
|
3915
4357
|
const ledger = this.readLedger();
|
|
3916
|
-
const raw = ledger
|
|
4358
|
+
const raw = getOptionalJsonObjectField(ledger, "_raw", "Claude Code raw-config ledger field");
|
|
3917
4359
|
raw[key] = value;
|
|
3918
4360
|
ledger._raw = raw;
|
|
3919
4361
|
this.writeLedger(ledger);
|
|
@@ -3927,7 +4369,8 @@ var ClaudeCodeApplier = class {
|
|
|
3927
4369
|
*/
|
|
3928
4370
|
getConfigRaw(key) {
|
|
3929
4371
|
try {
|
|
3930
|
-
|
|
4372
|
+
assertSafeRecordKey(key, "Raw config key");
|
|
4373
|
+
const value = getOptionalJsonObjectField(this.readLedger(), "_raw", "Claude Code raw-config ledger field")[key];
|
|
3931
4374
|
return Promise.resolve(typeof value === "string" ? value : void 0);
|
|
3932
4375
|
} catch {
|
|
3933
4376
|
return Promise.resolve(void 0);
|
|
@@ -3980,6 +4423,7 @@ var ClaudeCodeApplier = class {
|
|
|
3980
4423
|
* clean no-op. We never throw for a claude-code ClawHub skill.
|
|
3981
4424
|
*/
|
|
3982
4425
|
applyClawHubSkill(slug) {
|
|
4426
|
+
assertSafePathSegment(slug, "ClawHub skill name");
|
|
3983
4427
|
log$2.info({ slug }, "Claude Code applyClawHubSkill: no claude-code ClawHub fetch — skipping (native skill dirs land via applySkill)");
|
|
3984
4428
|
return Promise.resolve();
|
|
3985
4429
|
}
|
|
@@ -3991,6 +4435,7 @@ var ClaudeCodeApplier = class {
|
|
|
3991
4435
|
return Promise.resolve(existsSync(this.home));
|
|
3992
4436
|
}
|
|
3993
4437
|
copySkillDir(name, srcPath) {
|
|
4438
|
+
assertSafePathSegment(name, "Skill name");
|
|
3994
4439
|
if (!existsSync(srcPath)) {
|
|
3995
4440
|
log$2.warn({
|
|
3996
4441
|
name,
|
|
@@ -4022,6 +4467,7 @@ var ClaudeCodeApplier = class {
|
|
|
4022
4467
|
return Promise.resolve();
|
|
4023
4468
|
}
|
|
4024
4469
|
deleteSkillDir(name) {
|
|
4470
|
+
assertSafePathSegment(name, "Skill name");
|
|
4025
4471
|
const dest = join(this.skillsDir, name);
|
|
4026
4472
|
try {
|
|
4027
4473
|
rmSync(dest, {
|
|
@@ -4033,25 +4479,21 @@ var ClaudeCodeApplier = class {
|
|
|
4033
4479
|
dest
|
|
4034
4480
|
}, "Claude Code removeSkill: removed skill from ~/.claude/skills");
|
|
4035
4481
|
} catch (err) {
|
|
4036
|
-
|
|
4482
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4483
|
+
log$2.error({
|
|
4037
4484
|
name,
|
|
4038
4485
|
dest,
|
|
4039
|
-
err:
|
|
4486
|
+
err: message
|
|
4040
4487
|
}, "Claude Code removeSkill: failed to remove skill directory");
|
|
4488
|
+
throw new Error(`Failed to remove Claude Code skill ${name}: ${message}`);
|
|
4041
4489
|
}
|
|
4042
4490
|
return Promise.resolve();
|
|
4043
4491
|
}
|
|
4044
4492
|
readLedger() {
|
|
4045
|
-
|
|
4046
|
-
try {
|
|
4047
|
-
return JSON.parse(readFileSync(this.ledgerPath, "utf-8"));
|
|
4048
|
-
} catch {
|
|
4049
|
-
return {};
|
|
4050
|
-
}
|
|
4493
|
+
return readJsonObjectFileSync(this.ledgerPath, "Claude Code integration ledger");
|
|
4051
4494
|
}
|
|
4052
4495
|
writeLedger(ledger) {
|
|
4053
|
-
|
|
4054
|
-
writeFileSync(this.ledgerPath, JSON.stringify(ledger, null, 2) + "\n", "utf-8");
|
|
4496
|
+
atomicWriteFileSync(this.ledgerPath, JSON.stringify(ledger, null, 2) + "\n");
|
|
4055
4497
|
}
|
|
4056
4498
|
};
|
|
4057
4499
|
//#endregion
|
|
@@ -4217,12 +4659,7 @@ var ClaudeCodeMcpSync = class {
|
|
|
4217
4659
|
const file = { mcpServers: Object.fromEntries(desired) };
|
|
4218
4660
|
const next = JSON.stringify(file, null, 2) + "\n";
|
|
4219
4661
|
if ((existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "") !== next) {
|
|
4220
|
-
|
|
4221
|
-
writeFileSync(this.configPath, next, {
|
|
4222
|
-
encoding: "utf-8",
|
|
4223
|
-
mode: 384
|
|
4224
|
-
});
|
|
4225
|
-
chmodSync(this.configPath, 384);
|
|
4662
|
+
atomicWriteFileSync(this.configPath, next);
|
|
4226
4663
|
log$1.info({
|
|
4227
4664
|
added: [...desiredIds],
|
|
4228
4665
|
removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
|
|
@@ -4262,8 +4699,8 @@ var ClaudeCodeMcpSync = class {
|
|
|
4262
4699
|
}
|
|
4263
4700
|
withAlfeApiKey(env) {
|
|
4264
4701
|
const merged = { ...env ?? {} };
|
|
4265
|
-
if ("ALFE_API_KEY" in merged) return merged;
|
|
4266
4702
|
if (!this.apiKey) {
|
|
4703
|
+
delete merged.ALFE_API_KEY;
|
|
4267
4704
|
log$1.warn("Claude Code MCP sync: no ALFE_API_KEY available — generated MCP servers will start in zero-accounts degraded mode");
|
|
4268
4705
|
return merged;
|
|
4269
4706
|
}
|
|
@@ -4279,8 +4716,7 @@ var ClaudeCodeMcpSync = class {
|
|
|
4279
4716
|
}
|
|
4280
4717
|
persistSyncedIds() {
|
|
4281
4718
|
try {
|
|
4282
|
-
|
|
4283
|
-
writeFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n", "utf-8");
|
|
4719
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n");
|
|
4284
4720
|
} catch (err) {
|
|
4285
4721
|
log$1.warn({ err: errMsg$1(err) }, "Claude Code MCP sync: failed to persist synced-id sidecar");
|
|
4286
4722
|
}
|
|
@@ -4295,6 +4731,7 @@ const log = createLogger("McpApplier");
|
|
|
4295
4731
|
const CONFIG_TEMPLATE_RE = /\{\{config\.([a-zA-Z0-9_]+)\}\}/g;
|
|
4296
4732
|
const CREDENTIALS_TEMPLATE_RE = /\{\{credentials\.([a-z0-9-]+)\.([a-zA-Z0-9_]+)\}\}/g;
|
|
4297
4733
|
const ALFE_TEMPLATE_RE = /\{\{alfe\.([a-zA-Z0-9_]+)\}\}/g;
|
|
4734
|
+
const RESERVED_RUNTIME_ENV_KEYS = new Set(["ALFE_API_KEY"]);
|
|
4298
4735
|
/**
|
|
4299
4736
|
* Budget for the activation-time warm of each applied server. Matches the
|
|
4300
4737
|
* bundler's default `connectTimeoutMs` so the warm never reports "failed"
|
|
@@ -4329,10 +4766,12 @@ var McpApplier = class {
|
|
|
4329
4766
|
async applyForIntegration(integrationId, servers, mergedConfig, opts) {
|
|
4330
4767
|
const owner = `integration:${integrationId}`;
|
|
4331
4768
|
const applied = [];
|
|
4769
|
+
const declaredIds = new Set(servers.map((server) => `${integrationId}-${server.id}`));
|
|
4332
4770
|
for (const server of servers) {
|
|
4333
4771
|
const id = `${integrationId}-${server.id}`;
|
|
4334
4772
|
const envResolved = await this.resolveEnv(server, mergedConfig, opts?.connectionId);
|
|
4335
4773
|
if (envResolved == null) {
|
|
4774
|
+
await this.manager.removeServer(id, { expectedOwner: owner });
|
|
4336
4775
|
log.info({
|
|
4337
4776
|
integrationId,
|
|
4338
4777
|
server: server.id,
|
|
@@ -4346,6 +4785,7 @@ var McpApplier = class {
|
|
|
4346
4785
|
});
|
|
4347
4786
|
applied.push(id);
|
|
4348
4787
|
}
|
|
4788
|
+
for (const { id, entry } of this.manager.listServers()) if (entry.owner === owner && !declaredIds.has(id)) await this.manager.removeServer(id, { expectedOwner: owner });
|
|
4349
4789
|
for (const id of applied) this.manager.warmServer(id, MCP_ACTIVATION_WARM_TIMEOUT_MS).then((status) => {
|
|
4350
4790
|
if (!status) return;
|
|
4351
4791
|
if (status.connected) log.info({
|
|
@@ -4421,13 +4861,17 @@ var McpApplier = class {
|
|
|
4421
4861
|
}
|
|
4422
4862
|
const resolved = {};
|
|
4423
4863
|
for (const [key, value] of Object.entries(server.env)) {
|
|
4864
|
+
if (RESERVED_RUNTIME_ENV_KEYS.has(key)) {
|
|
4865
|
+
log.warn({ key }, "Ignoring manifest override of reserved MCP runtime environment key");
|
|
4866
|
+
continue;
|
|
4867
|
+
}
|
|
4424
4868
|
const interpolated = interpolateString(value, mergedConfig, credentialsCache, this.platform);
|
|
4425
|
-
if (
|
|
4869
|
+
if (hasUnresolvedTemplate(interpolated)) {
|
|
4426
4870
|
log.warn({
|
|
4427
4871
|
provider,
|
|
4428
4872
|
connectionId,
|
|
4429
4873
|
key
|
|
4430
|
-
}, "
|
|
4874
|
+
}, "MCP interpolation left a placeholder — skipping registration");
|
|
4431
4875
|
return null;
|
|
4432
4876
|
}
|
|
4433
4877
|
resolved[key] = interpolated;
|
|
@@ -4435,8 +4879,8 @@ var McpApplier = class {
|
|
|
4435
4879
|
return resolved;
|
|
4436
4880
|
}
|
|
4437
4881
|
};
|
|
4438
|
-
function
|
|
4439
|
-
return /\{\{credentials\.[a-z0-9-]+\.[a-zA-Z0-9_]
|
|
4882
|
+
function hasUnresolvedTemplate(value) {
|
|
4883
|
+
return /\{\{(?:config\.[a-zA-Z0-9_]+|credentials\.[a-z0-9-]+\.[a-zA-Z0-9_]+|alfe\.[a-zA-Z0-9_]+)\}\}/.test(value);
|
|
4440
4884
|
}
|
|
4441
4885
|
function interpolateString(value, mergedConfig, credentials, platform) {
|
|
4442
4886
|
let next = value.replace(CONFIG_TEMPLATE_RE, (match, configKey) => {
|