@alfe.ai/integrations 0.5.4 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -11
- package/dist/index.d.ts +54 -23
- package/dist/index.js +776 -269
- 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 {
|
|
@@ -157,6 +348,7 @@ const log$6 = createLogger("Installer");
|
|
|
157
348
|
const INTEGRATIONS_DIR = join(homedir(), ".alfe", "integrations");
|
|
158
349
|
const GIT_TIMEOUT_MS = 6e4;
|
|
159
350
|
const NPM_TIMEOUT_MS = 6e4;
|
|
351
|
+
const NPX_PREWARM_TIMEOUT_MS = 12e4;
|
|
160
352
|
/**
|
|
161
353
|
* Dot-prefix for the transient staging directories used by the diff-based
|
|
162
354
|
* in-place upgrade (`stage()` → `commitStaged()`). Dot-prefixed so `list()`
|
|
@@ -164,6 +356,7 @@ const NPM_TIMEOUT_MS = 6e4;
|
|
|
164
356
|
* staging dir for a real integration install.
|
|
165
357
|
*/
|
|
166
358
|
const STAGING_PREFIX = ".staging-";
|
|
359
|
+
const BACKUP_PREFIX = ".backup-";
|
|
167
360
|
/** Shared @alfe.ai packages available to all integration hooks */
|
|
168
361
|
const SHARED_PACKAGES = {
|
|
169
362
|
"@alfe.ai/config": "latest",
|
|
@@ -190,7 +383,10 @@ var Installer = class {
|
|
|
190
383
|
* Get the path where an integration's content lives.
|
|
191
384
|
*/
|
|
192
385
|
getInstallPath(name) {
|
|
193
|
-
|
|
386
|
+
const safeName = assertSafePathSegment(name, "Integration id");
|
|
387
|
+
const installPath = join(this.basePath, safeName);
|
|
388
|
+
this.recoverInterruptedSwap(safeName, installPath);
|
|
389
|
+
return installPath;
|
|
194
390
|
}
|
|
195
391
|
/**
|
|
196
392
|
* Install an integration by cloning its git repo and checking out the pinned commit.
|
|
@@ -204,6 +400,7 @@ var Installer = class {
|
|
|
204
400
|
* it is cleaned up before retrying.
|
|
205
401
|
*/
|
|
206
402
|
async install(resolved) {
|
|
403
|
+
this.validateResolved(resolved);
|
|
207
404
|
const installPath = this.getInstallPath(resolved.id);
|
|
208
405
|
if (existsSync(installPath)) rmSync(installPath, {
|
|
209
406
|
recursive: true,
|
|
@@ -214,6 +411,7 @@ var Installer = class {
|
|
|
214
411
|
else await this.cloneDirect(resolved, installPath);
|
|
215
412
|
await this.ensureSharedPackages();
|
|
216
413
|
await this.installLocalDependencies(installPath);
|
|
414
|
+
await this.prewarmMcpNpxPackages(installPath);
|
|
217
415
|
return installPath;
|
|
218
416
|
}
|
|
219
417
|
/**
|
|
@@ -223,6 +421,7 @@ var Installer = class {
|
|
|
223
421
|
try {
|
|
224
422
|
await execFileAsync$2("git", [
|
|
225
423
|
"clone",
|
|
424
|
+
"--",
|
|
226
425
|
resolved.repository,
|
|
227
426
|
installPath
|
|
228
427
|
], { timeout: GIT_TIMEOUT_MS });
|
|
@@ -244,11 +443,12 @@ var Installer = class {
|
|
|
244
443
|
*/
|
|
245
444
|
async cloneAndExtractSubdir(resolved, installPath) {
|
|
246
445
|
if (!resolved.subdir) throw new InstallerError("subdir is required for monorepo extraction");
|
|
247
|
-
const subdir = resolved.subdir;
|
|
446
|
+
const subdir = assertSafeRelativePath(resolved.subdir, "Integration subdir");
|
|
248
447
|
const tempDir = mkdtempSync(join(tmpdir(), `alfe-clone-${resolved.id}-`));
|
|
249
448
|
try {
|
|
250
449
|
await execFileAsync$2("git", [
|
|
251
450
|
"clone",
|
|
451
|
+
"--",
|
|
252
452
|
resolved.repository,
|
|
253
453
|
tempDir
|
|
254
454
|
], { timeout: GIT_TIMEOUT_MS });
|
|
@@ -256,9 +456,8 @@ var Installer = class {
|
|
|
256
456
|
cwd: tempDir,
|
|
257
457
|
timeout: GIT_TIMEOUT_MS
|
|
258
458
|
});
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
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 });
|
|
262
461
|
} catch (err) {
|
|
263
462
|
if (existsSync(installPath)) rmSync(installPath, {
|
|
264
463
|
recursive: true,
|
|
@@ -292,6 +491,7 @@ var Installer = class {
|
|
|
292
491
|
* cleaned up (try/finally) so a failed prepare leaves no partial clone.
|
|
293
492
|
*/
|
|
294
493
|
async stage(resolved) {
|
|
494
|
+
this.validateResolved(resolved);
|
|
295
495
|
mkdirSync(this.basePath, { recursive: true });
|
|
296
496
|
this.sweepStagingDirs();
|
|
297
497
|
const stagingPath = join(this.basePath, `${STAGING_PREFIX}${resolved.id}-${randomBytes(6).toString("hex")}`);
|
|
@@ -304,6 +504,7 @@ var Installer = class {
|
|
|
304
504
|
else await this.cloneDirect(resolved, stagingPath);
|
|
305
505
|
await this.ensureSharedPackages();
|
|
306
506
|
await this.installLocalDependencies(stagingPath);
|
|
507
|
+
await this.prewarmMcpNpxPackages(stagingPath);
|
|
307
508
|
return stagingPath;
|
|
308
509
|
} catch (err) {
|
|
309
510
|
if (existsSync(stagingPath)) rmSync(stagingPath, {
|
|
@@ -314,18 +515,55 @@ var Installer = class {
|
|
|
314
515
|
}
|
|
315
516
|
}
|
|
316
517
|
/**
|
|
317
|
-
* Commit a previously-staged clone
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
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.
|
|
321
522
|
*/
|
|
322
523
|
commitStaged(name, stagedPath) {
|
|
323
524
|
const installPath = this.getInstallPath(name);
|
|
324
|
-
|
|
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, {
|
|
325
564
|
recursive: true,
|
|
326
565
|
force: true
|
|
327
566
|
});
|
|
328
|
-
renameSync(stagedPath, installPath);
|
|
329
567
|
}
|
|
330
568
|
/**
|
|
331
569
|
* Remove any orphaned `.staging-*` directories under the base path. Called at
|
|
@@ -356,11 +594,10 @@ var Installer = class {
|
|
|
356
594
|
async update(name, resolved) {
|
|
357
595
|
const installPath = this.getInstallPath(name);
|
|
358
596
|
if (!existsSync(installPath)) throw new InstallerError(`Integration "${name}" is not installed — cannot update`);
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
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;
|
|
364
601
|
}
|
|
365
602
|
/**
|
|
366
603
|
* Remove an installed integration.
|
|
@@ -396,7 +633,7 @@ var Installer = class {
|
|
|
396
633
|
type: "module",
|
|
397
634
|
dependencies: { ...SHARED_PACKAGES }
|
|
398
635
|
};
|
|
399
|
-
|
|
636
|
+
atomicWriteFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n");
|
|
400
637
|
log$6.info("Installing shared @alfe.ai packages for integration hooks");
|
|
401
638
|
await this.runNpmInstall(this.basePath);
|
|
402
639
|
this.sharedPackagesReady = true;
|
|
@@ -426,6 +663,63 @@ var Installer = class {
|
|
|
426
663
|
}
|
|
427
664
|
}
|
|
428
665
|
/**
|
|
666
|
+
* Pre-fetch every npx-executed MCP server package the manifest declares
|
|
667
|
+
* (e.g. `command: npx, args: ["-y", "@alfe.ai/xero-mcp@0.3.8"]`) into the
|
|
668
|
+
* npx cache, WITHOUT executing the server. The first bundler connect then
|
|
669
|
+
* spawns from a warm cache instead of paying a 30-90s npm download during
|
|
670
|
+
* the MCP handshake — where it used to blow the connect budget and strand
|
|
671
|
+
* the server's tools until a daemon restart.
|
|
672
|
+
*
|
|
673
|
+
* Strictly best-effort: a missing/unparseable manifest, a non-npx command,
|
|
674
|
+
* or a failed download logs a warning and moves on — an install must never
|
|
675
|
+
* fail because a pre-warm did.
|
|
676
|
+
*/
|
|
677
|
+
async prewarmMcpNpxPackages(installPath) {
|
|
678
|
+
let servers;
|
|
679
|
+
try {
|
|
680
|
+
const manifestPath = join(installPath, "alfe-integration.yaml");
|
|
681
|
+
if (!existsSync(manifestPath)) return;
|
|
682
|
+
servers = parseManifestFile(manifestPath).mcp_servers;
|
|
683
|
+
} catch (err) {
|
|
684
|
+
log$6.warn({
|
|
685
|
+
path: installPath,
|
|
686
|
+
err: err instanceof Error ? err.message : String(err)
|
|
687
|
+
}, "Skipping MCP npx pre-warm — manifest unreadable");
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
for (const server of servers) {
|
|
691
|
+
if (server.command !== "npx") continue;
|
|
692
|
+
const spec = (server.args ?? []).find((a) => !a.startsWith("-"));
|
|
693
|
+
if (!spec) continue;
|
|
694
|
+
try {
|
|
695
|
+
log$6.info({
|
|
696
|
+
server: server.id,
|
|
697
|
+
spec
|
|
698
|
+
}, "Pre-warming npx cache for MCP server package");
|
|
699
|
+
await this.runNpx(spec);
|
|
700
|
+
} catch (err) {
|
|
701
|
+
log$6.warn({
|
|
702
|
+
server: server.id,
|
|
703
|
+
spec,
|
|
704
|
+
err: err instanceof Error ? err.message : String(err)
|
|
705
|
+
}, "MCP npx pre-warm failed — first connect will pay the cold download");
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
/** Populate the npx cache for a package spec via a no-op command. */
|
|
710
|
+
async runNpx(spec) {
|
|
711
|
+
await execFileAsync$2("npx", [
|
|
712
|
+
"-y",
|
|
713
|
+
"-p",
|
|
714
|
+
spec,
|
|
715
|
+
"-c",
|
|
716
|
+
"exit 0"
|
|
717
|
+
], {
|
|
718
|
+
timeout: NPX_PREWARM_TIMEOUT_MS,
|
|
719
|
+
maxBuffer: 16 * 1024 * 1024
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
/**
|
|
429
723
|
* List all locally installed integrations.
|
|
430
724
|
*/
|
|
431
725
|
list() {
|
|
@@ -462,14 +756,20 @@ var Installer = class {
|
|
|
462
756
|
isInstalled(name) {
|
|
463
757
|
return existsSync(this.getInstallPath(name));
|
|
464
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
|
+
}
|
|
465
765
|
};
|
|
466
766
|
//#endregion
|
|
467
767
|
//#region src/state.ts
|
|
468
768
|
/**
|
|
469
769
|
* State Manager — manages ~/.alfe/integrations.json.
|
|
470
770
|
*
|
|
471
|
-
*
|
|
472
|
-
*
|
|
771
|
+
* Daemon-owned read-modify-write state with crash-safe atomic replacement.
|
|
772
|
+
* Secrets are NEVER written to this file.
|
|
473
773
|
*/
|
|
474
774
|
const DEFAULT_STATE_PATH = join(homedir(), ".alfe", "integrations.json");
|
|
475
775
|
const EMPTY_STATE = {
|
|
@@ -478,7 +778,6 @@ const EMPTY_STATE = {
|
|
|
478
778
|
};
|
|
479
779
|
var StateManager = class {
|
|
480
780
|
filePath;
|
|
481
|
-
lockHeld = false;
|
|
482
781
|
constructor(filePath) {
|
|
483
782
|
this.filePath = filePath ?? DEFAULT_STATE_PATH;
|
|
484
783
|
}
|
|
@@ -490,27 +789,19 @@ var StateManager = class {
|
|
|
490
789
|
...EMPTY_STATE,
|
|
491
790
|
integrations: {}
|
|
492
791
|
};
|
|
792
|
+
let parsed;
|
|
493
793
|
try {
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
...EMPTY_STATE,
|
|
498
|
-
integrations: {}
|
|
499
|
-
};
|
|
500
|
-
return parsed;
|
|
501
|
-
} catch {
|
|
502
|
-
return {
|
|
503
|
-
...EMPTY_STATE,
|
|
504
|
-
integrations: {}
|
|
505
|
-
};
|
|
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)}`);
|
|
506
797
|
}
|
|
798
|
+
return validateStateFile(parsed);
|
|
507
799
|
}
|
|
508
800
|
/**
|
|
509
801
|
* Write the state file atomically.
|
|
510
802
|
*/
|
|
511
803
|
write(state) {
|
|
512
|
-
|
|
513
|
-
writeFileSync(this.filePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
804
|
+
atomicWriteFileSync(this.filePath, JSON.stringify(state, null, 2) + "\n");
|
|
514
805
|
}
|
|
515
806
|
/**
|
|
516
807
|
* Get a specific integration's state.
|
|
@@ -522,6 +813,7 @@ var StateManager = class {
|
|
|
522
813
|
* Set an integration's state (read-modify-write).
|
|
523
814
|
*/
|
|
524
815
|
set(name, entry) {
|
|
816
|
+
assertSafePathSegment(name, "Integration id");
|
|
525
817
|
const state = this.read();
|
|
526
818
|
state.integrations[name] = entry;
|
|
527
819
|
this.write(state);
|
|
@@ -531,7 +823,8 @@ var StateManager = class {
|
|
|
531
823
|
*/
|
|
532
824
|
update(name, partial) {
|
|
533
825
|
const state = this.read();
|
|
534
|
-
|
|
826
|
+
assertSafePathSegment(name, "Integration id");
|
|
827
|
+
if (!Object.hasOwn(state.integrations, name)) throw new Error(`Integration "${name}" not found in state`);
|
|
535
828
|
state.integrations[name] = {
|
|
536
829
|
...state.integrations[name],
|
|
537
830
|
...partial
|
|
@@ -543,7 +836,8 @@ var StateManager = class {
|
|
|
543
836
|
*/
|
|
544
837
|
setStatus(name, status, error) {
|
|
545
838
|
const state = this.read();
|
|
546
|
-
|
|
839
|
+
assertSafePathSegment(name, "Integration id");
|
|
840
|
+
if (!Object.hasOwn(state.integrations, name)) return;
|
|
547
841
|
const existing = state.integrations[name];
|
|
548
842
|
existing.status = status;
|
|
549
843
|
if (error !== void 0) existing.error = error;
|
|
@@ -554,6 +848,7 @@ var StateManager = class {
|
|
|
554
848
|
* Remove an integration from the state file.
|
|
555
849
|
*/
|
|
556
850
|
remove(name) {
|
|
851
|
+
assertSafePathSegment(name, "Integration id");
|
|
557
852
|
const state = this.read();
|
|
558
853
|
state.integrations = Object.fromEntries(Object.entries(state.integrations).filter(([key]) => key !== name));
|
|
559
854
|
this.write(state);
|
|
@@ -564,8 +859,8 @@ var StateManager = class {
|
|
|
564
859
|
list() {
|
|
565
860
|
const state = this.read();
|
|
566
861
|
return Object.entries(state.integrations).map(([id, entry]) => ({
|
|
567
|
-
|
|
568
|
-
|
|
862
|
+
...entry,
|
|
863
|
+
id
|
|
569
864
|
}));
|
|
570
865
|
}
|
|
571
866
|
/**
|
|
@@ -581,7 +876,8 @@ var StateManager = class {
|
|
|
581
876
|
*/
|
|
582
877
|
incrementReinstallAttempts(name) {
|
|
583
878
|
const state = this.read();
|
|
584
|
-
|
|
879
|
+
assertSafePathSegment(name, "Integration id");
|
|
880
|
+
if (!Object.hasOwn(state.integrations, name)) return;
|
|
585
881
|
state.integrations[name].reinstallAttempts = (state.integrations[name].reinstallAttempts ?? 0) + 1;
|
|
586
882
|
this.write(state);
|
|
587
883
|
}
|
|
@@ -590,11 +886,35 @@ var StateManager = class {
|
|
|
590
886
|
*/
|
|
591
887
|
resetReinstallAttempts(name) {
|
|
592
888
|
const state = this.read();
|
|
593
|
-
|
|
889
|
+
assertSafePathSegment(name, "Integration id");
|
|
890
|
+
if (!Object.hasOwn(state.integrations, name)) return;
|
|
594
891
|
state.integrations[name].reinstallAttempts = 0;
|
|
595
892
|
this.write(state);
|
|
596
893
|
}
|
|
597
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
|
+
}
|
|
598
918
|
//#endregion
|
|
599
919
|
//#region src/lock.ts
|
|
600
920
|
/**
|
|
@@ -623,30 +943,20 @@ var LockManager = class {
|
|
|
623
943
|
runtimes: {},
|
|
624
944
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
625
945
|
};
|
|
946
|
+
let parsed;
|
|
626
947
|
try {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
...EMPTY_LOCK,
|
|
631
|
-
runtimes: {},
|
|
632
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
633
|
-
};
|
|
634
|
-
return parsed;
|
|
635
|
-
} catch {
|
|
636
|
-
return {
|
|
637
|
-
...EMPTY_LOCK,
|
|
638
|
-
runtimes: {},
|
|
639
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
640
|
-
};
|
|
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)}`);
|
|
641
951
|
}
|
|
952
|
+
return validateRuntimeLock(parsed);
|
|
642
953
|
}
|
|
643
954
|
/**
|
|
644
955
|
* Write the lock file atomically.
|
|
645
956
|
*/
|
|
646
957
|
write(lock) {
|
|
647
|
-
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
648
958
|
lock.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
649
|
-
|
|
959
|
+
atomicWriteFileSync(this.filePath, JSON.stringify(lock, null, 2) + "\n");
|
|
650
960
|
}
|
|
651
961
|
/**
|
|
652
962
|
* Add entries for an integration activation in a specific runtime.
|
|
@@ -656,6 +966,8 @@ var LockManager = class {
|
|
|
656
966
|
* what lets a config-only integration be torn down on deactivate.
|
|
657
967
|
*/
|
|
658
968
|
addEntries(runtime, integrationId, version, plugins, skills, installPath, opts) {
|
|
969
|
+
assertSafePathSegment(runtime, "Runtime id");
|
|
970
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
659
971
|
const lock = this.read();
|
|
660
972
|
if (!(runtime in lock.runtimes)) lock.runtimes[runtime] = {
|
|
661
973
|
plugins: [],
|
|
@@ -688,6 +1000,27 @@ var LockManager = class {
|
|
|
688
1000
|
this.write(lock);
|
|
689
1001
|
}
|
|
690
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
|
+
/**
|
|
691
1024
|
* Remove all entries for a given integration across all runtimes.
|
|
692
1025
|
* Returns what was removed, keyed by runtime.
|
|
693
1026
|
*
|
|
@@ -696,6 +1029,7 @@ var LockManager = class {
|
|
|
696
1029
|
* `applier.removeConfig` even for a config-only integration.
|
|
697
1030
|
*/
|
|
698
1031
|
removeEntries(integrationId) {
|
|
1032
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
699
1033
|
const lock = this.read();
|
|
700
1034
|
const removed = {};
|
|
701
1035
|
for (const [runtime, state] of Object.entries(lock.runtimes)) {
|
|
@@ -724,6 +1058,49 @@ var LockManager = class {
|
|
|
724
1058
|
};
|
|
725
1059
|
}
|
|
726
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
|
+
}
|
|
727
1104
|
//#endregion
|
|
728
1105
|
//#region src/hooks.ts
|
|
729
1106
|
/**
|
|
@@ -761,6 +1138,7 @@ const HOOK_TIMEOUT_MS = 3e4;
|
|
|
761
1138
|
const INSTALL_HOOK_TIMEOUT_MS = 6e5;
|
|
762
1139
|
const INTEGRATIONS_BASE_DIR = join(homedir(), ".alfe", "integrations");
|
|
763
1140
|
const STATE_BASE_DIR = join(homedir(), ".alfe", "state");
|
|
1141
|
+
const IS_WINDOWS = platform() === "win32";
|
|
764
1142
|
/**
|
|
765
1143
|
* Build the environment variables for a hook script execution.
|
|
766
1144
|
*
|
|
@@ -773,6 +1151,7 @@ const STATE_BASE_DIR = join(homedir(), ".alfe", "state");
|
|
|
773
1151
|
*/
|
|
774
1152
|
function buildHookEnv(options, additionalEnv) {
|
|
775
1153
|
const { integrationName, config, secrets, runtimes } = options;
|
|
1154
|
+
assertSafePathSegment(integrationName, "Integration id");
|
|
776
1155
|
const nameUpper = integrationName.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
777
1156
|
const integrationDir = join(INTEGRATIONS_BASE_DIR, integrationName);
|
|
778
1157
|
const stateDir = join(STATE_BASE_DIR, integrationName);
|
|
@@ -811,12 +1190,13 @@ function buildHookEnv(options, additionalEnv) {
|
|
|
811
1190
|
* 3. Default → "bash"
|
|
812
1191
|
*/
|
|
813
1192
|
function resolveInterpreter(scriptPath) {
|
|
814
|
-
|
|
1193
|
+
let fd;
|
|
815
1194
|
try {
|
|
816
|
-
|
|
1195
|
+
fd = openSync(scriptPath, "r");
|
|
817
1196
|
const buf = Buffer.alloc(256);
|
|
818
1197
|
readSync(fd, buf, 0, 256, 0);
|
|
819
1198
|
closeSync(fd);
|
|
1199
|
+
fd = void 0;
|
|
820
1200
|
const firstLine = buf.toString("utf-8").split("\n")[0];
|
|
821
1201
|
if (firstLine.startsWith("#!")) {
|
|
822
1202
|
const shebang = firstLine.slice(2).trim();
|
|
@@ -828,7 +1208,7 @@ function resolveInterpreter(scriptPath) {
|
|
|
828
1208
|
};
|
|
829
1209
|
}
|
|
830
1210
|
const cmd = shebang.split(/\s+/)[0];
|
|
831
|
-
if (
|
|
1211
|
+
if (IS_WINDOWS && cmd.startsWith("/")) return {
|
|
832
1212
|
command: cmd.split("/").pop() ?? cmd,
|
|
833
1213
|
args: [scriptPath]
|
|
834
1214
|
};
|
|
@@ -837,13 +1217,15 @@ function resolveInterpreter(scriptPath) {
|
|
|
837
1217
|
args: [scriptPath]
|
|
838
1218
|
};
|
|
839
1219
|
}
|
|
840
|
-
} catch {}
|
|
1220
|
+
} catch {} finally {
|
|
1221
|
+
if (fd !== void 0) closeSync(fd);
|
|
1222
|
+
}
|
|
841
1223
|
if (scriptPath.endsWith(".js") || scriptPath.endsWith(".mjs")) return {
|
|
842
1224
|
command: "node",
|
|
843
1225
|
args: [scriptPath]
|
|
844
1226
|
};
|
|
845
1227
|
if (scriptPath.endsWith(".py")) return {
|
|
846
|
-
command:
|
|
1228
|
+
command: IS_WINDOWS ? "python" : "python3",
|
|
847
1229
|
args: [scriptPath]
|
|
848
1230
|
};
|
|
849
1231
|
return {
|
|
@@ -866,13 +1248,17 @@ function resolveInterpreter(scriptPath) {
|
|
|
866
1248
|
* @returns Hook execution result
|
|
867
1249
|
*/
|
|
868
1250
|
async function runHook(integrationPath, hookScript, env, timeoutMs = HOOK_TIMEOUT_MS) {
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
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
|
+
}
|
|
876
1262
|
const { command, args } = resolveInterpreter(scriptPath);
|
|
877
1263
|
return new Promise((resolve) => {
|
|
878
1264
|
let stdout = "";
|
|
@@ -888,10 +1274,29 @@ async function runHook(integrationPath, hookScript, env, timeoutMs = HOOK_TIMEOU
|
|
|
888
1274
|
"ignore",
|
|
889
1275
|
"pipe",
|
|
890
1276
|
"pipe"
|
|
891
|
-
]
|
|
1277
|
+
],
|
|
1278
|
+
detached: !IS_WINDOWS
|
|
892
1279
|
});
|
|
893
1280
|
const timer = setTimeout(() => {
|
|
894
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 {}
|
|
895
1300
|
proc.kill("SIGKILL");
|
|
896
1301
|
}, timeoutMs);
|
|
897
1302
|
proc.stdout.on("data", (data) => {
|
|
@@ -1003,20 +1408,50 @@ function pluginSpecVersion(spec) {
|
|
|
1003
1408
|
const FULL_PLACEHOLDER = /^\{\{config\.([a-zA-Z0-9_]+)\}\}$/;
|
|
1004
1409
|
function interpolateSelfConfig(obj, config) {
|
|
1005
1410
|
const result = {};
|
|
1006
|
-
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") {
|
|
1007
1416
|
const wholeMatch = FULL_PLACEHOLDER.exec(value);
|
|
1008
1417
|
if (wholeMatch) {
|
|
1009
1418
|
const raw = config[wholeMatch[1]];
|
|
1010
|
-
|
|
1011
|
-
continue;
|
|
1419
|
+
return raw !== void 0 ? raw : value;
|
|
1012
1420
|
}
|
|
1013
|
-
|
|
1014
|
-
const
|
|
1015
|
-
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;
|
|
1016
1424
|
});
|
|
1017
|
-
}
|
|
1018
|
-
|
|
1019
|
-
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
|
+
};
|
|
1020
1455
|
}
|
|
1021
1456
|
/**
|
|
1022
1457
|
* Merge universal installs with runtime-specific installs from the manifest.
|
|
@@ -1037,8 +1472,17 @@ function resolveInstallsForRuntime(manifest, runtime) {
|
|
|
1037
1472
|
* checkout-able `manifestCommit` pins the clone.
|
|
1038
1473
|
*/
|
|
1039
1474
|
function buildCustomResolved(id, cs) {
|
|
1040
|
-
|
|
1041
|
-
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);
|
|
1042
1486
|
const subdir = dir === "." || dir === "" ? void 0 : dir;
|
|
1043
1487
|
const manifestVersion = cs.manifest?.version;
|
|
1044
1488
|
return {
|
|
@@ -1057,9 +1501,15 @@ function buildCustomResolved(id, cs) {
|
|
|
1057
1501
|
*/
|
|
1058
1502
|
function ensureCanonicalManifestName(installPath, manifestPath) {
|
|
1059
1503
|
const canonical = join(installPath, "alfe-integration.yaml");
|
|
1060
|
-
if (existsSync(canonical))
|
|
1061
|
-
|
|
1062
|
-
|
|
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}"`);
|
|
1063
1513
|
}
|
|
1064
1514
|
var IntegrationManager = class {
|
|
1065
1515
|
log = createLogger("IntegrationManager");
|
|
@@ -1098,6 +1548,11 @@ var IntegrationManager = class {
|
|
|
1098
1548
|
async install(params) {
|
|
1099
1549
|
const { name, version, config, customSource } = params;
|
|
1100
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
|
+
}
|
|
1101
1556
|
const existing = this.state.get(name);
|
|
1102
1557
|
if (existing) {
|
|
1103
1558
|
if (existing.status === "installed" || existing.status === "configured" || existing.status === "active") {
|
|
@@ -1137,7 +1592,10 @@ var IntegrationManager = class {
|
|
|
1137
1592
|
const manifestPath = join(installPath, "alfe-integration.yaml");
|
|
1138
1593
|
if (!existsSync(manifestPath)) throw new Error(`No alfe-integration.yaml found in ${installPath}`);
|
|
1139
1594
|
const manifest = parseManifestFile(manifestPath);
|
|
1595
|
+
assertManifestIdentity(name, manifest, customSource !== void 0);
|
|
1140
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);
|
|
1141
1599
|
for (const dep of manifest.depends_on) {
|
|
1142
1600
|
const depState = this.state.get(dep);
|
|
1143
1601
|
if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
|
|
@@ -1153,8 +1611,8 @@ var IntegrationManager = class {
|
|
|
1153
1611
|
this.log.info(`Running post_install hook: ${manifest.hooks.post_install}`);
|
|
1154
1612
|
const hookResult = await runHookWithContext(installPath, manifest.hooks.post_install, {
|
|
1155
1613
|
integrationName: name,
|
|
1156
|
-
config:
|
|
1157
|
-
secrets:
|
|
1614
|
+
config: preparedConfig.nonSecretConfig,
|
|
1615
|
+
secrets: preparedConfig.secretConfig,
|
|
1158
1616
|
runtimes: [...this.runtimeAppliers.keys()]
|
|
1159
1617
|
}, INSTALL_HOOK_TIMEOUT_MS);
|
|
1160
1618
|
if (hookResult.exitCode !== 0) throw new Error(this.hookFailureMessage("post_install hook failed", hookResult));
|
|
@@ -1163,7 +1621,7 @@ var IntegrationManager = class {
|
|
|
1163
1621
|
status: "installed",
|
|
1164
1622
|
version: manifest.version,
|
|
1165
1623
|
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1166
|
-
config:
|
|
1624
|
+
config: preparedConfig.nonSecretConfig,
|
|
1167
1625
|
customConnectionId: customSource?.connectionId
|
|
1168
1626
|
});
|
|
1169
1627
|
this.log.info(`Integration "${name}" installed successfully`);
|
|
@@ -1183,6 +1641,7 @@ var IntegrationManager = class {
|
|
|
1183
1641
|
try {
|
|
1184
1642
|
await this.installer.remove(name);
|
|
1185
1643
|
} catch {}
|
|
1644
|
+
this.secrets.delete(name);
|
|
1186
1645
|
this.state.setStatus(name, "error", message);
|
|
1187
1646
|
return this.err("INSTALL_FAILED", message);
|
|
1188
1647
|
}
|
|
@@ -1203,20 +1662,7 @@ var IntegrationManager = class {
|
|
|
1203
1662
|
if (entry.status !== "installed" && entry.status !== "configured" && entry.status !== "active") return this.err("INVALID_STATE", `Cannot configure integration in "${entry.status}" state`);
|
|
1204
1663
|
this.log.info(`Configuring integration: ${name}`);
|
|
1205
1664
|
try {
|
|
1206
|
-
const
|
|
1207
|
-
if (manifest.config_schema.length > 0) {
|
|
1208
|
-
const result = buildConfigValidationSchema(manifest.config_schema).safeParse(config);
|
|
1209
|
-
if (!result.success) {
|
|
1210
|
-
const issues = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
1211
|
-
return this.err("INVALID_CONFIG", `Config validation failed: ${issues}`);
|
|
1212
|
-
}
|
|
1213
|
-
}
|
|
1214
|
-
const missingRequired = manifest.config_schema.filter((f) => f.required && config[f.key] === void 0).map((f) => f.key);
|
|
1215
|
-
if (missingRequired.length > 0) return this.err("MISSING_CONFIG", `Missing required config fields: ${missingRequired.join(", ")}`);
|
|
1216
|
-
const nonSecretConfig = {};
|
|
1217
|
-
const secretConfig = /* @__PURE__ */ new Map();
|
|
1218
|
-
for (const [key, value] of Object.entries(config)) if (manifest.config_schema.find((f) => f.key === key)?.type === "secret") secretConfig.set(key, value);
|
|
1219
|
-
else nonSecretConfig[key] = value;
|
|
1665
|
+
const { nonSecretConfig, secretConfig } = validateAndSplitConfig(parseManifestFile(join(this.installer.getInstallPath(name), "alfe-integration.yaml")), config);
|
|
1220
1666
|
this.state.update(name, {
|
|
1221
1667
|
status: "configured",
|
|
1222
1668
|
config: nonSecretConfig
|
|
@@ -1233,6 +1679,7 @@ var IntegrationManager = class {
|
|
|
1233
1679
|
}
|
|
1234
1680
|
};
|
|
1235
1681
|
} catch (err) {
|
|
1682
|
+
if (err instanceof IntegrationConfigValidationError) return this.err(err.code, err.message);
|
|
1236
1683
|
const message = err instanceof Error ? err.message : String(err);
|
|
1237
1684
|
this.log.error(`Failed to configure "${name}": ${message}`);
|
|
1238
1685
|
return this.err("CONFIGURE_FAILED", message);
|
|
@@ -1311,7 +1758,7 @@ var IntegrationManager = class {
|
|
|
1311
1758
|
await applier.applyClawHubSkill(skill.clawhub);
|
|
1312
1759
|
} else if (skill.path) {
|
|
1313
1760
|
const skillName = skill.path.split("/").pop() ?? skill.path;
|
|
1314
|
-
const srcPath =
|
|
1761
|
+
const srcPath = resolveExistingWithin(installPath, skill.path, `Skill path for ${skillLabel}`, "directory");
|
|
1315
1762
|
this.log.info(`Applying skill ${skillName} to ${runtimeName}`);
|
|
1316
1763
|
await applier.applySkill(skillName, srcPath);
|
|
1317
1764
|
}
|
|
@@ -1325,8 +1772,10 @@ var IntegrationManager = class {
|
|
|
1325
1772
|
if (skillFailures.length > 0) this.log.warn(`Continuing activation with ${String(skillFailures.length)} failed skill(s): ${skillFailures.join(", ")}`);
|
|
1326
1773
|
let runtimeConfigApplied = false;
|
|
1327
1774
|
if (runtimeConfig && Object.keys(runtimeConfig).length > 0) {
|
|
1328
|
-
const
|
|
1329
|
-
|
|
1775
|
+
const interpolatedConfig = interpolateSelfConfig(runtimeConfig, {
|
|
1776
|
+
...entry.config,
|
|
1777
|
+
...Object.fromEntries(this.secrets.get(integrationId) ?? [])
|
|
1778
|
+
});
|
|
1330
1779
|
this.log.info(`Applying config for ${integrationId} to ${runtimeName}`);
|
|
1331
1780
|
await applier.applyConfig(integrationId, interpolatedConfig);
|
|
1332
1781
|
configApplied = true;
|
|
@@ -1406,7 +1855,8 @@ var IntegrationManager = class {
|
|
|
1406
1855
|
async deactivate(integrationId) {
|
|
1407
1856
|
const entry = this.state.get(integrationId);
|
|
1408
1857
|
if (!entry) return this.err("NOT_FOUND", `Integration "${integrationId}" is not installed`);
|
|
1409
|
-
|
|
1858
|
+
const ownedEntries = this.lockManager.getEntriesForIntegration(integrationId);
|
|
1859
|
+
if (entry.status !== "active" && entry.status !== "error" && Object.keys(ownedEntries).length === 0) return {
|
|
1410
1860
|
ok: true,
|
|
1411
1861
|
payload: {
|
|
1412
1862
|
name: integrationId,
|
|
@@ -1416,16 +1866,18 @@ var IntegrationManager = class {
|
|
|
1416
1866
|
};
|
|
1417
1867
|
this.log.info(`Deactivating integration: ${integrationId}`);
|
|
1418
1868
|
try {
|
|
1419
|
-
const
|
|
1420
|
-
const
|
|
1421
|
-
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)) {
|
|
1422
1872
|
const applier = this.runtimeAppliers.get(runtimeName);
|
|
1423
1873
|
if (!applier) {
|
|
1424
1874
|
this.log.warn(`No applier for runtime "${runtimeName}" — cannot remove entries`);
|
|
1875
|
+
cleanupFailures.push(`runtime:${runtimeName}:missing-applier`);
|
|
1425
1876
|
continue;
|
|
1426
1877
|
}
|
|
1427
1878
|
if (!await applier.isAvailable()) {
|
|
1428
1879
|
this.log.warn(`Runtime "${runtimeName}" is not available — skipping removal`);
|
|
1880
|
+
cleanupFailures.push(`runtime:${runtimeName}:unavailable`);
|
|
1429
1881
|
continue;
|
|
1430
1882
|
}
|
|
1431
1883
|
const keepPlugins = claimedPluginsByRuntime.get(runtimeName) ?? /* @__PURE__ */ new Set();
|
|
@@ -1440,6 +1892,7 @@ var IntegrationManager = class {
|
|
|
1440
1892
|
await applier.removePlugin(plugin.package);
|
|
1441
1893
|
} catch (err) {
|
|
1442
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`);
|
|
1443
1896
|
}
|
|
1444
1897
|
}
|
|
1445
1898
|
for (const skill of entries.skills) {
|
|
@@ -1453,13 +1906,17 @@ var IntegrationManager = class {
|
|
|
1453
1906
|
else await applier.removeSkill(skill.name);
|
|
1454
1907
|
} catch (err) {
|
|
1455
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`);
|
|
1456
1910
|
}
|
|
1457
1911
|
}
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
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
|
+
}
|
|
1463
1920
|
}
|
|
1464
1921
|
}
|
|
1465
1922
|
if (this.mcpApplier) {
|
|
@@ -1468,8 +1925,11 @@ var IntegrationManager = class {
|
|
|
1468
1925
|
await this.mcpApplier.removeForIntegration(integrationId);
|
|
1469
1926
|
} catch (err) {
|
|
1470
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");
|
|
1471
1929
|
}
|
|
1472
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);
|
|
1473
1933
|
this.state.setStatus(integrationId, "configured");
|
|
1474
1934
|
return {
|
|
1475
1935
|
ok: true,
|
|
@@ -1500,7 +1960,10 @@ var IntegrationManager = class {
|
|
|
1500
1960
|
if (!entry) return this.err("NOT_FOUND", `Integration "${name}" is not installed`);
|
|
1501
1961
|
this.log.info(`Uninstalling integration: ${name}`);
|
|
1502
1962
|
try {
|
|
1503
|
-
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
|
+
}
|
|
1504
1967
|
const installPath = this.installer.getInstallPath(name);
|
|
1505
1968
|
let manifest = null;
|
|
1506
1969
|
const manifestPath = join(installPath, "alfe-integration.yaml");
|
|
@@ -1571,7 +2034,7 @@ var IntegrationManager = class {
|
|
|
1571
2034
|
this.log.info(`Reinstalling integration: ${name}`);
|
|
1572
2035
|
try {
|
|
1573
2036
|
const uninstallResult = await this.uninstall({ name });
|
|
1574
|
-
if (!uninstallResult.ok) this.
|
|
2037
|
+
if (!uninstallResult.ok) return this.err("REINSTALL_FAILED", `Could not safely remove the existing integration: ${uninstallResult.error?.message ?? "unknown cleanup failure"}`);
|
|
1575
2038
|
const installResult = await this.install(params);
|
|
1576
2039
|
if (!installResult.ok) return installResult;
|
|
1577
2040
|
return await this.activate(name, { forcePlugins: true });
|
|
@@ -1642,6 +2105,8 @@ var IntegrationManager = class {
|
|
|
1642
2105
|
}
|
|
1643
2106
|
this.log.info(`Upgrading integration: ${name}${version ? `@${version}` : ""}`);
|
|
1644
2107
|
let stagedPath;
|
|
2108
|
+
let newManifest;
|
|
2109
|
+
let preparedUpgradeConfig;
|
|
1645
2110
|
try {
|
|
1646
2111
|
let resolved;
|
|
1647
2112
|
if (customSource) {
|
|
@@ -1653,6 +2118,16 @@ var IntegrationManager = class {
|
|
|
1653
2118
|
}
|
|
1654
2119
|
stagedPath = await this.installer.stage(resolved);
|
|
1655
2120
|
if (customSource) ensureCanonicalManifestName(stagedPath, customSource.manifestPath);
|
|
2121
|
+
const stagedManifestPath = join(stagedPath, "alfe-integration.yaml");
|
|
2122
|
+
if (!existsSync(stagedManifestPath)) throw new Error(`No alfe-integration.yaml found in staged upgrade for "${name}"`);
|
|
2123
|
+
newManifest = parseManifestFile(stagedManifestPath);
|
|
2124
|
+
assertManifestIdentity(name, newManifest, customSource !== void 0);
|
|
2125
|
+
if (config !== void 0) preparedUpgradeConfig = validateAndSplitConfig(newManifest, config, { allowEmpty: true });
|
|
2126
|
+
for (const dep of newManifest.depends_on) {
|
|
2127
|
+
const depState = this.state.get(dep);
|
|
2128
|
+
if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
|
|
2129
|
+
}
|
|
2130
|
+
this.log.info(`Staged upgrade manifest validated: ${newManifest.id}@${newManifest.version}`);
|
|
1656
2131
|
} catch (err) {
|
|
1657
2132
|
const message = err instanceof Error ? err.message : String(err);
|
|
1658
2133
|
this.log.error(`Failed to prepare upgrade for "${name}": ${message}`);
|
|
@@ -1661,21 +2136,7 @@ var IntegrationManager = class {
|
|
|
1661
2136
|
try {
|
|
1662
2137
|
await opts?.onBeforeRuntimeMutation?.();
|
|
1663
2138
|
this.installer.commitStaged(name, stagedPath);
|
|
1664
|
-
|
|
1665
|
-
if (!existsSync(newManifestPath)) throw new Error(`No alfe-integration.yaml found after commit for "${name}"`);
|
|
1666
|
-
const newManifest = parseManifestFile(newManifestPath);
|
|
1667
|
-
this.log.info(`Upgrade manifest validated: ${newManifest.id}@${newManifest.version}`);
|
|
1668
|
-
for (const dep of newManifest.depends_on) {
|
|
1669
|
-
const depState = this.state.get(dep);
|
|
1670
|
-
if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
|
|
1671
|
-
}
|
|
1672
|
-
this.state.update(name, {
|
|
1673
|
-
status: "installed",
|
|
1674
|
-
version: newManifest.version,
|
|
1675
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1676
|
-
config: config ?? existing.config,
|
|
1677
|
-
customConnectionId: customSource?.connectionId ?? existing.customConnectionId
|
|
1678
|
-
});
|
|
2139
|
+
this.log.info(`Upgrade committed: ${newManifest.id}@${newManifest.version}`);
|
|
1679
2140
|
const installHooksSupported = this.manifestSupportsRegisteredRuntime(newManifest);
|
|
1680
2141
|
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(", ")})`);
|
|
1681
2142
|
if (installHooksSupported && newManifest.hooks.pre_install) {
|
|
@@ -1687,13 +2148,21 @@ var IntegrationManager = class {
|
|
|
1687
2148
|
this.log.info(`Running post_install hook: ${newManifest.hooks.post_install}`);
|
|
1688
2149
|
const hookResult = await runHookWithContext(this.installer.getInstallPath(name), newManifest.hooks.post_install, {
|
|
1689
2150
|
integrationName: name,
|
|
1690
|
-
config:
|
|
1691
|
-
secrets: this.secrets.get(name),
|
|
2151
|
+
config: preparedUpgradeConfig?.nonSecretConfig ?? existing.config,
|
|
2152
|
+
secrets: preparedUpgradeConfig?.secretConfig ?? this.secrets.get(name),
|
|
1692
2153
|
runtimes: [...this.runtimeAppliers.keys()]
|
|
1693
2154
|
}, INSTALL_HOOK_TIMEOUT_MS);
|
|
1694
2155
|
if (hookResult.exitCode !== 0) throw new Error(this.hookFailureMessage("post_install hook failed", hookResult));
|
|
1695
2156
|
}
|
|
1696
|
-
await this.applyUpgradeDiffRemovals(name,
|
|
2157
|
+
await this.applyUpgradeDiffRemovals(name, newManifest);
|
|
2158
|
+
this.state.update(name, {
|
|
2159
|
+
status: "installed",
|
|
2160
|
+
version: newManifest.version,
|
|
2161
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2162
|
+
config: preparedUpgradeConfig?.nonSecretConfig ?? existing.config,
|
|
2163
|
+
customConnectionId: customSource?.connectionId ?? existing.customConnectionId
|
|
2164
|
+
});
|
|
2165
|
+
if (preparedUpgradeConfig) this.secrets.set(name, preparedUpgradeConfig.secretConfig);
|
|
1697
2166
|
return await this.activate(name, { forcePlugins: true });
|
|
1698
2167
|
} catch (err) {
|
|
1699
2168
|
try {
|
|
@@ -1805,7 +2274,7 @@ var IntegrationManager = class {
|
|
|
1805
2274
|
commands: commands.map((cmd) => ({
|
|
1806
2275
|
name: cmd.name,
|
|
1807
2276
|
handler: cmd.handler,
|
|
1808
|
-
resolvedPath:
|
|
2277
|
+
resolvedPath: resolveExistingWithin(installPath, cmd.handler, `Command handler ${cmd.name}`, "file"),
|
|
1809
2278
|
method: cmd.method ?? "handle",
|
|
1810
2279
|
timeoutMs: cmd.timeout_ms ?? 3e4,
|
|
1811
2280
|
description: cmd.description
|
|
@@ -1870,9 +2339,7 @@ var IntegrationManager = class {
|
|
|
1870
2339
|
healthy: hookResult.exitCode === 0,
|
|
1871
2340
|
status: entry.status,
|
|
1872
2341
|
version: manifest.version,
|
|
1873
|
-
message: hookResult.exitCode === 0 ? "Healthy" : hookResult.timedOut ? `Health check failed (timed out after ${String(hookResult.timedOutAfterMs ?? "?")}ms)` : `Health check failed (exit ${String(hookResult.exitCode)})
|
|
1874
|
-
stdout: hookResult.stdout || void 0,
|
|
1875
|
-
stderr: hookResult.stderr || void 0
|
|
2342
|
+
message: hookResult.exitCode === 0 ? "Healthy" : hookResult.timedOut ? `Health check failed (timed out after ${String(hookResult.timedOutAfterMs ?? "?")}ms)` : `Health check failed (exit ${String(hookResult.exitCode)})`
|
|
1876
2343
|
};
|
|
1877
2344
|
} catch (err) {
|
|
1878
2345
|
return {
|
|
@@ -1896,13 +2363,13 @@ var IntegrationManager = class {
|
|
|
1896
2363
|
/**
|
|
1897
2364
|
* Build a hook-failure message. A hook we SIGKILLed at its timeout is rendered
|
|
1898
2365
|
* as `(timed out after <ms>ms)` so it is visually distinguishable in Sentry
|
|
1899
|
-
* from a genuine non-zero exit `(exit <code>)
|
|
1900
|
-
*
|
|
2366
|
+
* from a genuine non-zero exit `(exit <code>)`. Captured output is deliberately
|
|
2367
|
+
* excluded: hooks inherit daemon credentials and receive integration secrets,
|
|
2368
|
+
* so stdout/stderr is a secret-bearing channel that must not enter durable
|
|
2369
|
+
* state, logs, or user-facing error messages.
|
|
1901
2370
|
*/
|
|
1902
2371
|
hookFailureMessage(label, hookResult) {
|
|
1903
|
-
|
|
1904
|
-
const output = hookResult.stderr || hookResult.stdout;
|
|
1905
|
-
return output ? `${label} (${reason}): ${output}` : `${label} (${reason})`;
|
|
2372
|
+
return `${label} (${hookResult.timedOut ? `timed out after ${String(hookResult.timedOutAfterMs ?? "?")}ms` : `exit ${String(hookResult.exitCode)}`})`;
|
|
1906
2373
|
}
|
|
1907
2374
|
/**
|
|
1908
2375
|
* Compute the plugin (bare package) names and skill names still claimed by
|
|
@@ -1916,13 +2383,13 @@ var IntegrationManager = class {
|
|
|
1916
2383
|
* integrations pinning the same plugin at different versions still keep the
|
|
1917
2384
|
* one file-system install alive when only one is removed.
|
|
1918
2385
|
*/
|
|
1919
|
-
claimedEntriesByRuntime() {
|
|
2386
|
+
claimedEntriesByRuntime(excludeIntegrationId) {
|
|
1920
2387
|
const remaining = this.lockManager.read();
|
|
1921
2388
|
const plugins = /* @__PURE__ */ new Map();
|
|
1922
2389
|
const skills = /* @__PURE__ */ new Map();
|
|
1923
2390
|
for (const [rtName, state] of Object.entries(remaining.runtimes)) {
|
|
1924
|
-
plugins.set(rtName, new Set(state.plugins.map((
|
|
1925
|
-
skills.set(rtName, new Set(state.skills.map((
|
|
2391
|
+
plugins.set(rtName, new Set(state.plugins.filter((entry) => entry.sourceIntegration !== excludeIntegrationId).map((entry) => stripPluginVersion(entry.package))));
|
|
2392
|
+
skills.set(rtName, new Set(state.skills.filter((entry) => entry.sourceIntegration !== excludeIntegrationId).map((entry) => entry.name)));
|
|
1926
2393
|
}
|
|
1927
2394
|
return {
|
|
1928
2395
|
plugins,
|
|
@@ -1935,24 +2402,27 @@ var IntegrationManager = class {
|
|
|
1935
2402
|
* (and everything a sibling integration still claims) in place. Phase 4's
|
|
1936
2403
|
* `activate` re-applies and re-locks the new set immediately after.
|
|
1937
2404
|
*
|
|
1938
|
-
*
|
|
1939
|
-
*
|
|
1940
|
-
*
|
|
2405
|
+
* The existing lock remains intact until every physical removal and MCP prune
|
|
2406
|
+
* succeeds. This makes partial cleanup retryable; only then are the old rows
|
|
2407
|
+
* cleared so activate() can record the new manifest's contributions.
|
|
1941
2408
|
*/
|
|
1942
|
-
async applyUpgradeDiffRemovals(integrationId,
|
|
1943
|
-
const
|
|
1944
|
-
const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime();
|
|
1945
|
-
|
|
2409
|
+
async applyUpgradeDiffRemovals(integrationId, newManifest) {
|
|
2410
|
+
const ownedEntries = this.lockManager.getEntriesForIntegration(integrationId);
|
|
2411
|
+
const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime(integrationId);
|
|
2412
|
+
const cleanupFailures = [];
|
|
2413
|
+
for (const [runtimeName, entries] of Object.entries(ownedEntries)) {
|
|
1946
2414
|
const applier = this.runtimeAppliers.get(runtimeName);
|
|
1947
2415
|
if (!applier) {
|
|
1948
2416
|
this.log.warn(`No applier for runtime "${runtimeName}" — cannot diff-remove entries`);
|
|
2417
|
+
cleanupFailures.push(`runtime:${runtimeName}:missing-applier`);
|
|
1949
2418
|
continue;
|
|
1950
2419
|
}
|
|
1951
2420
|
if (!await applier.isAvailable()) {
|
|
1952
2421
|
this.log.warn(`Runtime "${runtimeName}" is not available — skipping upgrade diff removal`);
|
|
2422
|
+
cleanupFailures.push(`runtime:${runtimeName}:unavailable`);
|
|
1953
2423
|
continue;
|
|
1954
2424
|
}
|
|
1955
|
-
const { plugins: newPlugins, skills: newSkills
|
|
2425
|
+
const { plugins: newPlugins, skills: newSkills } = resolveInstallsForRuntime(newManifest, runtimeName);
|
|
1956
2426
|
const keepPluginsNew = new Set(newPlugins.map((p) => stripPluginVersion(p.package)));
|
|
1957
2427
|
const keepSkillsNew = new Set(newSkills.map((s) => s.clawhub ?? s.path?.split("/").pop() ?? "unknown"));
|
|
1958
2428
|
const keepPluginsOther = claimedPluginsByRuntime.get(runtimeName) ?? /* @__PURE__ */ new Set();
|
|
@@ -1965,6 +2435,7 @@ var IntegrationManager = class {
|
|
|
1965
2435
|
await applier.removePlugin(plugin.package);
|
|
1966
2436
|
} catch (err) {
|
|
1967
2437
|
this.log.warn(`Failed to remove dropped plugin ${plugin.package} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2438
|
+
cleanupFailures.push(`runtime:${runtimeName}:plugin`);
|
|
1968
2439
|
}
|
|
1969
2440
|
}
|
|
1970
2441
|
for (const skill of entries.skills) {
|
|
@@ -1975,17 +2446,16 @@ var IntegrationManager = class {
|
|
|
1975
2446
|
else await applier.removeSkill(skill.name);
|
|
1976
2447
|
} catch (err) {
|
|
1977
2448
|
this.log.warn(`Failed to remove dropped skill ${skill.name} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2449
|
+
cleanupFailures.push(`runtime:${runtimeName}:skill`);
|
|
1978
2450
|
}
|
|
1979
2451
|
}
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
const newHasConfig = Boolean(newRuntimeConfig && Object.keys(newRuntimeConfig).length > 0);
|
|
1983
|
-
if (oldHadConfig && !newHasConfig) {
|
|
1984
|
-
this.log.info(`Upgrade: removing gone config for ${integrationId} from ${runtimeName}`);
|
|
2452
|
+
if (entries.config.length > 0) {
|
|
2453
|
+
this.log.info(`Upgrade: removing prior config for ${integrationId} from ${runtimeName}`);
|
|
1985
2454
|
try {
|
|
1986
2455
|
await applier.removeConfig(integrationId);
|
|
1987
2456
|
} catch (err) {
|
|
1988
|
-
this.log.warn(`Failed to remove
|
|
2457
|
+
this.log.warn(`Failed to remove prior config for ${integrationId} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2458
|
+
cleanupFailures.push(`runtime:${runtimeName}:config`);
|
|
1989
2459
|
}
|
|
1990
2460
|
}
|
|
1991
2461
|
}
|
|
@@ -1996,8 +2466,11 @@ var IntegrationManager = class {
|
|
|
1996
2466
|
await this.mcpApplier.pruneForIntegration(integrationId, keepIds);
|
|
1997
2467
|
} catch (err) {
|
|
1998
2468
|
this.log.warn(`Failed to prune MCP servers for ${integrationId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2469
|
+
cleanupFailures.push("mcp");
|
|
1999
2470
|
}
|
|
2000
2471
|
}
|
|
2472
|
+
if (cleanupFailures.length > 0) throw new Error(`Failed to remove ${String(cleanupFailures.length)} superseded integration contribution(s); ownership lock retained for retry`);
|
|
2473
|
+
this.lockManager.removeEntries(integrationId);
|
|
2001
2474
|
}
|
|
2002
2475
|
/**
|
|
2003
2476
|
* True when the manifest's `supported_agents` (if declared) intersects the
|
|
@@ -2223,6 +2696,11 @@ const INVALID_PARTIAL_PROVIDER_PHRASE = "custom model providers must declare";
|
|
|
2223
2696
|
function isInvalidPartialProviderUnset(err) {
|
|
2224
2697
|
return (err instanceof Error ? err.message : String(err)).toLowerCase().includes(INVALID_PARTIAL_PROVIDER_PHRASE);
|
|
2225
2698
|
}
|
|
2699
|
+
/** An already-absent config path is the only safe idempotent unset failure. */
|
|
2700
|
+
function isMissingConfigPathError(err) {
|
|
2701
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2702
|
+
return /config path not found/i.test(message);
|
|
2703
|
+
}
|
|
2226
2704
|
const delay$1 = (ms) => new Promise((resolve) => {
|
|
2227
2705
|
setTimeout(resolve, ms);
|
|
2228
2706
|
});
|
|
@@ -2359,16 +2837,26 @@ function configSetErrorMessage(err, args) {
|
|
|
2359
2837
|
}
|
|
2360
2838
|
return `${target} failed: ${String(err)}`;
|
|
2361
2839
|
}
|
|
2362
|
-
async function readParentObject(parentPath) {
|
|
2840
|
+
async function readParentObject(parentPath, options = {}) {
|
|
2363
2841
|
try {
|
|
2364
2842
|
const { stdout } = await execFileAsync$1("openclaw", [
|
|
2365
2843
|
"config",
|
|
2366
2844
|
"get",
|
|
2367
2845
|
parentPath
|
|
2368
2846
|
], { timeout: 1e4 });
|
|
2369
|
-
const
|
|
2847
|
+
const trimmed = stdout.trim();
|
|
2848
|
+
if (trimmed === "") return {};
|
|
2849
|
+
const parsed = JSON.parse(trimmed);
|
|
2370
2850
|
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
2371
|
-
|
|
2851
|
+
if (parsed === null) return {};
|
|
2852
|
+
if (options.failClosed) throw new Error(`OpenClaw config path "${parentPath}" is not an object`);
|
|
2853
|
+
} catch (err) {
|
|
2854
|
+
if (isMissingConfigPathError(err)) return {};
|
|
2855
|
+
if (options.failClosed) {
|
|
2856
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
2857
|
+
throw new Error(`Unable to read OpenClaw config path "${parentPath}" for cleanup: ${message}`);
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2372
2860
|
return {};
|
|
2373
2861
|
}
|
|
2374
2862
|
var OpenClawApplier = class {
|
|
@@ -2860,12 +3348,14 @@ var OpenClawApplier = class {
|
|
|
2860
3348
|
], { timeout: 3e4 });
|
|
2861
3349
|
}
|
|
2862
3350
|
applySkill(name, srcPath) {
|
|
3351
|
+
assertSafePathSegment(name, "Skill name");
|
|
2863
3352
|
if (!existsSync(srcPath)) throw new Error(`Skill source path not found: ${srcPath}`);
|
|
2864
3353
|
mkdirSync(this.skillsDir, { recursive: true });
|
|
2865
3354
|
cpSync(srcPath, join(this.skillsDir, name), { recursive: true });
|
|
2866
3355
|
return Promise.resolve();
|
|
2867
3356
|
}
|
|
2868
3357
|
applyClawHubSkill(slug) {
|
|
3358
|
+
assertSafePathSegment(slug, "ClawHub skill name");
|
|
2869
3359
|
return this.cliLock.run(async () => {
|
|
2870
3360
|
log$5.info({ slug }, "Installing skill from ClawHub");
|
|
2871
3361
|
try {
|
|
@@ -2890,6 +3380,7 @@ var OpenClawApplier = class {
|
|
|
2890
3380
|
});
|
|
2891
3381
|
}
|
|
2892
3382
|
removeClawHubSkill(slug) {
|
|
3383
|
+
assertSafePathSegment(slug, "ClawHub skill name");
|
|
2893
3384
|
const workspaceSkillsDir = join(this.agentWorkspace, "skills", slug);
|
|
2894
3385
|
if (existsSync(workspaceSkillsDir)) {
|
|
2895
3386
|
rmSync(workspaceSkillsDir, {
|
|
@@ -2901,6 +3392,7 @@ var OpenClawApplier = class {
|
|
|
2901
3392
|
return Promise.resolve();
|
|
2902
3393
|
}
|
|
2903
3394
|
removeSkill(name) {
|
|
3395
|
+
assertSafePathSegment(name, "Skill name");
|
|
2904
3396
|
const skillPath = join(this.skillsDir, name);
|
|
2905
3397
|
if (existsSync(skillPath)) rmSync(skillPath, {
|
|
2906
3398
|
recursive: true,
|
|
@@ -2918,8 +3410,9 @@ var OpenClawApplier = class {
|
|
|
2918
3410
|
return this.cliLock.run(() => this.applyConfigLocked(integrationId, config));
|
|
2919
3411
|
}
|
|
2920
3412
|
async applyConfigLocked(integrationId, config) {
|
|
3413
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
2921
3414
|
const tracking = this.readTracking();
|
|
2922
|
-
const integrations = tracking
|
|
3415
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "OpenClaw integration tracking field");
|
|
2923
3416
|
const previous = integrations[integrationId];
|
|
2924
3417
|
const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
|
|
2925
3418
|
if (previous && typeof previous === "object" && !Array.isArray(previous)) {
|
|
@@ -2990,8 +3483,9 @@ var OpenClawApplier = class {
|
|
|
2990
3483
|
return this.cliLock.run(() => this.removeConfigLocked(integrationId));
|
|
2991
3484
|
}
|
|
2992
3485
|
async removeConfigLocked(integrationId) {
|
|
3486
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
2993
3487
|
const tracking = this.readTracking();
|
|
2994
|
-
const integrations = tracking
|
|
3488
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "OpenClaw integration tracking field");
|
|
2995
3489
|
if (!(integrationId in integrations)) return;
|
|
2996
3490
|
const integrationConfig = integrations[integrationId];
|
|
2997
3491
|
const { leaves, subtreesByParent } = partitionEntries(flattenConfig(integrationConfig));
|
|
@@ -3005,15 +3499,16 @@ var OpenClawApplier = class {
|
|
|
3005
3499
|
* Drop a set of dotted keys from a dot-free parent object via read-drop-write,
|
|
3006
3500
|
* UNLOCKED. If the parent becomes empty, `config unset` it; otherwise
|
|
3007
3501
|
* `--replace` the shrunk map (siblings survive because they remain in
|
|
3008
|
-
* `remaining`).
|
|
3009
|
-
*
|
|
3502
|
+
* `remaining`). A proven already-absent path is idempotent success; every
|
|
3503
|
+
* other write failure propagates so callers retain the ownership record for a
|
|
3504
|
+
* later retry. Shared by `removeConfig` (whole-integration teardown) and
|
|
3010
3505
|
* `applyConfig`'s stale-key diff (per-key removal between manifest versions).
|
|
3011
3506
|
*
|
|
3012
3507
|
* Assumes the shared CLI lock is already held by the calling public method —
|
|
3013
3508
|
* the lock is NOT re-entrant, so this stays an `*Unlocked` internal.
|
|
3014
3509
|
*/
|
|
3015
3510
|
async dropSubtreeKeysUnlocked(parentPath, dottedKeys) {
|
|
3016
|
-
const existing = await readParentObject(parentPath);
|
|
3511
|
+
const existing = await readParentObject(parentPath, { failClosed: true });
|
|
3017
3512
|
if (Object.keys(existing).length === 0) return;
|
|
3018
3513
|
const remaining = Object.fromEntries(Object.entries(existing).filter(([k]) => !dottedKeys.has(k)));
|
|
3019
3514
|
try {
|
|
@@ -3024,10 +3519,8 @@ var OpenClawApplier = class {
|
|
|
3024
3519
|
"--replace"
|
|
3025
3520
|
]);
|
|
3026
3521
|
} catch (err) {
|
|
3027
|
-
|
|
3028
|
-
|
|
3029
|
-
parentPath
|
|
3030
|
-
}, "Failed to update parent config during subtree key drop");
|
|
3522
|
+
if (isMissingConfigPathError(err)) return;
|
|
3523
|
+
throw err;
|
|
3031
3524
|
}
|
|
3032
3525
|
}
|
|
3033
3526
|
/**
|
|
@@ -3047,15 +3540,17 @@ var OpenClawApplier = class {
|
|
|
3047
3540
|
* the provider subtree is unset for `models.providers.zhipu.baseUrl`, the
|
|
3048
3541
|
* follow-up `.apiKey` / `.models` leaves skip re-issuing the parent unset.
|
|
3049
3542
|
*
|
|
3050
|
-
*
|
|
3051
|
-
*
|
|
3052
|
-
*
|
|
3053
|
-
* (whole-integration teardown) and
|
|
3543
|
+
* A proven already-absent path is idempotent success; every other failure
|
|
3544
|
+
* propagates so the ownership ledger remains a retry record. Assumes the
|
|
3545
|
+
* shared CLI lock is held — stays an `*Unlocked` internal since the lock is
|
|
3546
|
+
* NOT re-entrant. Shared by `removeConfig` (whole-integration teardown) and
|
|
3547
|
+
* `applyConfig`'s stale-leaf diff.
|
|
3054
3548
|
*/
|
|
3055
3549
|
async unsetLeafPathUnlocked(path, unsetParents) {
|
|
3056
3550
|
try {
|
|
3057
3551
|
await this.runConfigCommandUnlocked(["unset", path]);
|
|
3058
3552
|
} catch (err) {
|
|
3553
|
+
if (isMissingConfigPathError(err)) return;
|
|
3059
3554
|
const parentPath = path.includes(".") ? path.slice(0, path.lastIndexOf(".")) : void 0;
|
|
3060
3555
|
if (parentPath && isInvalidPartialProviderUnset(err)) {
|
|
3061
3556
|
if (unsetParents.has(parentPath)) return;
|
|
@@ -3067,17 +3562,12 @@ var OpenClawApplier = class {
|
|
|
3067
3562
|
parentPath
|
|
3068
3563
|
}, "Leaf unset rejected as an invalid partial custom provider — unset the parent subtree instead");
|
|
3069
3564
|
} catch (parentErr) {
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
parentPath
|
|
3073
|
-
}, "Failed to unset parent subtree after an invalid-partial leaf unset");
|
|
3565
|
+
if (isMissingConfigPathError(parentErr)) return;
|
|
3566
|
+
throw parentErr;
|
|
3074
3567
|
}
|
|
3075
3568
|
return;
|
|
3076
3569
|
}
|
|
3077
|
-
|
|
3078
|
-
err: err instanceof Error ? err.message : String(err),
|
|
3079
|
-
path
|
|
3080
|
-
}, "Failed to unset config via openclaw config unset");
|
|
3570
|
+
throw err;
|
|
3081
3571
|
}
|
|
3082
3572
|
}
|
|
3083
3573
|
/**
|
|
@@ -3137,16 +3627,10 @@ var OpenClawApplier = class {
|
|
|
3137
3627
|
return Promise.resolve(existsSync(this.home));
|
|
3138
3628
|
}
|
|
3139
3629
|
readTracking() {
|
|
3140
|
-
|
|
3141
|
-
try {
|
|
3142
|
-
return JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
3143
|
-
} catch {
|
|
3144
|
-
return {};
|
|
3145
|
-
}
|
|
3630
|
+
return readJsonObjectFileSync(this.trackingPath, "OpenClaw integration tracking file");
|
|
3146
3631
|
}
|
|
3147
3632
|
writeTracking(config) {
|
|
3148
|
-
|
|
3149
|
-
writeFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
3633
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n");
|
|
3150
3634
|
}
|
|
3151
3635
|
};
|
|
3152
3636
|
//#endregion
|
|
@@ -3259,22 +3743,16 @@ var HermesApplier = class {
|
|
|
3259
3743
|
* be cleanly removed later (mirrors OpenClawApplier's `_integrations`).
|
|
3260
3744
|
*/
|
|
3261
3745
|
async applyConfig(integrationId, config) {
|
|
3746
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3262
3747
|
const tracking = this.readTracking();
|
|
3263
|
-
const integrations = tracking
|
|
3748
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "Hermes integration tracking field");
|
|
3264
3749
|
const previous = integrations[integrationId];
|
|
3265
3750
|
const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
|
|
3266
3751
|
if (previous && typeof previous === "object" && !Array.isArray(previous)) {
|
|
3267
3752
|
const prev = partitionEntries(flattenConfig(previous));
|
|
3268
3753
|
const nextLeafPaths = new Set(leaves.map((l) => l.path));
|
|
3269
3754
|
const staleLeafPaths = prev.leaves.filter((l) => !nextLeafPaths.has(l.path)).map((l) => l.path);
|
|
3270
|
-
if (staleLeafPaths.length > 0)
|
|
3271
|
-
await this.deleteConfigKeys(staleLeafPaths);
|
|
3272
|
-
} catch (err) {
|
|
3273
|
-
log$4.warn({
|
|
3274
|
-
err: err instanceof Error ? err.message : String(err),
|
|
3275
|
-
integrationId
|
|
3276
|
-
}, "Failed to delete stale Hermes config keys during applyConfig diff");
|
|
3277
|
-
}
|
|
3755
|
+
if (staleLeafPaths.length > 0) await this.deleteConfigKeys(staleLeafPaths);
|
|
3278
3756
|
const staleSubtreeParents = [...prev.subtreesByParent].filter(([parent, prevKvs]) => {
|
|
3279
3757
|
const nextKvs = subtreesByParent.get(parent);
|
|
3280
3758
|
return [...prevKvs.keys()].some((k) => !nextKvs?.has(k));
|
|
@@ -3309,8 +3787,9 @@ var HermesApplier = class {
|
|
|
3309
3787
|
* `hermes config unset` verb — see the file header). Clears the tracking entry.
|
|
3310
3788
|
*/
|
|
3311
3789
|
async removeConfig(integrationId) {
|
|
3790
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3312
3791
|
const tracking = this.readTracking();
|
|
3313
|
-
const integrations = tracking
|
|
3792
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "Hermes integration tracking field");
|
|
3314
3793
|
if (!(integrationId in integrations)) return;
|
|
3315
3794
|
const integrationConfig = integrations[integrationId];
|
|
3316
3795
|
const { leaves } = partitionEntries(flattenConfig(integrationConfig));
|
|
@@ -3321,6 +3800,7 @@ var HermesApplier = class {
|
|
|
3321
3800
|
err: err instanceof Error ? err.message : String(err),
|
|
3322
3801
|
integrationId
|
|
3323
3802
|
}, "Failed to delete Alfe config keys from ~/.hermes/config.yaml");
|
|
3803
|
+
throw err;
|
|
3324
3804
|
}
|
|
3325
3805
|
tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
|
|
3326
3806
|
this.writeTracking(tracking);
|
|
@@ -3461,10 +3941,11 @@ var HermesApplier = class {
|
|
|
3461
3941
|
deleteConfigKeysSync(paths) {
|
|
3462
3942
|
if (paths.length === 0 || !existsSync(this.configYamlPath)) return;
|
|
3463
3943
|
const doc = parseDocument(readFileSync(this.configYamlPath, "utf-8"));
|
|
3944
|
+
if (doc.errors.length > 0) throw new Error("Hermes config.yaml is invalid; refusing destructive config cleanup");
|
|
3464
3945
|
const before = doc.toString();
|
|
3465
3946
|
for (const path of paths) doc.deleteIn(path.split("."));
|
|
3466
3947
|
const after = doc.toString();
|
|
3467
|
-
if (before !== after)
|
|
3948
|
+
if (before !== after) atomicWriteFileSync(this.configYamlPath, after);
|
|
3468
3949
|
}
|
|
3469
3950
|
/**
|
|
3470
3951
|
* Run `hermes config <args>` (only `set` is used — removal goes via
|
|
@@ -3490,16 +3971,10 @@ var HermesApplier = class {
|
|
|
3490
3971
|
return result;
|
|
3491
3972
|
}
|
|
3492
3973
|
readTracking() {
|
|
3493
|
-
|
|
3494
|
-
try {
|
|
3495
|
-
return JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
3496
|
-
} catch {
|
|
3497
|
-
return {};
|
|
3498
|
-
}
|
|
3974
|
+
return readJsonObjectFileSync(this.trackingPath, "Hermes integration tracking file");
|
|
3499
3975
|
}
|
|
3500
3976
|
writeTracking(config) {
|
|
3501
|
-
|
|
3502
|
-
writeFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
3977
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n");
|
|
3503
3978
|
}
|
|
3504
3979
|
};
|
|
3505
3980
|
//#endregion
|
|
@@ -3602,8 +4077,8 @@ var HermesMcpSync = class {
|
|
|
3602
4077
|
*/
|
|
3603
4078
|
start() {
|
|
3604
4079
|
if (this.started) return;
|
|
3605
|
-
this.started = true;
|
|
3606
4080
|
this.loadSyncedIds();
|
|
4081
|
+
this.started = true;
|
|
3607
4082
|
this.syncOnce().catch((err) => {
|
|
3608
4083
|
log$3.warn({ err: errMsg$2(err) }, "Hermes MCP sync: initial sync failed");
|
|
3609
4084
|
});
|
|
@@ -3658,14 +4133,14 @@ var HermesMcpSync = class {
|
|
|
3658
4133
|
const desired = this.computeDesired();
|
|
3659
4134
|
const desiredIds = new Set(desired.keys());
|
|
3660
4135
|
const doc = parseDocument(existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "");
|
|
4136
|
+
if (doc.errors.length > 0) throw new Error("Hermes config.yaml is invalid; refusing MCP reconciliation");
|
|
3661
4137
|
const before = doc.toString();
|
|
3662
4138
|
for (const [id, entry] of desired) doc.setIn(["mcp_servers", id], entry);
|
|
3663
4139
|
for (const id of this.syncedIds) if (!desiredIds.has(id)) doc.deleteIn(["mcp_servers", id]);
|
|
3664
4140
|
const after = doc.toString();
|
|
3665
4141
|
let changed = before !== after;
|
|
3666
4142
|
if (changed) {
|
|
3667
|
-
|
|
3668
|
-
writeFileSync(this.configPath, after, "utf-8");
|
|
4143
|
+
atomicWriteFileSync(this.configPath, after);
|
|
3669
4144
|
log$3.info({
|
|
3670
4145
|
added: [...desiredIds],
|
|
3671
4146
|
removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
|
|
@@ -3702,7 +4177,7 @@ var HermesMcpSync = class {
|
|
|
3702
4177
|
}
|
|
3703
4178
|
withAlfeApiKey(env) {
|
|
3704
4179
|
const merged = { ...env ?? {} };
|
|
3705
|
-
|
|
4180
|
+
merged.ALFE_API_KEY = ALFE_API_KEY_ENV_REF;
|
|
3706
4181
|
return merged;
|
|
3707
4182
|
}
|
|
3708
4183
|
/** Read-merge-write `~/.hermes/.env` to set ALFE_API_KEY without clobbering other keys. */
|
|
@@ -3717,17 +4192,15 @@ var HermesMcpSync = class {
|
|
|
3717
4192
|
if (!existsSync(this.trackingPath)) return;
|
|
3718
4193
|
try {
|
|
3719
4194
|
const data = JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
3720
|
-
if (Array.isArray(data.syncedIds)
|
|
3721
|
-
|
|
3722
|
-
}
|
|
3723
|
-
persistSyncedIds() {
|
|
3724
|
-
try {
|
|
3725
|
-
mkdirSync(dirname(this.trackingPath), { recursive: true });
|
|
3726
|
-
writeFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n", "utf-8");
|
|
4195
|
+
if (!Array.isArray(data.syncedIds) || data.syncedIds.some((value) => typeof value !== "string")) throw new Error("invalid syncedIds shape");
|
|
4196
|
+
this.syncedIds = new Set(data.syncedIds);
|
|
3727
4197
|
} catch (err) {
|
|
3728
|
-
|
|
4198
|
+
throw new Error(`Hermes MCP ownership sidecar is unreadable; refusing reconciliation: ${errMsg$2(err)}`);
|
|
3729
4199
|
}
|
|
3730
4200
|
}
|
|
4201
|
+
persistSyncedIds() {
|
|
4202
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n");
|
|
4203
|
+
}
|
|
3731
4204
|
};
|
|
3732
4205
|
/**
|
|
3733
4206
|
* Set `KEY=value` in a `.env`-style file, preserving every other line (comments,
|
|
@@ -3736,6 +4209,8 @@ var HermesMcpSync = class {
|
|
|
3736
4209
|
* `~/.hermes/.env` ever holds.
|
|
3737
4210
|
*/
|
|
3738
4211
|
function upsertEnvVar(envPath, key, value) {
|
|
4212
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) throw new Error("Environment variable key is invalid");
|
|
4213
|
+
if (value.includes("\n") || value.includes("\r") || value.includes("\0")) throw new Error(`Environment variable ${key} contains an invalid control character`);
|
|
3739
4214
|
const desiredLine = `${key}=${value}`;
|
|
3740
4215
|
const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
|
|
3741
4216
|
const lines = existing.length > 0 ? existing.replace(/\n$/, "").split("\n") : [];
|
|
@@ -3750,10 +4225,7 @@ function upsertEnvVar(envPath, key, value) {
|
|
|
3750
4225
|
changed = lines[idx] !== desiredLine;
|
|
3751
4226
|
out = lines.map((line, i) => i === idx ? desiredLine : line);
|
|
3752
4227
|
}
|
|
3753
|
-
if (changed)
|
|
3754
|
-
mkdirSync(dirname(envPath), { recursive: true });
|
|
3755
|
-
writeFileSync(envPath, out.join("\n") + "\n", "utf-8");
|
|
3756
|
-
}
|
|
4228
|
+
if (changed) atomicWriteFileSync(envPath, out.join("\n") + "\n");
|
|
3757
4229
|
return changed;
|
|
3758
4230
|
}
|
|
3759
4231
|
function errMsg$2(err) {
|
|
@@ -3823,8 +4295,9 @@ var ClaudeCodeApplier = class {
|
|
|
3823
4295
|
* replace, no stale-key diff needed since nothing is applied to a runtime).
|
|
3824
4296
|
*/
|
|
3825
4297
|
applyConfig(integrationId, config) {
|
|
4298
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3826
4299
|
const ledger = this.readLedger();
|
|
3827
|
-
const integrations = ledger
|
|
4300
|
+
const integrations = getOptionalJsonObjectField(ledger, "_integrations", "Claude Code integration ledger field");
|
|
3828
4301
|
integrations[integrationId] = config;
|
|
3829
4302
|
ledger._integrations = integrations;
|
|
3830
4303
|
this.writeLedger(ledger);
|
|
@@ -3833,8 +4306,9 @@ var ClaudeCodeApplier = class {
|
|
|
3833
4306
|
}
|
|
3834
4307
|
/** Clear an integration's config contribution from the ledger. */
|
|
3835
4308
|
removeConfig(integrationId) {
|
|
4309
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3836
4310
|
const ledger = this.readLedger();
|
|
3837
|
-
const integrations = ledger
|
|
4311
|
+
const integrations = getOptionalJsonObjectField(ledger, "_integrations", "Claude Code integration ledger field");
|
|
3838
4312
|
if (!(integrationId in integrations)) return Promise.resolve();
|
|
3839
4313
|
ledger._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
|
|
3840
4314
|
this.writeLedger(ledger);
|
|
@@ -3852,8 +4326,9 @@ var ClaudeCodeApplier = class {
|
|
|
3852
4326
|
* `getConfigRaw`.
|
|
3853
4327
|
*/
|
|
3854
4328
|
setConfigRaw(key, value) {
|
|
4329
|
+
assertSafeRecordKey(key, "Raw config key");
|
|
3855
4330
|
const ledger = this.readLedger();
|
|
3856
|
-
const raw = ledger
|
|
4331
|
+
const raw = getOptionalJsonObjectField(ledger, "_raw", "Claude Code raw-config ledger field");
|
|
3857
4332
|
raw[key] = value;
|
|
3858
4333
|
ledger._raw = raw;
|
|
3859
4334
|
this.writeLedger(ledger);
|
|
@@ -3867,7 +4342,8 @@ var ClaudeCodeApplier = class {
|
|
|
3867
4342
|
*/
|
|
3868
4343
|
getConfigRaw(key) {
|
|
3869
4344
|
try {
|
|
3870
|
-
|
|
4345
|
+
assertSafeRecordKey(key, "Raw config key");
|
|
4346
|
+
const value = getOptionalJsonObjectField(this.readLedger(), "_raw", "Claude Code raw-config ledger field")[key];
|
|
3871
4347
|
return Promise.resolve(typeof value === "string" ? value : void 0);
|
|
3872
4348
|
} catch {
|
|
3873
4349
|
return Promise.resolve(void 0);
|
|
@@ -3920,6 +4396,7 @@ var ClaudeCodeApplier = class {
|
|
|
3920
4396
|
* clean no-op. We never throw for a claude-code ClawHub skill.
|
|
3921
4397
|
*/
|
|
3922
4398
|
applyClawHubSkill(slug) {
|
|
4399
|
+
assertSafePathSegment(slug, "ClawHub skill name");
|
|
3923
4400
|
log$2.info({ slug }, "Claude Code applyClawHubSkill: no claude-code ClawHub fetch — skipping (native skill dirs land via applySkill)");
|
|
3924
4401
|
return Promise.resolve();
|
|
3925
4402
|
}
|
|
@@ -3931,6 +4408,7 @@ var ClaudeCodeApplier = class {
|
|
|
3931
4408
|
return Promise.resolve(existsSync(this.home));
|
|
3932
4409
|
}
|
|
3933
4410
|
copySkillDir(name, srcPath) {
|
|
4411
|
+
assertSafePathSegment(name, "Skill name");
|
|
3934
4412
|
if (!existsSync(srcPath)) {
|
|
3935
4413
|
log$2.warn({
|
|
3936
4414
|
name,
|
|
@@ -3962,6 +4440,7 @@ var ClaudeCodeApplier = class {
|
|
|
3962
4440
|
return Promise.resolve();
|
|
3963
4441
|
}
|
|
3964
4442
|
deleteSkillDir(name) {
|
|
4443
|
+
assertSafePathSegment(name, "Skill name");
|
|
3965
4444
|
const dest = join(this.skillsDir, name);
|
|
3966
4445
|
try {
|
|
3967
4446
|
rmSync(dest, {
|
|
@@ -3973,25 +4452,21 @@ var ClaudeCodeApplier = class {
|
|
|
3973
4452
|
dest
|
|
3974
4453
|
}, "Claude Code removeSkill: removed skill from ~/.claude/skills");
|
|
3975
4454
|
} catch (err) {
|
|
3976
|
-
|
|
4455
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4456
|
+
log$2.error({
|
|
3977
4457
|
name,
|
|
3978
4458
|
dest,
|
|
3979
|
-
err:
|
|
4459
|
+
err: message
|
|
3980
4460
|
}, "Claude Code removeSkill: failed to remove skill directory");
|
|
4461
|
+
throw new Error(`Failed to remove Claude Code skill ${name}: ${message}`);
|
|
3981
4462
|
}
|
|
3982
4463
|
return Promise.resolve();
|
|
3983
4464
|
}
|
|
3984
4465
|
readLedger() {
|
|
3985
|
-
|
|
3986
|
-
try {
|
|
3987
|
-
return JSON.parse(readFileSync(this.ledgerPath, "utf-8"));
|
|
3988
|
-
} catch {
|
|
3989
|
-
return {};
|
|
3990
|
-
}
|
|
4466
|
+
return readJsonObjectFileSync(this.ledgerPath, "Claude Code integration ledger");
|
|
3991
4467
|
}
|
|
3992
4468
|
writeLedger(ledger) {
|
|
3993
|
-
|
|
3994
|
-
writeFileSync(this.ledgerPath, JSON.stringify(ledger, null, 2) + "\n", "utf-8");
|
|
4469
|
+
atomicWriteFileSync(this.ledgerPath, JSON.stringify(ledger, null, 2) + "\n");
|
|
3995
4470
|
}
|
|
3996
4471
|
};
|
|
3997
4472
|
//#endregion
|
|
@@ -4157,12 +4632,7 @@ var ClaudeCodeMcpSync = class {
|
|
|
4157
4632
|
const file = { mcpServers: Object.fromEntries(desired) };
|
|
4158
4633
|
const next = JSON.stringify(file, null, 2) + "\n";
|
|
4159
4634
|
if ((existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "") !== next) {
|
|
4160
|
-
|
|
4161
|
-
writeFileSync(this.configPath, next, {
|
|
4162
|
-
encoding: "utf-8",
|
|
4163
|
-
mode: 384
|
|
4164
|
-
});
|
|
4165
|
-
chmodSync(this.configPath, 384);
|
|
4635
|
+
atomicWriteFileSync(this.configPath, next);
|
|
4166
4636
|
log$1.info({
|
|
4167
4637
|
added: [...desiredIds],
|
|
4168
4638
|
removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
|
|
@@ -4202,8 +4672,8 @@ var ClaudeCodeMcpSync = class {
|
|
|
4202
4672
|
}
|
|
4203
4673
|
withAlfeApiKey(env) {
|
|
4204
4674
|
const merged = { ...env ?? {} };
|
|
4205
|
-
if ("ALFE_API_KEY" in merged) return merged;
|
|
4206
4675
|
if (!this.apiKey) {
|
|
4676
|
+
delete merged.ALFE_API_KEY;
|
|
4207
4677
|
log$1.warn("Claude Code MCP sync: no ALFE_API_KEY available — generated MCP servers will start in zero-accounts degraded mode");
|
|
4208
4678
|
return merged;
|
|
4209
4679
|
}
|
|
@@ -4219,8 +4689,7 @@ var ClaudeCodeMcpSync = class {
|
|
|
4219
4689
|
}
|
|
4220
4690
|
persistSyncedIds() {
|
|
4221
4691
|
try {
|
|
4222
|
-
|
|
4223
|
-
writeFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n", "utf-8");
|
|
4692
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n");
|
|
4224
4693
|
} catch (err) {
|
|
4225
4694
|
log$1.warn({ err: errMsg$1(err) }, "Claude Code MCP sync: failed to persist synced-id sidecar");
|
|
4226
4695
|
}
|
|
@@ -4235,6 +4704,18 @@ const log = createLogger("McpApplier");
|
|
|
4235
4704
|
const CONFIG_TEMPLATE_RE = /\{\{config\.([a-zA-Z0-9_]+)\}\}/g;
|
|
4236
4705
|
const CREDENTIALS_TEMPLATE_RE = /\{\{credentials\.([a-z0-9-]+)\.([a-zA-Z0-9_]+)\}\}/g;
|
|
4237
4706
|
const ALFE_TEMPLATE_RE = /\{\{alfe\.([a-zA-Z0-9_]+)\}\}/g;
|
|
4707
|
+
const RESERVED_RUNTIME_ENV_KEYS = new Set(["ALFE_API_KEY"]);
|
|
4708
|
+
/**
|
|
4709
|
+
* Budget for the activation-time warm of each applied server. Matches the
|
|
4710
|
+
* bundler's default `connectTimeoutMs` so the warm never reports "failed"
|
|
4711
|
+
* for a connect that would still succeed within its own bound. Generous
|
|
4712
|
+
* enough to cover a cold `npx` npm download when the installer's pre-warm
|
|
4713
|
+
* failed; typically the pre-warm makes this a <5s cache hit. The warm is
|
|
4714
|
+
* fire-and-forget (see applyForIntegration), so this budget never extends
|
|
4715
|
+
* the reconcile pass — a warm that fails anyway is picked up by the
|
|
4716
|
+
* bundler's `retryNeverConnected` sweep within a minute.
|
|
4717
|
+
*/
|
|
4718
|
+
const MCP_ACTIVATION_WARM_TIMEOUT_MS = 12e4;
|
|
4238
4719
|
var McpApplier = class {
|
|
4239
4720
|
manager;
|
|
4240
4721
|
credentials;
|
|
@@ -4258,10 +4739,12 @@ var McpApplier = class {
|
|
|
4258
4739
|
async applyForIntegration(integrationId, servers, mergedConfig, opts) {
|
|
4259
4740
|
const owner = `integration:${integrationId}`;
|
|
4260
4741
|
const applied = [];
|
|
4742
|
+
const declaredIds = new Set(servers.map((server) => `${integrationId}-${server.id}`));
|
|
4261
4743
|
for (const server of servers) {
|
|
4262
4744
|
const id = `${integrationId}-${server.id}`;
|
|
4263
4745
|
const envResolved = await this.resolveEnv(server, mergedConfig, opts?.connectionId);
|
|
4264
4746
|
if (envResolved == null) {
|
|
4747
|
+
await this.manager.removeServer(id, { expectedOwner: owner });
|
|
4265
4748
|
log.info({
|
|
4266
4749
|
integrationId,
|
|
4267
4750
|
server: server.id,
|
|
@@ -4275,6 +4758,26 @@ var McpApplier = class {
|
|
|
4275
4758
|
});
|
|
4276
4759
|
applied.push(id);
|
|
4277
4760
|
}
|
|
4761
|
+
for (const { id, entry } of this.manager.listServers()) if (entry.owner === owner && !declaredIds.has(id)) await this.manager.removeServer(id, { expectedOwner: owner });
|
|
4762
|
+
for (const id of applied) this.manager.warmServer(id, MCP_ACTIVATION_WARM_TIMEOUT_MS).then((status) => {
|
|
4763
|
+
if (!status) return;
|
|
4764
|
+
if (status.connected) log.info({
|
|
4765
|
+
integrationId,
|
|
4766
|
+
server: id,
|
|
4767
|
+
toolCount: status.toolCount
|
|
4768
|
+
}, "MCP server warmed on activation");
|
|
4769
|
+
else log.warn({
|
|
4770
|
+
integrationId,
|
|
4771
|
+
server: id,
|
|
4772
|
+
lastError: status.lastError
|
|
4773
|
+
}, "MCP server failed activation warm — retry sweep will re-attempt");
|
|
4774
|
+
}).catch((err) => {
|
|
4775
|
+
log.warn({
|
|
4776
|
+
integrationId,
|
|
4777
|
+
server: id,
|
|
4778
|
+
err: errMsg(err)
|
|
4779
|
+
}, "MCP activation warm threw — retry sweep will re-attempt");
|
|
4780
|
+
});
|
|
4278
4781
|
return applied;
|
|
4279
4782
|
}
|
|
4280
4783
|
/**
|
|
@@ -4331,13 +4834,17 @@ var McpApplier = class {
|
|
|
4331
4834
|
}
|
|
4332
4835
|
const resolved = {};
|
|
4333
4836
|
for (const [key, value] of Object.entries(server.env)) {
|
|
4837
|
+
if (RESERVED_RUNTIME_ENV_KEYS.has(key)) {
|
|
4838
|
+
log.warn({ key }, "Ignoring manifest override of reserved MCP runtime environment key");
|
|
4839
|
+
continue;
|
|
4840
|
+
}
|
|
4334
4841
|
const interpolated = interpolateString(value, mergedConfig, credentialsCache, this.platform);
|
|
4335
|
-
if (
|
|
4842
|
+
if (hasUnresolvedTemplate(interpolated)) {
|
|
4336
4843
|
log.warn({
|
|
4337
4844
|
provider,
|
|
4338
4845
|
connectionId,
|
|
4339
4846
|
key
|
|
4340
|
-
}, "
|
|
4847
|
+
}, "MCP interpolation left a placeholder — skipping registration");
|
|
4341
4848
|
return null;
|
|
4342
4849
|
}
|
|
4343
4850
|
resolved[key] = interpolated;
|
|
@@ -4345,8 +4852,8 @@ var McpApplier = class {
|
|
|
4345
4852
|
return resolved;
|
|
4346
4853
|
}
|
|
4347
4854
|
};
|
|
4348
|
-
function
|
|
4349
|
-
return /\{\{credentials\.[a-z0-9-]+\.[a-zA-Z0-9_]
|
|
4855
|
+
function hasUnresolvedTemplate(value) {
|
|
4856
|
+
return /\{\{(?:config\.[a-zA-Z0-9_]+|credentials\.[a-z0-9-]+\.[a-zA-Z0-9_]+|alfe\.[a-zA-Z0-9_]+)\}\}/.test(value);
|
|
4350
4857
|
}
|
|
4351
4858
|
function interpolateString(value, mergedConfig, credentials, platform) {
|
|
4352
4859
|
let next = value.replace(CONFIG_TEMPLATE_RE, (match, configKey) => {
|