@alfe.ai/integrations 0.6.0 → 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 +39 -23
- package/dist/index.js +686 -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 {
|
|
@@ -165,6 +356,7 @@ const NPX_PREWARM_TIMEOUT_MS = 12e4;
|
|
|
165
356
|
* staging dir for a real integration install.
|
|
166
357
|
*/
|
|
167
358
|
const STAGING_PREFIX = ".staging-";
|
|
359
|
+
const BACKUP_PREFIX = ".backup-";
|
|
168
360
|
/** Shared @alfe.ai packages available to all integration hooks */
|
|
169
361
|
const SHARED_PACKAGES = {
|
|
170
362
|
"@alfe.ai/config": "latest",
|
|
@@ -191,7 +383,10 @@ var Installer = class {
|
|
|
191
383
|
* Get the path where an integration's content lives.
|
|
192
384
|
*/
|
|
193
385
|
getInstallPath(name) {
|
|
194
|
-
|
|
386
|
+
const safeName = assertSafePathSegment(name, "Integration id");
|
|
387
|
+
const installPath = join(this.basePath, safeName);
|
|
388
|
+
this.recoverInterruptedSwap(safeName, installPath);
|
|
389
|
+
return installPath;
|
|
195
390
|
}
|
|
196
391
|
/**
|
|
197
392
|
* Install an integration by cloning its git repo and checking out the pinned commit.
|
|
@@ -205,6 +400,7 @@ var Installer = class {
|
|
|
205
400
|
* it is cleaned up before retrying.
|
|
206
401
|
*/
|
|
207
402
|
async install(resolved) {
|
|
403
|
+
this.validateResolved(resolved);
|
|
208
404
|
const installPath = this.getInstallPath(resolved.id);
|
|
209
405
|
if (existsSync(installPath)) rmSync(installPath, {
|
|
210
406
|
recursive: true,
|
|
@@ -225,6 +421,7 @@ var Installer = class {
|
|
|
225
421
|
try {
|
|
226
422
|
await execFileAsync$2("git", [
|
|
227
423
|
"clone",
|
|
424
|
+
"--",
|
|
228
425
|
resolved.repository,
|
|
229
426
|
installPath
|
|
230
427
|
], { timeout: GIT_TIMEOUT_MS });
|
|
@@ -246,11 +443,12 @@ var Installer = class {
|
|
|
246
443
|
*/
|
|
247
444
|
async cloneAndExtractSubdir(resolved, installPath) {
|
|
248
445
|
if (!resolved.subdir) throw new InstallerError("subdir is required for monorepo extraction");
|
|
249
|
-
const subdir = resolved.subdir;
|
|
446
|
+
const subdir = assertSafeRelativePath(resolved.subdir, "Integration subdir");
|
|
250
447
|
const tempDir = mkdtempSync(join(tmpdir(), `alfe-clone-${resolved.id}-`));
|
|
251
448
|
try {
|
|
252
449
|
await execFileAsync$2("git", [
|
|
253
450
|
"clone",
|
|
451
|
+
"--",
|
|
254
452
|
resolved.repository,
|
|
255
453
|
tempDir
|
|
256
454
|
], { timeout: GIT_TIMEOUT_MS });
|
|
@@ -258,9 +456,8 @@ var Installer = class {
|
|
|
258
456
|
cwd: tempDir,
|
|
259
457
|
timeout: GIT_TIMEOUT_MS
|
|
260
458
|
});
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
cpSync(subdirPath, installPath, { recursive: true });
|
|
459
|
+
if (!existsSync(join(tempDir, subdir))) throw new InstallerError(`Subdir "${subdir}" not found in repo after checkout`);
|
|
460
|
+
cpSync(resolveExistingWithin(tempDir, subdir, "Integration subdir", "directory"), installPath, { recursive: true });
|
|
264
461
|
} catch (err) {
|
|
265
462
|
if (existsSync(installPath)) rmSync(installPath, {
|
|
266
463
|
recursive: true,
|
|
@@ -294,6 +491,7 @@ var Installer = class {
|
|
|
294
491
|
* cleaned up (try/finally) so a failed prepare leaves no partial clone.
|
|
295
492
|
*/
|
|
296
493
|
async stage(resolved) {
|
|
494
|
+
this.validateResolved(resolved);
|
|
297
495
|
mkdirSync(this.basePath, { recursive: true });
|
|
298
496
|
this.sweepStagingDirs();
|
|
299
497
|
const stagingPath = join(this.basePath, `${STAGING_PREFIX}${resolved.id}-${randomBytes(6).toString("hex")}`);
|
|
@@ -317,18 +515,55 @@ var Installer = class {
|
|
|
317
515
|
}
|
|
318
516
|
}
|
|
319
517
|
/**
|
|
320
|
-
* Commit a previously-staged clone
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
*
|
|
518
|
+
* Commit a previously-staged clone with a recoverable same-filesystem swap.
|
|
519
|
+
* The live dir first moves to a deterministic backup, then the staged dir
|
|
520
|
+
* moves into place. A failed second rename restores the prior install; a
|
|
521
|
+
* daemon crash is recovered on the next `getInstallPath()` call.
|
|
324
522
|
*/
|
|
325
523
|
commitStaged(name, stagedPath) {
|
|
326
524
|
const installPath = this.getInstallPath(name);
|
|
327
|
-
|
|
525
|
+
const base = realpathSync(this.basePath);
|
|
526
|
+
const staged = realpathSync(stagedPath);
|
|
527
|
+
const backupPath = join(this.basePath, `${BACKUP_PREFIX}${name}`);
|
|
528
|
+
if (dirname(staged) !== base || !basename(staged).startsWith(`${STAGING_PREFIX}${name}-`)) throw new InstallerError("Refusing to commit a staging directory outside the integrations root");
|
|
529
|
+
if (existsSync(backupPath)) rmSync(backupPath, {
|
|
530
|
+
recursive: true,
|
|
531
|
+
force: true
|
|
532
|
+
});
|
|
533
|
+
const hadLiveInstall = existsSync(installPath);
|
|
534
|
+
if (hadLiveInstall) renameSync(installPath, backupPath);
|
|
535
|
+
try {
|
|
536
|
+
renameSync(staged, installPath);
|
|
537
|
+
} catch (err) {
|
|
538
|
+
if (hadLiveInstall && existsSync(backupPath) && !existsSync(installPath)) renameSync(backupPath, installPath);
|
|
539
|
+
throw err;
|
|
540
|
+
}
|
|
541
|
+
if (existsSync(backupPath)) try {
|
|
542
|
+
rmSync(backupPath, {
|
|
543
|
+
recursive: true,
|
|
544
|
+
force: true
|
|
545
|
+
});
|
|
546
|
+
} catch (err) {
|
|
547
|
+
log$6.warn({
|
|
548
|
+
integrationId: name,
|
|
549
|
+
err: err instanceof Error ? err.message : String(err)
|
|
550
|
+
}, "Upgrade committed but prior install backup could not be removed");
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
/** Restore a live install left in the deterministic backup by a killed swap. */
|
|
554
|
+
recoverInterruptedSwap(name, installPath) {
|
|
555
|
+
if (!existsSync(this.basePath)) return;
|
|
556
|
+
const backupPath = join(this.basePath, `${BACKUP_PREFIX}${name}`);
|
|
557
|
+
if (!existsSync(backupPath)) return;
|
|
558
|
+
if (!existsSync(installPath)) {
|
|
559
|
+
renameSync(backupPath, installPath);
|
|
560
|
+
log$6.warn({ integrationId: name }, "Recovered integration install from interrupted upgrade swap");
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
rmSync(backupPath, {
|
|
328
564
|
recursive: true,
|
|
329
565
|
force: true
|
|
330
566
|
});
|
|
331
|
-
renameSync(stagedPath, installPath);
|
|
332
567
|
}
|
|
333
568
|
/**
|
|
334
569
|
* Remove any orphaned `.staging-*` directories under the base path. Called at
|
|
@@ -359,11 +594,10 @@ var Installer = class {
|
|
|
359
594
|
async update(name, resolved) {
|
|
360
595
|
const installPath = this.getInstallPath(name);
|
|
361
596
|
if (!existsSync(installPath)) throw new InstallerError(`Integration "${name}" is not installed — cannot update`);
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
return this.install(resolved);
|
|
597
|
+
if (resolved.id !== name) throw new InstallerError("Resolved integration id must match the installed integration id");
|
|
598
|
+
const stagedPath = await this.stage(resolved);
|
|
599
|
+
this.commitStaged(name, stagedPath);
|
|
600
|
+
return installPath;
|
|
367
601
|
}
|
|
368
602
|
/**
|
|
369
603
|
* Remove an installed integration.
|
|
@@ -399,7 +633,7 @@ var Installer = class {
|
|
|
399
633
|
type: "module",
|
|
400
634
|
dependencies: { ...SHARED_PACKAGES }
|
|
401
635
|
};
|
|
402
|
-
|
|
636
|
+
atomicWriteFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 2) + "\n");
|
|
403
637
|
log$6.info("Installing shared @alfe.ai packages for integration hooks");
|
|
404
638
|
await this.runNpmInstall(this.basePath);
|
|
405
639
|
this.sharedPackagesReady = true;
|
|
@@ -522,14 +756,20 @@ var Installer = class {
|
|
|
522
756
|
isInstalled(name) {
|
|
523
757
|
return existsSync(this.getInstallPath(name));
|
|
524
758
|
}
|
|
759
|
+
validateResolved(resolved) {
|
|
760
|
+
assertSafePathSegment(resolved.id, "Integration id");
|
|
761
|
+
if (resolved.repository.length === 0 || resolved.repository.startsWith("-") || resolved.repository.includes("\0")) throw new InstallerError("Integration repository is invalid");
|
|
762
|
+
if (!/^[0-9a-f]{6,64}$/i.test(resolved.commit)) throw new InstallerError("Integration commit must be a hexadecimal git object id");
|
|
763
|
+
if (resolved.subdir) assertSafeRelativePath(resolved.subdir, "Integration subdir");
|
|
764
|
+
}
|
|
525
765
|
};
|
|
526
766
|
//#endregion
|
|
527
767
|
//#region src/state.ts
|
|
528
768
|
/**
|
|
529
769
|
* State Manager — manages ~/.alfe/integrations.json.
|
|
530
770
|
*
|
|
531
|
-
*
|
|
532
|
-
*
|
|
771
|
+
* Daemon-owned read-modify-write state with crash-safe atomic replacement.
|
|
772
|
+
* Secrets are NEVER written to this file.
|
|
533
773
|
*/
|
|
534
774
|
const DEFAULT_STATE_PATH = join(homedir(), ".alfe", "integrations.json");
|
|
535
775
|
const EMPTY_STATE = {
|
|
@@ -538,7 +778,6 @@ const EMPTY_STATE = {
|
|
|
538
778
|
};
|
|
539
779
|
var StateManager = class {
|
|
540
780
|
filePath;
|
|
541
|
-
lockHeld = false;
|
|
542
781
|
constructor(filePath) {
|
|
543
782
|
this.filePath = filePath ?? DEFAULT_STATE_PATH;
|
|
544
783
|
}
|
|
@@ -550,27 +789,19 @@ var StateManager = class {
|
|
|
550
789
|
...EMPTY_STATE,
|
|
551
790
|
integrations: {}
|
|
552
791
|
};
|
|
792
|
+
let parsed;
|
|
553
793
|
try {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
...EMPTY_STATE,
|
|
558
|
-
integrations: {}
|
|
559
|
-
};
|
|
560
|
-
return parsed;
|
|
561
|
-
} catch {
|
|
562
|
-
return {
|
|
563
|
-
...EMPTY_STATE,
|
|
564
|
-
integrations: {}
|
|
565
|
-
};
|
|
794
|
+
parsed = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
795
|
+
} catch (err) {
|
|
796
|
+
throw new Error(`Integration state file is unreadable; refusing an empty-state fallback: ${err instanceof Error ? err.message : String(err)}`);
|
|
566
797
|
}
|
|
798
|
+
return validateStateFile(parsed);
|
|
567
799
|
}
|
|
568
800
|
/**
|
|
569
801
|
* Write the state file atomically.
|
|
570
802
|
*/
|
|
571
803
|
write(state) {
|
|
572
|
-
|
|
573
|
-
writeFileSync(this.filePath, JSON.stringify(state, null, 2) + "\n", "utf-8");
|
|
804
|
+
atomicWriteFileSync(this.filePath, JSON.stringify(state, null, 2) + "\n");
|
|
574
805
|
}
|
|
575
806
|
/**
|
|
576
807
|
* Get a specific integration's state.
|
|
@@ -582,6 +813,7 @@ var StateManager = class {
|
|
|
582
813
|
* Set an integration's state (read-modify-write).
|
|
583
814
|
*/
|
|
584
815
|
set(name, entry) {
|
|
816
|
+
assertSafePathSegment(name, "Integration id");
|
|
585
817
|
const state = this.read();
|
|
586
818
|
state.integrations[name] = entry;
|
|
587
819
|
this.write(state);
|
|
@@ -591,7 +823,8 @@ var StateManager = class {
|
|
|
591
823
|
*/
|
|
592
824
|
update(name, partial) {
|
|
593
825
|
const state = this.read();
|
|
594
|
-
|
|
826
|
+
assertSafePathSegment(name, "Integration id");
|
|
827
|
+
if (!Object.hasOwn(state.integrations, name)) throw new Error(`Integration "${name}" not found in state`);
|
|
595
828
|
state.integrations[name] = {
|
|
596
829
|
...state.integrations[name],
|
|
597
830
|
...partial
|
|
@@ -603,7 +836,8 @@ var StateManager = class {
|
|
|
603
836
|
*/
|
|
604
837
|
setStatus(name, status, error) {
|
|
605
838
|
const state = this.read();
|
|
606
|
-
|
|
839
|
+
assertSafePathSegment(name, "Integration id");
|
|
840
|
+
if (!Object.hasOwn(state.integrations, name)) return;
|
|
607
841
|
const existing = state.integrations[name];
|
|
608
842
|
existing.status = status;
|
|
609
843
|
if (error !== void 0) existing.error = error;
|
|
@@ -614,6 +848,7 @@ var StateManager = class {
|
|
|
614
848
|
* Remove an integration from the state file.
|
|
615
849
|
*/
|
|
616
850
|
remove(name) {
|
|
851
|
+
assertSafePathSegment(name, "Integration id");
|
|
617
852
|
const state = this.read();
|
|
618
853
|
state.integrations = Object.fromEntries(Object.entries(state.integrations).filter(([key]) => key !== name));
|
|
619
854
|
this.write(state);
|
|
@@ -624,8 +859,8 @@ var StateManager = class {
|
|
|
624
859
|
list() {
|
|
625
860
|
const state = this.read();
|
|
626
861
|
return Object.entries(state.integrations).map(([id, entry]) => ({
|
|
627
|
-
|
|
628
|
-
|
|
862
|
+
...entry,
|
|
863
|
+
id
|
|
629
864
|
}));
|
|
630
865
|
}
|
|
631
866
|
/**
|
|
@@ -641,7 +876,8 @@ var StateManager = class {
|
|
|
641
876
|
*/
|
|
642
877
|
incrementReinstallAttempts(name) {
|
|
643
878
|
const state = this.read();
|
|
644
|
-
|
|
879
|
+
assertSafePathSegment(name, "Integration id");
|
|
880
|
+
if (!Object.hasOwn(state.integrations, name)) return;
|
|
645
881
|
state.integrations[name].reinstallAttempts = (state.integrations[name].reinstallAttempts ?? 0) + 1;
|
|
646
882
|
this.write(state);
|
|
647
883
|
}
|
|
@@ -650,11 +886,35 @@ var StateManager = class {
|
|
|
650
886
|
*/
|
|
651
887
|
resetReinstallAttempts(name) {
|
|
652
888
|
const state = this.read();
|
|
653
|
-
|
|
889
|
+
assertSafePathSegment(name, "Integration id");
|
|
890
|
+
if (!Object.hasOwn(state.integrations, name)) return;
|
|
654
891
|
state.integrations[name].reinstallAttempts = 0;
|
|
655
892
|
this.write(state);
|
|
656
893
|
}
|
|
657
894
|
};
|
|
895
|
+
const VALID_STATUSES = new Set([
|
|
896
|
+
"installing",
|
|
897
|
+
"installed",
|
|
898
|
+
"configured",
|
|
899
|
+
"active",
|
|
900
|
+
"error"
|
|
901
|
+
]);
|
|
902
|
+
function isRecord$1(value) {
|
|
903
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
904
|
+
}
|
|
905
|
+
function validateStateFile(value) {
|
|
906
|
+
if (!isRecord$1(value) || value.version !== 1 || !isRecord$1(value.integrations)) throw new Error("Integration state file has an unsupported or invalid shape");
|
|
907
|
+
const integrations = Object.create(null);
|
|
908
|
+
for (const [id, rawEntry] of Object.entries(value.integrations)) {
|
|
909
|
+
assertSafePathSegment(id, "Persisted integration id");
|
|
910
|
+
if (!isRecord$1(rawEntry) || typeof rawEntry.status !== "string" || !VALID_STATUSES.has(rawEntry.status) || typeof rawEntry.version !== "string" || typeof rawEntry.installedAt !== "string" || !isRecord$1(rawEntry.config) || rawEntry.error !== void 0 && typeof rawEntry.error !== "string" || rawEntry.reinstallAttempts !== void 0 && (typeof rawEntry.reinstallAttempts !== "number" || !Number.isSafeInteger(rawEntry.reinstallAttempts) || rawEntry.reinstallAttempts < 0) || rawEntry.customConnectionId !== void 0 && typeof rawEntry.customConnectionId !== "string") throw new Error(`Integration state entry "${id}" has an invalid shape`);
|
|
911
|
+
integrations[id] = rawEntry;
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
version: 1,
|
|
915
|
+
integrations
|
|
916
|
+
};
|
|
917
|
+
}
|
|
658
918
|
//#endregion
|
|
659
919
|
//#region src/lock.ts
|
|
660
920
|
/**
|
|
@@ -683,30 +943,20 @@ var LockManager = class {
|
|
|
683
943
|
runtimes: {},
|
|
684
944
|
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
685
945
|
};
|
|
946
|
+
let parsed;
|
|
686
947
|
try {
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
...EMPTY_LOCK,
|
|
691
|
-
runtimes: {},
|
|
692
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
693
|
-
};
|
|
694
|
-
return parsed;
|
|
695
|
-
} catch {
|
|
696
|
-
return {
|
|
697
|
-
...EMPTY_LOCK,
|
|
698
|
-
runtimes: {},
|
|
699
|
-
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
700
|
-
};
|
|
948
|
+
parsed = JSON.parse(readFileSync(this.filePath, "utf-8"));
|
|
949
|
+
} catch (err) {
|
|
950
|
+
throw new Error(`Runtime lock file is unreadable; refusing an empty-lock fallback: ${err instanceof Error ? err.message : String(err)}`);
|
|
701
951
|
}
|
|
952
|
+
return validateRuntimeLock(parsed);
|
|
702
953
|
}
|
|
703
954
|
/**
|
|
704
955
|
* Write the lock file atomically.
|
|
705
956
|
*/
|
|
706
957
|
write(lock) {
|
|
707
|
-
mkdirSync(dirname(this.filePath), { recursive: true });
|
|
708
958
|
lock.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
709
|
-
|
|
959
|
+
atomicWriteFileSync(this.filePath, JSON.stringify(lock, null, 2) + "\n");
|
|
710
960
|
}
|
|
711
961
|
/**
|
|
712
962
|
* Add entries for an integration activation in a specific runtime.
|
|
@@ -716,6 +966,8 @@ var LockManager = class {
|
|
|
716
966
|
* what lets a config-only integration be torn down on deactivate.
|
|
717
967
|
*/
|
|
718
968
|
addEntries(runtime, integrationId, version, plugins, skills, installPath, opts) {
|
|
969
|
+
assertSafePathSegment(runtime, "Runtime id");
|
|
970
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
719
971
|
const lock = this.read();
|
|
720
972
|
if (!(runtime in lock.runtimes)) lock.runtimes[runtime] = {
|
|
721
973
|
plugins: [],
|
|
@@ -748,6 +1000,27 @@ var LockManager = class {
|
|
|
748
1000
|
this.write(lock);
|
|
749
1001
|
}
|
|
750
1002
|
/**
|
|
1003
|
+
* Snapshot an integration's entries without changing the lock. Teardown uses
|
|
1004
|
+
* this first and only clears the entries after every physical removal
|
|
1005
|
+
* succeeds, making a failed cleanup retryable.
|
|
1006
|
+
*/
|
|
1007
|
+
getEntriesForIntegration(integrationId) {
|
|
1008
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
1009
|
+
const lock = this.read();
|
|
1010
|
+
const entries = {};
|
|
1011
|
+
for (const [runtime, state] of Object.entries(lock.runtimes)) {
|
|
1012
|
+
const plugins = state.plugins.filter((entry) => entry.sourceIntegration === integrationId);
|
|
1013
|
+
const skills = state.skills.filter((entry) => entry.sourceIntegration === integrationId);
|
|
1014
|
+
const config = (state.config ?? []).filter((entry) => entry.sourceIntegration === integrationId);
|
|
1015
|
+
if (plugins.length > 0 || skills.length > 0 || config.length > 0) entries[runtime] = {
|
|
1016
|
+
plugins,
|
|
1017
|
+
skills,
|
|
1018
|
+
config
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
return entries;
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
751
1024
|
* Remove all entries for a given integration across all runtimes.
|
|
752
1025
|
* Returns what was removed, keyed by runtime.
|
|
753
1026
|
*
|
|
@@ -756,6 +1029,7 @@ var LockManager = class {
|
|
|
756
1029
|
* `applier.removeConfig` even for a config-only integration.
|
|
757
1030
|
*/
|
|
758
1031
|
removeEntries(integrationId) {
|
|
1032
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
759
1033
|
const lock = this.read();
|
|
760
1034
|
const removed = {};
|
|
761
1035
|
for (const [runtime, state] of Object.entries(lock.runtimes)) {
|
|
@@ -784,6 +1058,49 @@ var LockManager = class {
|
|
|
784
1058
|
};
|
|
785
1059
|
}
|
|
786
1060
|
};
|
|
1061
|
+
function isRecord(value) {
|
|
1062
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1063
|
+
}
|
|
1064
|
+
function hasStringFields(value, fields) {
|
|
1065
|
+
return isRecord(value) && fields.every((field) => typeof value[field] === "string");
|
|
1066
|
+
}
|
|
1067
|
+
function validateRuntimeLock(value) {
|
|
1068
|
+
if (!isRecord(value) || value.version !== 1 || !isRecord(value.runtimes) || typeof value.updatedAt !== "string") throw new Error("Runtime lock file has an unsupported or invalid shape");
|
|
1069
|
+
const runtimes = Object.create(null);
|
|
1070
|
+
for (const [runtime, rawState] of Object.entries(value.runtimes)) {
|
|
1071
|
+
assertSafePathSegment(runtime, "Persisted runtime id");
|
|
1072
|
+
if (!isRecord(rawState) || !Array.isArray(rawState.plugins) || !rawState.plugins.every((entry) => hasStringFields(entry, [
|
|
1073
|
+
"package",
|
|
1074
|
+
"sourceIntegration",
|
|
1075
|
+
"integrationVersion"
|
|
1076
|
+
])) || !Array.isArray(rawState.skills) || !rawState.skills.every((entry) => hasStringFields(entry, [
|
|
1077
|
+
"name",
|
|
1078
|
+
"sourcePath",
|
|
1079
|
+
"sourceIntegration",
|
|
1080
|
+
"integrationVersion"
|
|
1081
|
+
])) || rawState.config !== void 0 && (!Array.isArray(rawState.config) || !rawState.config.every((entry) => hasStringFields(entry, ["sourceIntegration", "integrationVersion"])))) throw new Error(`Runtime lock entry "${runtime}" has an invalid shape`);
|
|
1082
|
+
const state = rawState;
|
|
1083
|
+
for (const plugin of state.plugins) {
|
|
1084
|
+
assertSafePathSegment(plugin.sourceIntegration, "Persisted integration id");
|
|
1085
|
+
if (plugin.package.length === 0 || plugin.package.startsWith("-") || plugin.package.includes("\0") || plugin.integrationVersion.length === 0) throw new Error(`Runtime lock plugin entry in "${runtime}" is invalid`);
|
|
1086
|
+
}
|
|
1087
|
+
for (const skill of state.skills) {
|
|
1088
|
+
assertSafePathSegment(skill.sourceIntegration, "Persisted integration id");
|
|
1089
|
+
assertSafePathSegment(skill.name, "Persisted skill name");
|
|
1090
|
+
if (skill.integrationVersion.length === 0 || skill.sourcePath.includes("\0")) throw new Error(`Runtime lock skill entry in "${runtime}" is invalid`);
|
|
1091
|
+
}
|
|
1092
|
+
for (const config of state.config ?? []) {
|
|
1093
|
+
assertSafePathSegment(config.sourceIntegration, "Persisted integration id");
|
|
1094
|
+
if (config.integrationVersion.length === 0) throw new Error(`Runtime lock config entry in "${runtime}" is invalid`);
|
|
1095
|
+
}
|
|
1096
|
+
runtimes[runtime] = state;
|
|
1097
|
+
}
|
|
1098
|
+
return {
|
|
1099
|
+
version: 1,
|
|
1100
|
+
runtimes,
|
|
1101
|
+
updatedAt: value.updatedAt
|
|
1102
|
+
};
|
|
1103
|
+
}
|
|
787
1104
|
//#endregion
|
|
788
1105
|
//#region src/hooks.ts
|
|
789
1106
|
/**
|
|
@@ -821,6 +1138,7 @@ const HOOK_TIMEOUT_MS = 3e4;
|
|
|
821
1138
|
const INSTALL_HOOK_TIMEOUT_MS = 6e5;
|
|
822
1139
|
const INTEGRATIONS_BASE_DIR = join(homedir(), ".alfe", "integrations");
|
|
823
1140
|
const STATE_BASE_DIR = join(homedir(), ".alfe", "state");
|
|
1141
|
+
const IS_WINDOWS = platform() === "win32";
|
|
824
1142
|
/**
|
|
825
1143
|
* Build the environment variables for a hook script execution.
|
|
826
1144
|
*
|
|
@@ -833,6 +1151,7 @@ const STATE_BASE_DIR = join(homedir(), ".alfe", "state");
|
|
|
833
1151
|
*/
|
|
834
1152
|
function buildHookEnv(options, additionalEnv) {
|
|
835
1153
|
const { integrationName, config, secrets, runtimes } = options;
|
|
1154
|
+
assertSafePathSegment(integrationName, "Integration id");
|
|
836
1155
|
const nameUpper = integrationName.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
837
1156
|
const integrationDir = join(INTEGRATIONS_BASE_DIR, integrationName);
|
|
838
1157
|
const stateDir = join(STATE_BASE_DIR, integrationName);
|
|
@@ -871,12 +1190,13 @@ function buildHookEnv(options, additionalEnv) {
|
|
|
871
1190
|
* 3. Default → "bash"
|
|
872
1191
|
*/
|
|
873
1192
|
function resolveInterpreter(scriptPath) {
|
|
874
|
-
|
|
1193
|
+
let fd;
|
|
875
1194
|
try {
|
|
876
|
-
|
|
1195
|
+
fd = openSync(scriptPath, "r");
|
|
877
1196
|
const buf = Buffer.alloc(256);
|
|
878
1197
|
readSync(fd, buf, 0, 256, 0);
|
|
879
1198
|
closeSync(fd);
|
|
1199
|
+
fd = void 0;
|
|
880
1200
|
const firstLine = buf.toString("utf-8").split("\n")[0];
|
|
881
1201
|
if (firstLine.startsWith("#!")) {
|
|
882
1202
|
const shebang = firstLine.slice(2).trim();
|
|
@@ -888,7 +1208,7 @@ function resolveInterpreter(scriptPath) {
|
|
|
888
1208
|
};
|
|
889
1209
|
}
|
|
890
1210
|
const cmd = shebang.split(/\s+/)[0];
|
|
891
|
-
if (
|
|
1211
|
+
if (IS_WINDOWS && cmd.startsWith("/")) return {
|
|
892
1212
|
command: cmd.split("/").pop() ?? cmd,
|
|
893
1213
|
args: [scriptPath]
|
|
894
1214
|
};
|
|
@@ -897,13 +1217,15 @@ function resolveInterpreter(scriptPath) {
|
|
|
897
1217
|
args: [scriptPath]
|
|
898
1218
|
};
|
|
899
1219
|
}
|
|
900
|
-
} catch {}
|
|
1220
|
+
} catch {} finally {
|
|
1221
|
+
if (fd !== void 0) closeSync(fd);
|
|
1222
|
+
}
|
|
901
1223
|
if (scriptPath.endsWith(".js") || scriptPath.endsWith(".mjs")) return {
|
|
902
1224
|
command: "node",
|
|
903
1225
|
args: [scriptPath]
|
|
904
1226
|
};
|
|
905
1227
|
if (scriptPath.endsWith(".py")) return {
|
|
906
|
-
command:
|
|
1228
|
+
command: IS_WINDOWS ? "python" : "python3",
|
|
907
1229
|
args: [scriptPath]
|
|
908
1230
|
};
|
|
909
1231
|
return {
|
|
@@ -926,13 +1248,17 @@ function resolveInterpreter(scriptPath) {
|
|
|
926
1248
|
* @returns Hook execution result
|
|
927
1249
|
*/
|
|
928
1250
|
async function runHook(integrationPath, hookScript, env, timeoutMs = HOOK_TIMEOUT_MS) {
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1251
|
+
let scriptPath;
|
|
1252
|
+
try {
|
|
1253
|
+
scriptPath = resolveExistingWithin(integrationPath, hookScript, "Integration hook", "file");
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
return {
|
|
1256
|
+
exitCode: 1,
|
|
1257
|
+
stdout: "",
|
|
1258
|
+
stderr: `Invalid integration hook: ${err instanceof Error ? err.message : String(err)}`,
|
|
1259
|
+
timedOut: false
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
936
1262
|
const { command, args } = resolveInterpreter(scriptPath);
|
|
937
1263
|
return new Promise((resolve) => {
|
|
938
1264
|
let stdout = "";
|
|
@@ -948,10 +1274,29 @@ async function runHook(integrationPath, hookScript, env, timeoutMs = HOOK_TIMEOU
|
|
|
948
1274
|
"ignore",
|
|
949
1275
|
"pipe",
|
|
950
1276
|
"pipe"
|
|
951
|
-
]
|
|
1277
|
+
],
|
|
1278
|
+
detached: !IS_WINDOWS
|
|
952
1279
|
});
|
|
953
1280
|
const timer = setTimeout(() => {
|
|
954
1281
|
timedOut = true;
|
|
1282
|
+
if (IS_WINDOWS && proc.pid !== void 0) {
|
|
1283
|
+
spawn("taskkill", [
|
|
1284
|
+
"/PID",
|
|
1285
|
+
String(proc.pid),
|
|
1286
|
+
"/T",
|
|
1287
|
+
"/F"
|
|
1288
|
+
], {
|
|
1289
|
+
stdio: "ignore",
|
|
1290
|
+
windowsHide: true
|
|
1291
|
+
}).once("error", () => {
|
|
1292
|
+
proc.kill("SIGKILL");
|
|
1293
|
+
});
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
if (!IS_WINDOWS && proc.pid !== void 0) try {
|
|
1297
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
1298
|
+
return;
|
|
1299
|
+
} catch {}
|
|
955
1300
|
proc.kill("SIGKILL");
|
|
956
1301
|
}, timeoutMs);
|
|
957
1302
|
proc.stdout.on("data", (data) => {
|
|
@@ -1063,20 +1408,50 @@ function pluginSpecVersion(spec) {
|
|
|
1063
1408
|
const FULL_PLACEHOLDER = /^\{\{config\.([a-zA-Z0-9_]+)\}\}$/;
|
|
1064
1409
|
function interpolateSelfConfig(obj, config) {
|
|
1065
1410
|
const result = {};
|
|
1066
|
-
for (const [key, value] of Object.entries(obj))
|
|
1411
|
+
for (const [key, value] of Object.entries(obj)) result[key] = interpolateSelfConfigValue(value, config);
|
|
1412
|
+
return result;
|
|
1413
|
+
}
|
|
1414
|
+
function interpolateSelfConfigValue(value, config) {
|
|
1415
|
+
if (typeof value === "string") {
|
|
1067
1416
|
const wholeMatch = FULL_PLACEHOLDER.exec(value);
|
|
1068
1417
|
if (wholeMatch) {
|
|
1069
1418
|
const raw = config[wholeMatch[1]];
|
|
1070
|
-
|
|
1071
|
-
continue;
|
|
1419
|
+
return raw !== void 0 ? raw : value;
|
|
1072
1420
|
}
|
|
1073
|
-
|
|
1074
|
-
const
|
|
1075
|
-
return typeof
|
|
1421
|
+
return value.replace(/\{\{config\.([a-zA-Z0-9_]+)\}\}/g, (_match, configKey) => {
|
|
1422
|
+
const candidate = config[configKey];
|
|
1423
|
+
return typeof candidate === "string" || typeof candidate === "number" ? String(candidate) : _match;
|
|
1076
1424
|
});
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
return
|
|
1425
|
+
}
|
|
1426
|
+
if (Array.isArray(value)) return value.map((item) => interpolateSelfConfigValue(item, config));
|
|
1427
|
+
if (value && typeof value === "object") return interpolateSelfConfig(value, config);
|
|
1428
|
+
return value;
|
|
1429
|
+
}
|
|
1430
|
+
var IntegrationConfigValidationError = class extends Error {
|
|
1431
|
+
constructor(code, message) {
|
|
1432
|
+
super(message);
|
|
1433
|
+
this.code = code;
|
|
1434
|
+
this.name = "IntegrationConfigValidationError";
|
|
1435
|
+
}
|
|
1436
|
+
};
|
|
1437
|
+
function validateAndSplitConfig(manifest, config, options = {}) {
|
|
1438
|
+
if (options.allowEmpty && Object.keys(config).length === 0) return {
|
|
1439
|
+
nonSecretConfig: {},
|
|
1440
|
+
secretConfig: /* @__PURE__ */ new Map()
|
|
1441
|
+
};
|
|
1442
|
+
const result = buildConfigValidationSchema(manifest.config_schema).safeParse(config);
|
|
1443
|
+
if (!result.success) throw new IntegrationConfigValidationError("INVALID_CONFIG", `Config validation failed: ${result.error.issues.map((issue) => `${issue.path.join(".")}: ${issue.message}`).join("; ")}`);
|
|
1444
|
+
const validatedConfig = result.data;
|
|
1445
|
+
const missingRequired = manifest.config_schema.filter((field) => field.required && validatedConfig[field.key] === void 0).map((field) => field.key);
|
|
1446
|
+
if (missingRequired.length > 0) throw new IntegrationConfigValidationError("MISSING_CONFIG", `Missing required config fields: ${missingRequired.join(", ")}`);
|
|
1447
|
+
const nonSecretConfig = {};
|
|
1448
|
+
const secretConfig = /* @__PURE__ */ new Map();
|
|
1449
|
+
for (const [key, value] of Object.entries(validatedConfig)) if (manifest.config_schema.find((candidate) => candidate.key === key)?.type === "secret") secretConfig.set(key, value);
|
|
1450
|
+
else nonSecretConfig[key] = value;
|
|
1451
|
+
return {
|
|
1452
|
+
nonSecretConfig,
|
|
1453
|
+
secretConfig
|
|
1454
|
+
};
|
|
1080
1455
|
}
|
|
1081
1456
|
/**
|
|
1082
1457
|
* Merge universal installs with runtime-specific installs from the manifest.
|
|
@@ -1097,8 +1472,17 @@ function resolveInstallsForRuntime(manifest, runtime) {
|
|
|
1097
1472
|
* checkout-able `manifestCommit` pins the clone.
|
|
1098
1473
|
*/
|
|
1099
1474
|
function buildCustomResolved(id, cs) {
|
|
1100
|
-
|
|
1101
|
-
const
|
|
1475
|
+
assertSafePathSegment(id, "Custom integration id");
|
|
1476
|
+
const manifestPath = assertSafeRelativePath(cs.manifestPath, "Custom integration manifest path");
|
|
1477
|
+
let repositoryUrl;
|
|
1478
|
+
try {
|
|
1479
|
+
repositoryUrl = new URL(cs.manifestRepo);
|
|
1480
|
+
} catch {
|
|
1481
|
+
throw new Error("Custom integration repository must be a valid HTTPS URL");
|
|
1482
|
+
}
|
|
1483
|
+
if (repositoryUrl.protocol !== "https:" || repositoryUrl.username !== "" || repositoryUrl.password !== "") throw new Error("Custom integration repository must be an HTTPS URL without embedded credentials");
|
|
1484
|
+
if (!/^[0-9a-f]{40}$/i.test(cs.manifestCommit)) throw new Error("Custom integration commit must be a full 40-character SHA-1");
|
|
1485
|
+
const dir = dirname(manifestPath);
|
|
1102
1486
|
const subdir = dir === "." || dir === "" ? void 0 : dir;
|
|
1103
1487
|
const manifestVersion = cs.manifest?.version;
|
|
1104
1488
|
return {
|
|
@@ -1117,9 +1501,15 @@ function buildCustomResolved(id, cs) {
|
|
|
1117
1501
|
*/
|
|
1118
1502
|
function ensureCanonicalManifestName(installPath, manifestPath) {
|
|
1119
1503
|
const canonical = join(installPath, "alfe-integration.yaml");
|
|
1120
|
-
if (existsSync(canonical))
|
|
1121
|
-
|
|
1122
|
-
|
|
1504
|
+
if (existsSync(canonical)) {
|
|
1505
|
+
resolveExistingWithin(installPath, "alfe-integration.yaml", "Canonical integration manifest", "file");
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
rmSync(canonical, { force: true });
|
|
1509
|
+
copyFileSync(resolveExistingWithin(installPath, basename(manifestPath), "Custom integration manifest", "file"), canonical);
|
|
1510
|
+
}
|
|
1511
|
+
function assertManifestIdentity(installId, manifest, isCustom) {
|
|
1512
|
+
if (!(isCustom ? installId.startsWith(`custom:${manifest.id}@`) : manifest.id === installId)) throw new Error(`Manifest id "${manifest.id}" does not match install id "${installId}"`);
|
|
1123
1513
|
}
|
|
1124
1514
|
var IntegrationManager = class {
|
|
1125
1515
|
log = createLogger("IntegrationManager");
|
|
@@ -1158,6 +1548,11 @@ var IntegrationManager = class {
|
|
|
1158
1548
|
async install(params) {
|
|
1159
1549
|
const { name, version, config, customSource } = params;
|
|
1160
1550
|
if (!name) return this.err("INVALID_PARAMS", "Integration name is required");
|
|
1551
|
+
try {
|
|
1552
|
+
assertSafePathSegment(name, "Integration id");
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
return this.err("INVALID_PARAMS", err instanceof Error ? err.message : String(err));
|
|
1555
|
+
}
|
|
1161
1556
|
const existing = this.state.get(name);
|
|
1162
1557
|
if (existing) {
|
|
1163
1558
|
if (existing.status === "installed" || existing.status === "configured" || existing.status === "active") {
|
|
@@ -1197,7 +1592,10 @@ var IntegrationManager = class {
|
|
|
1197
1592
|
const manifestPath = join(installPath, "alfe-integration.yaml");
|
|
1198
1593
|
if (!existsSync(manifestPath)) throw new Error(`No alfe-integration.yaml found in ${installPath}`);
|
|
1199
1594
|
const manifest = parseManifestFile(manifestPath);
|
|
1595
|
+
assertManifestIdentity(name, manifest, customSource !== void 0);
|
|
1200
1596
|
this.log.info(`Manifest validated: ${manifest.id}@${manifest.version}`);
|
|
1597
|
+
const preparedConfig = validateAndSplitConfig(manifest, config ?? {}, { allowEmpty: true });
|
|
1598
|
+
this.secrets.set(name, preparedConfig.secretConfig);
|
|
1201
1599
|
for (const dep of manifest.depends_on) {
|
|
1202
1600
|
const depState = this.state.get(dep);
|
|
1203
1601
|
if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
|
|
@@ -1213,8 +1611,8 @@ var IntegrationManager = class {
|
|
|
1213
1611
|
this.log.info(`Running post_install hook: ${manifest.hooks.post_install}`);
|
|
1214
1612
|
const hookResult = await runHookWithContext(installPath, manifest.hooks.post_install, {
|
|
1215
1613
|
integrationName: name,
|
|
1216
|
-
config:
|
|
1217
|
-
secrets:
|
|
1614
|
+
config: preparedConfig.nonSecretConfig,
|
|
1615
|
+
secrets: preparedConfig.secretConfig,
|
|
1218
1616
|
runtimes: [...this.runtimeAppliers.keys()]
|
|
1219
1617
|
}, INSTALL_HOOK_TIMEOUT_MS);
|
|
1220
1618
|
if (hookResult.exitCode !== 0) throw new Error(this.hookFailureMessage("post_install hook failed", hookResult));
|
|
@@ -1223,7 +1621,7 @@ var IntegrationManager = class {
|
|
|
1223
1621
|
status: "installed",
|
|
1224
1622
|
version: manifest.version,
|
|
1225
1623
|
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1226
|
-
config:
|
|
1624
|
+
config: preparedConfig.nonSecretConfig,
|
|
1227
1625
|
customConnectionId: customSource?.connectionId
|
|
1228
1626
|
});
|
|
1229
1627
|
this.log.info(`Integration "${name}" installed successfully`);
|
|
@@ -1243,6 +1641,7 @@ var IntegrationManager = class {
|
|
|
1243
1641
|
try {
|
|
1244
1642
|
await this.installer.remove(name);
|
|
1245
1643
|
} catch {}
|
|
1644
|
+
this.secrets.delete(name);
|
|
1246
1645
|
this.state.setStatus(name, "error", message);
|
|
1247
1646
|
return this.err("INSTALL_FAILED", message);
|
|
1248
1647
|
}
|
|
@@ -1263,20 +1662,7 @@ var IntegrationManager = class {
|
|
|
1263
1662
|
if (entry.status !== "installed" && entry.status !== "configured" && entry.status !== "active") return this.err("INVALID_STATE", `Cannot configure integration in "${entry.status}" state`);
|
|
1264
1663
|
this.log.info(`Configuring integration: ${name}`);
|
|
1265
1664
|
try {
|
|
1266
|
-
const
|
|
1267
|
-
if (manifest.config_schema.length > 0) {
|
|
1268
|
-
const result = buildConfigValidationSchema(manifest.config_schema).safeParse(config);
|
|
1269
|
-
if (!result.success) {
|
|
1270
|
-
const issues = result.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ");
|
|
1271
|
-
return this.err("INVALID_CONFIG", `Config validation failed: ${issues}`);
|
|
1272
|
-
}
|
|
1273
|
-
}
|
|
1274
|
-
const missingRequired = manifest.config_schema.filter((f) => f.required && config[f.key] === void 0).map((f) => f.key);
|
|
1275
|
-
if (missingRequired.length > 0) return this.err("MISSING_CONFIG", `Missing required config fields: ${missingRequired.join(", ")}`);
|
|
1276
|
-
const nonSecretConfig = {};
|
|
1277
|
-
const secretConfig = /* @__PURE__ */ new Map();
|
|
1278
|
-
for (const [key, value] of Object.entries(config)) if (manifest.config_schema.find((f) => f.key === key)?.type === "secret") secretConfig.set(key, value);
|
|
1279
|
-
else nonSecretConfig[key] = value;
|
|
1665
|
+
const { nonSecretConfig, secretConfig } = validateAndSplitConfig(parseManifestFile(join(this.installer.getInstallPath(name), "alfe-integration.yaml")), config);
|
|
1280
1666
|
this.state.update(name, {
|
|
1281
1667
|
status: "configured",
|
|
1282
1668
|
config: nonSecretConfig
|
|
@@ -1293,6 +1679,7 @@ var IntegrationManager = class {
|
|
|
1293
1679
|
}
|
|
1294
1680
|
};
|
|
1295
1681
|
} catch (err) {
|
|
1682
|
+
if (err instanceof IntegrationConfigValidationError) return this.err(err.code, err.message);
|
|
1296
1683
|
const message = err instanceof Error ? err.message : String(err);
|
|
1297
1684
|
this.log.error(`Failed to configure "${name}": ${message}`);
|
|
1298
1685
|
return this.err("CONFIGURE_FAILED", message);
|
|
@@ -1371,7 +1758,7 @@ var IntegrationManager = class {
|
|
|
1371
1758
|
await applier.applyClawHubSkill(skill.clawhub);
|
|
1372
1759
|
} else if (skill.path) {
|
|
1373
1760
|
const skillName = skill.path.split("/").pop() ?? skill.path;
|
|
1374
|
-
const srcPath =
|
|
1761
|
+
const srcPath = resolveExistingWithin(installPath, skill.path, `Skill path for ${skillLabel}`, "directory");
|
|
1375
1762
|
this.log.info(`Applying skill ${skillName} to ${runtimeName}`);
|
|
1376
1763
|
await applier.applySkill(skillName, srcPath);
|
|
1377
1764
|
}
|
|
@@ -1385,8 +1772,10 @@ var IntegrationManager = class {
|
|
|
1385
1772
|
if (skillFailures.length > 0) this.log.warn(`Continuing activation with ${String(skillFailures.length)} failed skill(s): ${skillFailures.join(", ")}`);
|
|
1386
1773
|
let runtimeConfigApplied = false;
|
|
1387
1774
|
if (runtimeConfig && Object.keys(runtimeConfig).length > 0) {
|
|
1388
|
-
const
|
|
1389
|
-
|
|
1775
|
+
const interpolatedConfig = interpolateSelfConfig(runtimeConfig, {
|
|
1776
|
+
...entry.config,
|
|
1777
|
+
...Object.fromEntries(this.secrets.get(integrationId) ?? [])
|
|
1778
|
+
});
|
|
1390
1779
|
this.log.info(`Applying config for ${integrationId} to ${runtimeName}`);
|
|
1391
1780
|
await applier.applyConfig(integrationId, interpolatedConfig);
|
|
1392
1781
|
configApplied = true;
|
|
@@ -1466,7 +1855,8 @@ var IntegrationManager = class {
|
|
|
1466
1855
|
async deactivate(integrationId) {
|
|
1467
1856
|
const entry = this.state.get(integrationId);
|
|
1468
1857
|
if (!entry) return this.err("NOT_FOUND", `Integration "${integrationId}" is not installed`);
|
|
1469
|
-
|
|
1858
|
+
const ownedEntries = this.lockManager.getEntriesForIntegration(integrationId);
|
|
1859
|
+
if (entry.status !== "active" && entry.status !== "error" && Object.keys(ownedEntries).length === 0) return {
|
|
1470
1860
|
ok: true,
|
|
1471
1861
|
payload: {
|
|
1472
1862
|
name: integrationId,
|
|
@@ -1476,16 +1866,18 @@ var IntegrationManager = class {
|
|
|
1476
1866
|
};
|
|
1477
1867
|
this.log.info(`Deactivating integration: ${integrationId}`);
|
|
1478
1868
|
try {
|
|
1479
|
-
const
|
|
1480
|
-
const
|
|
1481
|
-
for (const [runtimeName, entries] of Object.entries(
|
|
1869
|
+
const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime(integrationId);
|
|
1870
|
+
const cleanupFailures = [];
|
|
1871
|
+
for (const [runtimeName, entries] of Object.entries(ownedEntries)) {
|
|
1482
1872
|
const applier = this.runtimeAppliers.get(runtimeName);
|
|
1483
1873
|
if (!applier) {
|
|
1484
1874
|
this.log.warn(`No applier for runtime "${runtimeName}" — cannot remove entries`);
|
|
1875
|
+
cleanupFailures.push(`runtime:${runtimeName}:missing-applier`);
|
|
1485
1876
|
continue;
|
|
1486
1877
|
}
|
|
1487
1878
|
if (!await applier.isAvailable()) {
|
|
1488
1879
|
this.log.warn(`Runtime "${runtimeName}" is not available — skipping removal`);
|
|
1880
|
+
cleanupFailures.push(`runtime:${runtimeName}:unavailable`);
|
|
1489
1881
|
continue;
|
|
1490
1882
|
}
|
|
1491
1883
|
const keepPlugins = claimedPluginsByRuntime.get(runtimeName) ?? /* @__PURE__ */ new Set();
|
|
@@ -1500,6 +1892,7 @@ var IntegrationManager = class {
|
|
|
1500
1892
|
await applier.removePlugin(plugin.package);
|
|
1501
1893
|
} catch (err) {
|
|
1502
1894
|
this.log.warn(`Failed to remove plugin ${plugin.package} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1895
|
+
cleanupFailures.push(`runtime:${runtimeName}:plugin`);
|
|
1503
1896
|
}
|
|
1504
1897
|
}
|
|
1505
1898
|
for (const skill of entries.skills) {
|
|
@@ -1513,13 +1906,17 @@ var IntegrationManager = class {
|
|
|
1513
1906
|
else await applier.removeSkill(skill.name);
|
|
1514
1907
|
} catch (err) {
|
|
1515
1908
|
this.log.warn(`Failed to remove skill ${skill.name} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1909
|
+
cleanupFailures.push(`runtime:${runtimeName}:skill`);
|
|
1516
1910
|
}
|
|
1517
1911
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1912
|
+
if (entries.config.length > 0) {
|
|
1913
|
+
this.log.info(`Removing config for ${integrationId} from ${runtimeName}`);
|
|
1914
|
+
try {
|
|
1915
|
+
await applier.removeConfig(integrationId);
|
|
1916
|
+
} catch (err) {
|
|
1917
|
+
this.log.warn(`Failed to remove config for ${integrationId} from ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1918
|
+
cleanupFailures.push(`runtime:${runtimeName}:config`);
|
|
1919
|
+
}
|
|
1523
1920
|
}
|
|
1524
1921
|
}
|
|
1525
1922
|
if (this.mcpApplier) {
|
|
@@ -1528,8 +1925,11 @@ var IntegrationManager = class {
|
|
|
1528
1925
|
await this.mcpApplier.removeForIntegration(integrationId);
|
|
1529
1926
|
} catch (err) {
|
|
1530
1927
|
this.log.warn(`Failed to remove MCP servers for ${integrationId} via bundler manager: ${err instanceof Error ? err.message : String(err)}`);
|
|
1928
|
+
cleanupFailures.push("mcp");
|
|
1531
1929
|
}
|
|
1532
1930
|
}
|
|
1931
|
+
if (cleanupFailures.length > 0) return this.err("DEACTIVATE_FAILED", `Failed to remove ${String(cleanupFailures.length)} integration contribution(s); ownership lock retained for retry`);
|
|
1932
|
+
this.lockManager.removeEntries(integrationId);
|
|
1533
1933
|
this.state.setStatus(integrationId, "configured");
|
|
1534
1934
|
return {
|
|
1535
1935
|
ok: true,
|
|
@@ -1560,7 +1960,10 @@ var IntegrationManager = class {
|
|
|
1560
1960
|
if (!entry) return this.err("NOT_FOUND", `Integration "${name}" is not installed`);
|
|
1561
1961
|
this.log.info(`Uninstalling integration: ${name}`);
|
|
1562
1962
|
try {
|
|
1563
|
-
if (entry.status === "active"
|
|
1963
|
+
if (entry.status === "active" || entry.status === "error" || Object.keys(this.lockManager.getEntriesForIntegration(name)).length > 0) {
|
|
1964
|
+
const deactivateResult = await this.deactivate(name);
|
|
1965
|
+
if (!deactivateResult.ok) return this.err("UNINSTALL_FAILED", `Could not safely deactivate "${name}": ${deactivateResult.error?.message ?? "unknown cleanup failure"}`);
|
|
1966
|
+
}
|
|
1564
1967
|
const installPath = this.installer.getInstallPath(name);
|
|
1565
1968
|
let manifest = null;
|
|
1566
1969
|
const manifestPath = join(installPath, "alfe-integration.yaml");
|
|
@@ -1631,7 +2034,7 @@ var IntegrationManager = class {
|
|
|
1631
2034
|
this.log.info(`Reinstalling integration: ${name}`);
|
|
1632
2035
|
try {
|
|
1633
2036
|
const uninstallResult = await this.uninstall({ name });
|
|
1634
|
-
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"}`);
|
|
1635
2038
|
const installResult = await this.install(params);
|
|
1636
2039
|
if (!installResult.ok) return installResult;
|
|
1637
2040
|
return await this.activate(name, { forcePlugins: true });
|
|
@@ -1702,6 +2105,8 @@ var IntegrationManager = class {
|
|
|
1702
2105
|
}
|
|
1703
2106
|
this.log.info(`Upgrading integration: ${name}${version ? `@${version}` : ""}`);
|
|
1704
2107
|
let stagedPath;
|
|
2108
|
+
let newManifest;
|
|
2109
|
+
let preparedUpgradeConfig;
|
|
1705
2110
|
try {
|
|
1706
2111
|
let resolved;
|
|
1707
2112
|
if (customSource) {
|
|
@@ -1713,6 +2118,16 @@ var IntegrationManager = class {
|
|
|
1713
2118
|
}
|
|
1714
2119
|
stagedPath = await this.installer.stage(resolved);
|
|
1715
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}`);
|
|
1716
2131
|
} catch (err) {
|
|
1717
2132
|
const message = err instanceof Error ? err.message : String(err);
|
|
1718
2133
|
this.log.error(`Failed to prepare upgrade for "${name}": ${message}`);
|
|
@@ -1721,21 +2136,7 @@ var IntegrationManager = class {
|
|
|
1721
2136
|
try {
|
|
1722
2137
|
await opts?.onBeforeRuntimeMutation?.();
|
|
1723
2138
|
this.installer.commitStaged(name, stagedPath);
|
|
1724
|
-
|
|
1725
|
-
if (!existsSync(newManifestPath)) throw new Error(`No alfe-integration.yaml found after commit for "${name}"`);
|
|
1726
|
-
const newManifest = parseManifestFile(newManifestPath);
|
|
1727
|
-
this.log.info(`Upgrade manifest validated: ${newManifest.id}@${newManifest.version}`);
|
|
1728
|
-
for (const dep of newManifest.depends_on) {
|
|
1729
|
-
const depState = this.state.get(dep);
|
|
1730
|
-
if (!depState || depState.status === "error") throw new Error(`Dependency "${dep}" is not installed. Install it first: alfe integration install ${dep}`);
|
|
1731
|
-
}
|
|
1732
|
-
this.state.update(name, {
|
|
1733
|
-
status: "installed",
|
|
1734
|
-
version: newManifest.version,
|
|
1735
|
-
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1736
|
-
config: config ?? existing.config,
|
|
1737
|
-
customConnectionId: customSource?.connectionId ?? existing.customConnectionId
|
|
1738
|
-
});
|
|
2139
|
+
this.log.info(`Upgrade committed: ${newManifest.id}@${newManifest.version}`);
|
|
1739
2140
|
const installHooksSupported = this.manifestSupportsRegisteredRuntime(newManifest);
|
|
1740
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(", ")})`);
|
|
1741
2142
|
if (installHooksSupported && newManifest.hooks.pre_install) {
|
|
@@ -1747,13 +2148,21 @@ var IntegrationManager = class {
|
|
|
1747
2148
|
this.log.info(`Running post_install hook: ${newManifest.hooks.post_install}`);
|
|
1748
2149
|
const hookResult = await runHookWithContext(this.installer.getInstallPath(name), newManifest.hooks.post_install, {
|
|
1749
2150
|
integrationName: name,
|
|
1750
|
-
config:
|
|
1751
|
-
secrets: this.secrets.get(name),
|
|
2151
|
+
config: preparedUpgradeConfig?.nonSecretConfig ?? existing.config,
|
|
2152
|
+
secrets: preparedUpgradeConfig?.secretConfig ?? this.secrets.get(name),
|
|
1752
2153
|
runtimes: [...this.runtimeAppliers.keys()]
|
|
1753
2154
|
}, INSTALL_HOOK_TIMEOUT_MS);
|
|
1754
2155
|
if (hookResult.exitCode !== 0) throw new Error(this.hookFailureMessage("post_install hook failed", hookResult));
|
|
1755
2156
|
}
|
|
1756
|
-
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);
|
|
1757
2166
|
return await this.activate(name, { forcePlugins: true });
|
|
1758
2167
|
} catch (err) {
|
|
1759
2168
|
try {
|
|
@@ -1865,7 +2274,7 @@ var IntegrationManager = class {
|
|
|
1865
2274
|
commands: commands.map((cmd) => ({
|
|
1866
2275
|
name: cmd.name,
|
|
1867
2276
|
handler: cmd.handler,
|
|
1868
|
-
resolvedPath:
|
|
2277
|
+
resolvedPath: resolveExistingWithin(installPath, cmd.handler, `Command handler ${cmd.name}`, "file"),
|
|
1869
2278
|
method: cmd.method ?? "handle",
|
|
1870
2279
|
timeoutMs: cmd.timeout_ms ?? 3e4,
|
|
1871
2280
|
description: cmd.description
|
|
@@ -1930,9 +2339,7 @@ var IntegrationManager = class {
|
|
|
1930
2339
|
healthy: hookResult.exitCode === 0,
|
|
1931
2340
|
status: entry.status,
|
|
1932
2341
|
version: manifest.version,
|
|
1933
|
-
message: hookResult.exitCode === 0 ? "Healthy" : hookResult.timedOut ? `Health check failed (timed out after ${String(hookResult.timedOutAfterMs ?? "?")}ms)` : `Health check failed (exit ${String(hookResult.exitCode)})
|
|
1934
|
-
stdout: hookResult.stdout || void 0,
|
|
1935
|
-
stderr: hookResult.stderr || void 0
|
|
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)})`
|
|
1936
2343
|
};
|
|
1937
2344
|
} catch (err) {
|
|
1938
2345
|
return {
|
|
@@ -1956,13 +2363,13 @@ var IntegrationManager = class {
|
|
|
1956
2363
|
/**
|
|
1957
2364
|
* Build a hook-failure message. A hook we SIGKILLed at its timeout is rendered
|
|
1958
2365
|
* as `(timed out after <ms>ms)` so it is visually distinguishable in Sentry
|
|
1959
|
-
* from a genuine non-zero exit `(exit <code>)
|
|
1960
|
-
*
|
|
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.
|
|
1961
2370
|
*/
|
|
1962
2371
|
hookFailureMessage(label, hookResult) {
|
|
1963
|
-
|
|
1964
|
-
const output = hookResult.stderr || hookResult.stdout;
|
|
1965
|
-
return output ? `${label} (${reason}): ${output}` : `${label} (${reason})`;
|
|
2372
|
+
return `${label} (${hookResult.timedOut ? `timed out after ${String(hookResult.timedOutAfterMs ?? "?")}ms` : `exit ${String(hookResult.exitCode)}`})`;
|
|
1966
2373
|
}
|
|
1967
2374
|
/**
|
|
1968
2375
|
* Compute the plugin (bare package) names and skill names still claimed by
|
|
@@ -1976,13 +2383,13 @@ var IntegrationManager = class {
|
|
|
1976
2383
|
* integrations pinning the same plugin at different versions still keep the
|
|
1977
2384
|
* one file-system install alive when only one is removed.
|
|
1978
2385
|
*/
|
|
1979
|
-
claimedEntriesByRuntime() {
|
|
2386
|
+
claimedEntriesByRuntime(excludeIntegrationId) {
|
|
1980
2387
|
const remaining = this.lockManager.read();
|
|
1981
2388
|
const plugins = /* @__PURE__ */ new Map();
|
|
1982
2389
|
const skills = /* @__PURE__ */ new Map();
|
|
1983
2390
|
for (const [rtName, state] of Object.entries(remaining.runtimes)) {
|
|
1984
|
-
plugins.set(rtName, new Set(state.plugins.map((
|
|
1985
|
-
skills.set(rtName, new Set(state.skills.map((
|
|
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)));
|
|
1986
2393
|
}
|
|
1987
2394
|
return {
|
|
1988
2395
|
plugins,
|
|
@@ -1995,24 +2402,27 @@ var IntegrationManager = class {
|
|
|
1995
2402
|
* (and everything a sibling integration still claims) in place. Phase 4's
|
|
1996
2403
|
* `activate` re-applies and re-locks the new set immediately after.
|
|
1997
2404
|
*
|
|
1998
|
-
*
|
|
1999
|
-
*
|
|
2000
|
-
*
|
|
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.
|
|
2001
2408
|
*/
|
|
2002
|
-
async applyUpgradeDiffRemovals(integrationId,
|
|
2003
|
-
const
|
|
2004
|
-
const { plugins: claimedPluginsByRuntime, skills: claimedSkillsByRuntime } = this.claimedEntriesByRuntime();
|
|
2005
|
-
|
|
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)) {
|
|
2006
2414
|
const applier = this.runtimeAppliers.get(runtimeName);
|
|
2007
2415
|
if (!applier) {
|
|
2008
2416
|
this.log.warn(`No applier for runtime "${runtimeName}" — cannot diff-remove entries`);
|
|
2417
|
+
cleanupFailures.push(`runtime:${runtimeName}:missing-applier`);
|
|
2009
2418
|
continue;
|
|
2010
2419
|
}
|
|
2011
2420
|
if (!await applier.isAvailable()) {
|
|
2012
2421
|
this.log.warn(`Runtime "${runtimeName}" is not available — skipping upgrade diff removal`);
|
|
2422
|
+
cleanupFailures.push(`runtime:${runtimeName}:unavailable`);
|
|
2013
2423
|
continue;
|
|
2014
2424
|
}
|
|
2015
|
-
const { plugins: newPlugins, skills: newSkills
|
|
2425
|
+
const { plugins: newPlugins, skills: newSkills } = resolveInstallsForRuntime(newManifest, runtimeName);
|
|
2016
2426
|
const keepPluginsNew = new Set(newPlugins.map((p) => stripPluginVersion(p.package)));
|
|
2017
2427
|
const keepSkillsNew = new Set(newSkills.map((s) => s.clawhub ?? s.path?.split("/").pop() ?? "unknown"));
|
|
2018
2428
|
const keepPluginsOther = claimedPluginsByRuntime.get(runtimeName) ?? /* @__PURE__ */ new Set();
|
|
@@ -2025,6 +2435,7 @@ var IntegrationManager = class {
|
|
|
2025
2435
|
await applier.removePlugin(plugin.package);
|
|
2026
2436
|
} catch (err) {
|
|
2027
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`);
|
|
2028
2439
|
}
|
|
2029
2440
|
}
|
|
2030
2441
|
for (const skill of entries.skills) {
|
|
@@ -2035,17 +2446,16 @@ var IntegrationManager = class {
|
|
|
2035
2446
|
else await applier.removeSkill(skill.name);
|
|
2036
2447
|
} catch (err) {
|
|
2037
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`);
|
|
2038
2450
|
}
|
|
2039
2451
|
}
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
const newHasConfig = Boolean(newRuntimeConfig && Object.keys(newRuntimeConfig).length > 0);
|
|
2043
|
-
if (oldHadConfig && !newHasConfig) {
|
|
2044
|
-
this.log.info(`Upgrade: removing gone config for ${integrationId} from ${runtimeName}`);
|
|
2452
|
+
if (entries.config.length > 0) {
|
|
2453
|
+
this.log.info(`Upgrade: removing prior config for ${integrationId} from ${runtimeName}`);
|
|
2045
2454
|
try {
|
|
2046
2455
|
await applier.removeConfig(integrationId);
|
|
2047
2456
|
} catch (err) {
|
|
2048
|
-
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`);
|
|
2049
2459
|
}
|
|
2050
2460
|
}
|
|
2051
2461
|
}
|
|
@@ -2056,8 +2466,11 @@ var IntegrationManager = class {
|
|
|
2056
2466
|
await this.mcpApplier.pruneForIntegration(integrationId, keepIds);
|
|
2057
2467
|
} catch (err) {
|
|
2058
2468
|
this.log.warn(`Failed to prune MCP servers for ${integrationId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
2469
|
+
cleanupFailures.push("mcp");
|
|
2059
2470
|
}
|
|
2060
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);
|
|
2061
2474
|
}
|
|
2062
2475
|
/**
|
|
2063
2476
|
* True when the manifest's `supported_agents` (if declared) intersects the
|
|
@@ -2283,6 +2696,11 @@ const INVALID_PARTIAL_PROVIDER_PHRASE = "custom model providers must declare";
|
|
|
2283
2696
|
function isInvalidPartialProviderUnset(err) {
|
|
2284
2697
|
return (err instanceof Error ? err.message : String(err)).toLowerCase().includes(INVALID_PARTIAL_PROVIDER_PHRASE);
|
|
2285
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
|
+
}
|
|
2286
2704
|
const delay$1 = (ms) => new Promise((resolve) => {
|
|
2287
2705
|
setTimeout(resolve, ms);
|
|
2288
2706
|
});
|
|
@@ -2419,16 +2837,26 @@ function configSetErrorMessage(err, args) {
|
|
|
2419
2837
|
}
|
|
2420
2838
|
return `${target} failed: ${String(err)}`;
|
|
2421
2839
|
}
|
|
2422
|
-
async function readParentObject(parentPath) {
|
|
2840
|
+
async function readParentObject(parentPath, options = {}) {
|
|
2423
2841
|
try {
|
|
2424
2842
|
const { stdout } = await execFileAsync$1("openclaw", [
|
|
2425
2843
|
"config",
|
|
2426
2844
|
"get",
|
|
2427
2845
|
parentPath
|
|
2428
2846
|
], { timeout: 1e4 });
|
|
2429
|
-
const
|
|
2847
|
+
const trimmed = stdout.trim();
|
|
2848
|
+
if (trimmed === "") return {};
|
|
2849
|
+
const parsed = JSON.parse(trimmed);
|
|
2430
2850
|
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
|
|
2431
|
-
|
|
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
|
+
}
|
|
2432
2860
|
return {};
|
|
2433
2861
|
}
|
|
2434
2862
|
var OpenClawApplier = class {
|
|
@@ -2920,12 +3348,14 @@ var OpenClawApplier = class {
|
|
|
2920
3348
|
], { timeout: 3e4 });
|
|
2921
3349
|
}
|
|
2922
3350
|
applySkill(name, srcPath) {
|
|
3351
|
+
assertSafePathSegment(name, "Skill name");
|
|
2923
3352
|
if (!existsSync(srcPath)) throw new Error(`Skill source path not found: ${srcPath}`);
|
|
2924
3353
|
mkdirSync(this.skillsDir, { recursive: true });
|
|
2925
3354
|
cpSync(srcPath, join(this.skillsDir, name), { recursive: true });
|
|
2926
3355
|
return Promise.resolve();
|
|
2927
3356
|
}
|
|
2928
3357
|
applyClawHubSkill(slug) {
|
|
3358
|
+
assertSafePathSegment(slug, "ClawHub skill name");
|
|
2929
3359
|
return this.cliLock.run(async () => {
|
|
2930
3360
|
log$5.info({ slug }, "Installing skill from ClawHub");
|
|
2931
3361
|
try {
|
|
@@ -2950,6 +3380,7 @@ var OpenClawApplier = class {
|
|
|
2950
3380
|
});
|
|
2951
3381
|
}
|
|
2952
3382
|
removeClawHubSkill(slug) {
|
|
3383
|
+
assertSafePathSegment(slug, "ClawHub skill name");
|
|
2953
3384
|
const workspaceSkillsDir = join(this.agentWorkspace, "skills", slug);
|
|
2954
3385
|
if (existsSync(workspaceSkillsDir)) {
|
|
2955
3386
|
rmSync(workspaceSkillsDir, {
|
|
@@ -2961,6 +3392,7 @@ var OpenClawApplier = class {
|
|
|
2961
3392
|
return Promise.resolve();
|
|
2962
3393
|
}
|
|
2963
3394
|
removeSkill(name) {
|
|
3395
|
+
assertSafePathSegment(name, "Skill name");
|
|
2964
3396
|
const skillPath = join(this.skillsDir, name);
|
|
2965
3397
|
if (existsSync(skillPath)) rmSync(skillPath, {
|
|
2966
3398
|
recursive: true,
|
|
@@ -2978,8 +3410,9 @@ var OpenClawApplier = class {
|
|
|
2978
3410
|
return this.cliLock.run(() => this.applyConfigLocked(integrationId, config));
|
|
2979
3411
|
}
|
|
2980
3412
|
async applyConfigLocked(integrationId, config) {
|
|
3413
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
2981
3414
|
const tracking = this.readTracking();
|
|
2982
|
-
const integrations = tracking
|
|
3415
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "OpenClaw integration tracking field");
|
|
2983
3416
|
const previous = integrations[integrationId];
|
|
2984
3417
|
const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
|
|
2985
3418
|
if (previous && typeof previous === "object" && !Array.isArray(previous)) {
|
|
@@ -3050,8 +3483,9 @@ var OpenClawApplier = class {
|
|
|
3050
3483
|
return this.cliLock.run(() => this.removeConfigLocked(integrationId));
|
|
3051
3484
|
}
|
|
3052
3485
|
async removeConfigLocked(integrationId) {
|
|
3486
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3053
3487
|
const tracking = this.readTracking();
|
|
3054
|
-
const integrations = tracking
|
|
3488
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "OpenClaw integration tracking field");
|
|
3055
3489
|
if (!(integrationId in integrations)) return;
|
|
3056
3490
|
const integrationConfig = integrations[integrationId];
|
|
3057
3491
|
const { leaves, subtreesByParent } = partitionEntries(flattenConfig(integrationConfig));
|
|
@@ -3065,15 +3499,16 @@ var OpenClawApplier = class {
|
|
|
3065
3499
|
* Drop a set of dotted keys from a dot-free parent object via read-drop-write,
|
|
3066
3500
|
* UNLOCKED. If the parent becomes empty, `config unset` it; otherwise
|
|
3067
3501
|
* `--replace` the shrunk map (siblings survive because they remain in
|
|
3068
|
-
* `remaining`).
|
|
3069
|
-
*
|
|
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
|
|
3070
3505
|
* `applyConfig`'s stale-key diff (per-key removal between manifest versions).
|
|
3071
3506
|
*
|
|
3072
3507
|
* Assumes the shared CLI lock is already held by the calling public method —
|
|
3073
3508
|
* the lock is NOT re-entrant, so this stays an `*Unlocked` internal.
|
|
3074
3509
|
*/
|
|
3075
3510
|
async dropSubtreeKeysUnlocked(parentPath, dottedKeys) {
|
|
3076
|
-
const existing = await readParentObject(parentPath);
|
|
3511
|
+
const existing = await readParentObject(parentPath, { failClosed: true });
|
|
3077
3512
|
if (Object.keys(existing).length === 0) return;
|
|
3078
3513
|
const remaining = Object.fromEntries(Object.entries(existing).filter(([k]) => !dottedKeys.has(k)));
|
|
3079
3514
|
try {
|
|
@@ -3084,10 +3519,8 @@ var OpenClawApplier = class {
|
|
|
3084
3519
|
"--replace"
|
|
3085
3520
|
]);
|
|
3086
3521
|
} catch (err) {
|
|
3087
|
-
|
|
3088
|
-
|
|
3089
|
-
parentPath
|
|
3090
|
-
}, "Failed to update parent config during subtree key drop");
|
|
3522
|
+
if (isMissingConfigPathError(err)) return;
|
|
3523
|
+
throw err;
|
|
3091
3524
|
}
|
|
3092
3525
|
}
|
|
3093
3526
|
/**
|
|
@@ -3107,15 +3540,17 @@ var OpenClawApplier = class {
|
|
|
3107
3540
|
* the provider subtree is unset for `models.providers.zhipu.baseUrl`, the
|
|
3108
3541
|
* follow-up `.apiKey` / `.models` leaves skip re-issuing the parent unset.
|
|
3109
3542
|
*
|
|
3110
|
-
*
|
|
3111
|
-
*
|
|
3112
|
-
*
|
|
3113
|
-
* (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.
|
|
3114
3548
|
*/
|
|
3115
3549
|
async unsetLeafPathUnlocked(path, unsetParents) {
|
|
3116
3550
|
try {
|
|
3117
3551
|
await this.runConfigCommandUnlocked(["unset", path]);
|
|
3118
3552
|
} catch (err) {
|
|
3553
|
+
if (isMissingConfigPathError(err)) return;
|
|
3119
3554
|
const parentPath = path.includes(".") ? path.slice(0, path.lastIndexOf(".")) : void 0;
|
|
3120
3555
|
if (parentPath && isInvalidPartialProviderUnset(err)) {
|
|
3121
3556
|
if (unsetParents.has(parentPath)) return;
|
|
@@ -3127,17 +3562,12 @@ var OpenClawApplier = class {
|
|
|
3127
3562
|
parentPath
|
|
3128
3563
|
}, "Leaf unset rejected as an invalid partial custom provider — unset the parent subtree instead");
|
|
3129
3564
|
} catch (parentErr) {
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
parentPath
|
|
3133
|
-
}, "Failed to unset parent subtree after an invalid-partial leaf unset");
|
|
3565
|
+
if (isMissingConfigPathError(parentErr)) return;
|
|
3566
|
+
throw parentErr;
|
|
3134
3567
|
}
|
|
3135
3568
|
return;
|
|
3136
3569
|
}
|
|
3137
|
-
|
|
3138
|
-
err: err instanceof Error ? err.message : String(err),
|
|
3139
|
-
path
|
|
3140
|
-
}, "Failed to unset config via openclaw config unset");
|
|
3570
|
+
throw err;
|
|
3141
3571
|
}
|
|
3142
3572
|
}
|
|
3143
3573
|
/**
|
|
@@ -3197,16 +3627,10 @@ var OpenClawApplier = class {
|
|
|
3197
3627
|
return Promise.resolve(existsSync(this.home));
|
|
3198
3628
|
}
|
|
3199
3629
|
readTracking() {
|
|
3200
|
-
|
|
3201
|
-
try {
|
|
3202
|
-
return JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
3203
|
-
} catch {
|
|
3204
|
-
return {};
|
|
3205
|
-
}
|
|
3630
|
+
return readJsonObjectFileSync(this.trackingPath, "OpenClaw integration tracking file");
|
|
3206
3631
|
}
|
|
3207
3632
|
writeTracking(config) {
|
|
3208
|
-
|
|
3209
|
-
writeFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
3633
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n");
|
|
3210
3634
|
}
|
|
3211
3635
|
};
|
|
3212
3636
|
//#endregion
|
|
@@ -3319,22 +3743,16 @@ var HermesApplier = class {
|
|
|
3319
3743
|
* be cleanly removed later (mirrors OpenClawApplier's `_integrations`).
|
|
3320
3744
|
*/
|
|
3321
3745
|
async applyConfig(integrationId, config) {
|
|
3746
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3322
3747
|
const tracking = this.readTracking();
|
|
3323
|
-
const integrations = tracking
|
|
3748
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "Hermes integration tracking field");
|
|
3324
3749
|
const previous = integrations[integrationId];
|
|
3325
3750
|
const { leaves, subtreesByParent } = partitionEntries(flattenConfig(config));
|
|
3326
3751
|
if (previous && typeof previous === "object" && !Array.isArray(previous)) {
|
|
3327
3752
|
const prev = partitionEntries(flattenConfig(previous));
|
|
3328
3753
|
const nextLeafPaths = new Set(leaves.map((l) => l.path));
|
|
3329
3754
|
const staleLeafPaths = prev.leaves.filter((l) => !nextLeafPaths.has(l.path)).map((l) => l.path);
|
|
3330
|
-
if (staleLeafPaths.length > 0)
|
|
3331
|
-
await this.deleteConfigKeys(staleLeafPaths);
|
|
3332
|
-
} catch (err) {
|
|
3333
|
-
log$4.warn({
|
|
3334
|
-
err: err instanceof Error ? err.message : String(err),
|
|
3335
|
-
integrationId
|
|
3336
|
-
}, "Failed to delete stale Hermes config keys during applyConfig diff");
|
|
3337
|
-
}
|
|
3755
|
+
if (staleLeafPaths.length > 0) await this.deleteConfigKeys(staleLeafPaths);
|
|
3338
3756
|
const staleSubtreeParents = [...prev.subtreesByParent].filter(([parent, prevKvs]) => {
|
|
3339
3757
|
const nextKvs = subtreesByParent.get(parent);
|
|
3340
3758
|
return [...prevKvs.keys()].some((k) => !nextKvs?.has(k));
|
|
@@ -3369,8 +3787,9 @@ var HermesApplier = class {
|
|
|
3369
3787
|
* `hermes config unset` verb — see the file header). Clears the tracking entry.
|
|
3370
3788
|
*/
|
|
3371
3789
|
async removeConfig(integrationId) {
|
|
3790
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3372
3791
|
const tracking = this.readTracking();
|
|
3373
|
-
const integrations = tracking
|
|
3792
|
+
const integrations = getOptionalJsonObjectField(tracking, "_integrations", "Hermes integration tracking field");
|
|
3374
3793
|
if (!(integrationId in integrations)) return;
|
|
3375
3794
|
const integrationConfig = integrations[integrationId];
|
|
3376
3795
|
const { leaves } = partitionEntries(flattenConfig(integrationConfig));
|
|
@@ -3381,6 +3800,7 @@ var HermesApplier = class {
|
|
|
3381
3800
|
err: err instanceof Error ? err.message : String(err),
|
|
3382
3801
|
integrationId
|
|
3383
3802
|
}, "Failed to delete Alfe config keys from ~/.hermes/config.yaml");
|
|
3803
|
+
throw err;
|
|
3384
3804
|
}
|
|
3385
3805
|
tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
|
|
3386
3806
|
this.writeTracking(tracking);
|
|
@@ -3521,10 +3941,11 @@ var HermesApplier = class {
|
|
|
3521
3941
|
deleteConfigKeysSync(paths) {
|
|
3522
3942
|
if (paths.length === 0 || !existsSync(this.configYamlPath)) return;
|
|
3523
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");
|
|
3524
3945
|
const before = doc.toString();
|
|
3525
3946
|
for (const path of paths) doc.deleteIn(path.split("."));
|
|
3526
3947
|
const after = doc.toString();
|
|
3527
|
-
if (before !== after)
|
|
3948
|
+
if (before !== after) atomicWriteFileSync(this.configYamlPath, after);
|
|
3528
3949
|
}
|
|
3529
3950
|
/**
|
|
3530
3951
|
* Run `hermes config <args>` (only `set` is used — removal goes via
|
|
@@ -3550,16 +3971,10 @@ var HermesApplier = class {
|
|
|
3550
3971
|
return result;
|
|
3551
3972
|
}
|
|
3552
3973
|
readTracking() {
|
|
3553
|
-
|
|
3554
|
-
try {
|
|
3555
|
-
return JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
3556
|
-
} catch {
|
|
3557
|
-
return {};
|
|
3558
|
-
}
|
|
3974
|
+
return readJsonObjectFileSync(this.trackingPath, "Hermes integration tracking file");
|
|
3559
3975
|
}
|
|
3560
3976
|
writeTracking(config) {
|
|
3561
|
-
|
|
3562
|
-
writeFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
|
3977
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify(config, null, 2) + "\n");
|
|
3563
3978
|
}
|
|
3564
3979
|
};
|
|
3565
3980
|
//#endregion
|
|
@@ -3662,8 +4077,8 @@ var HermesMcpSync = class {
|
|
|
3662
4077
|
*/
|
|
3663
4078
|
start() {
|
|
3664
4079
|
if (this.started) return;
|
|
3665
|
-
this.started = true;
|
|
3666
4080
|
this.loadSyncedIds();
|
|
4081
|
+
this.started = true;
|
|
3667
4082
|
this.syncOnce().catch((err) => {
|
|
3668
4083
|
log$3.warn({ err: errMsg$2(err) }, "Hermes MCP sync: initial sync failed");
|
|
3669
4084
|
});
|
|
@@ -3718,14 +4133,14 @@ var HermesMcpSync = class {
|
|
|
3718
4133
|
const desired = this.computeDesired();
|
|
3719
4134
|
const desiredIds = new Set(desired.keys());
|
|
3720
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");
|
|
3721
4137
|
const before = doc.toString();
|
|
3722
4138
|
for (const [id, entry] of desired) doc.setIn(["mcp_servers", id], entry);
|
|
3723
4139
|
for (const id of this.syncedIds) if (!desiredIds.has(id)) doc.deleteIn(["mcp_servers", id]);
|
|
3724
4140
|
const after = doc.toString();
|
|
3725
4141
|
let changed = before !== after;
|
|
3726
4142
|
if (changed) {
|
|
3727
|
-
|
|
3728
|
-
writeFileSync(this.configPath, after, "utf-8");
|
|
4143
|
+
atomicWriteFileSync(this.configPath, after);
|
|
3729
4144
|
log$3.info({
|
|
3730
4145
|
added: [...desiredIds],
|
|
3731
4146
|
removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
|
|
@@ -3762,7 +4177,7 @@ var HermesMcpSync = class {
|
|
|
3762
4177
|
}
|
|
3763
4178
|
withAlfeApiKey(env) {
|
|
3764
4179
|
const merged = { ...env ?? {} };
|
|
3765
|
-
|
|
4180
|
+
merged.ALFE_API_KEY = ALFE_API_KEY_ENV_REF;
|
|
3766
4181
|
return merged;
|
|
3767
4182
|
}
|
|
3768
4183
|
/** Read-merge-write `~/.hermes/.env` to set ALFE_API_KEY without clobbering other keys. */
|
|
@@ -3777,17 +4192,15 @@ var HermesMcpSync = class {
|
|
|
3777
4192
|
if (!existsSync(this.trackingPath)) return;
|
|
3778
4193
|
try {
|
|
3779
4194
|
const data = JSON.parse(readFileSync(this.trackingPath, "utf-8"));
|
|
3780
|
-
if (Array.isArray(data.syncedIds)
|
|
3781
|
-
|
|
3782
|
-
}
|
|
3783
|
-
persistSyncedIds() {
|
|
3784
|
-
try {
|
|
3785
|
-
mkdirSync(dirname(this.trackingPath), { recursive: true });
|
|
3786
|
-
writeFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n", "utf-8");
|
|
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);
|
|
3787
4197
|
} catch (err) {
|
|
3788
|
-
|
|
4198
|
+
throw new Error(`Hermes MCP ownership sidecar is unreadable; refusing reconciliation: ${errMsg$2(err)}`);
|
|
3789
4199
|
}
|
|
3790
4200
|
}
|
|
4201
|
+
persistSyncedIds() {
|
|
4202
|
+
atomicWriteFileSync(this.trackingPath, JSON.stringify({ syncedIds: [...this.syncedIds] }, null, 2) + "\n");
|
|
4203
|
+
}
|
|
3791
4204
|
};
|
|
3792
4205
|
/**
|
|
3793
4206
|
* Set `KEY=value` in a `.env`-style file, preserving every other line (comments,
|
|
@@ -3796,6 +4209,8 @@ var HermesMcpSync = class {
|
|
|
3796
4209
|
* `~/.hermes/.env` ever holds.
|
|
3797
4210
|
*/
|
|
3798
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`);
|
|
3799
4214
|
const desiredLine = `${key}=${value}`;
|
|
3800
4215
|
const existing = existsSync(envPath) ? readFileSync(envPath, "utf-8") : "";
|
|
3801
4216
|
const lines = existing.length > 0 ? existing.replace(/\n$/, "").split("\n") : [];
|
|
@@ -3810,10 +4225,7 @@ function upsertEnvVar(envPath, key, value) {
|
|
|
3810
4225
|
changed = lines[idx] !== desiredLine;
|
|
3811
4226
|
out = lines.map((line, i) => i === idx ? desiredLine : line);
|
|
3812
4227
|
}
|
|
3813
|
-
if (changed)
|
|
3814
|
-
mkdirSync(dirname(envPath), { recursive: true });
|
|
3815
|
-
writeFileSync(envPath, out.join("\n") + "\n", "utf-8");
|
|
3816
|
-
}
|
|
4228
|
+
if (changed) atomicWriteFileSync(envPath, out.join("\n") + "\n");
|
|
3817
4229
|
return changed;
|
|
3818
4230
|
}
|
|
3819
4231
|
function errMsg$2(err) {
|
|
@@ -3883,8 +4295,9 @@ var ClaudeCodeApplier = class {
|
|
|
3883
4295
|
* replace, no stale-key diff needed since nothing is applied to a runtime).
|
|
3884
4296
|
*/
|
|
3885
4297
|
applyConfig(integrationId, config) {
|
|
4298
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3886
4299
|
const ledger = this.readLedger();
|
|
3887
|
-
const integrations = ledger
|
|
4300
|
+
const integrations = getOptionalJsonObjectField(ledger, "_integrations", "Claude Code integration ledger field");
|
|
3888
4301
|
integrations[integrationId] = config;
|
|
3889
4302
|
ledger._integrations = integrations;
|
|
3890
4303
|
this.writeLedger(ledger);
|
|
@@ -3893,8 +4306,9 @@ var ClaudeCodeApplier = class {
|
|
|
3893
4306
|
}
|
|
3894
4307
|
/** Clear an integration's config contribution from the ledger. */
|
|
3895
4308
|
removeConfig(integrationId) {
|
|
4309
|
+
assertSafePathSegment(integrationId, "Integration id");
|
|
3896
4310
|
const ledger = this.readLedger();
|
|
3897
|
-
const integrations = ledger
|
|
4311
|
+
const integrations = getOptionalJsonObjectField(ledger, "_integrations", "Claude Code integration ledger field");
|
|
3898
4312
|
if (!(integrationId in integrations)) return Promise.resolve();
|
|
3899
4313
|
ledger._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
|
|
3900
4314
|
this.writeLedger(ledger);
|
|
@@ -3912,8 +4326,9 @@ var ClaudeCodeApplier = class {
|
|
|
3912
4326
|
* `getConfigRaw`.
|
|
3913
4327
|
*/
|
|
3914
4328
|
setConfigRaw(key, value) {
|
|
4329
|
+
assertSafeRecordKey(key, "Raw config key");
|
|
3915
4330
|
const ledger = this.readLedger();
|
|
3916
|
-
const raw = ledger
|
|
4331
|
+
const raw = getOptionalJsonObjectField(ledger, "_raw", "Claude Code raw-config ledger field");
|
|
3917
4332
|
raw[key] = value;
|
|
3918
4333
|
ledger._raw = raw;
|
|
3919
4334
|
this.writeLedger(ledger);
|
|
@@ -3927,7 +4342,8 @@ var ClaudeCodeApplier = class {
|
|
|
3927
4342
|
*/
|
|
3928
4343
|
getConfigRaw(key) {
|
|
3929
4344
|
try {
|
|
3930
|
-
|
|
4345
|
+
assertSafeRecordKey(key, "Raw config key");
|
|
4346
|
+
const value = getOptionalJsonObjectField(this.readLedger(), "_raw", "Claude Code raw-config ledger field")[key];
|
|
3931
4347
|
return Promise.resolve(typeof value === "string" ? value : void 0);
|
|
3932
4348
|
} catch {
|
|
3933
4349
|
return Promise.resolve(void 0);
|
|
@@ -3980,6 +4396,7 @@ var ClaudeCodeApplier = class {
|
|
|
3980
4396
|
* clean no-op. We never throw for a claude-code ClawHub skill.
|
|
3981
4397
|
*/
|
|
3982
4398
|
applyClawHubSkill(slug) {
|
|
4399
|
+
assertSafePathSegment(slug, "ClawHub skill name");
|
|
3983
4400
|
log$2.info({ slug }, "Claude Code applyClawHubSkill: no claude-code ClawHub fetch — skipping (native skill dirs land via applySkill)");
|
|
3984
4401
|
return Promise.resolve();
|
|
3985
4402
|
}
|
|
@@ -3991,6 +4408,7 @@ var ClaudeCodeApplier = class {
|
|
|
3991
4408
|
return Promise.resolve(existsSync(this.home));
|
|
3992
4409
|
}
|
|
3993
4410
|
copySkillDir(name, srcPath) {
|
|
4411
|
+
assertSafePathSegment(name, "Skill name");
|
|
3994
4412
|
if (!existsSync(srcPath)) {
|
|
3995
4413
|
log$2.warn({
|
|
3996
4414
|
name,
|
|
@@ -4022,6 +4440,7 @@ var ClaudeCodeApplier = class {
|
|
|
4022
4440
|
return Promise.resolve();
|
|
4023
4441
|
}
|
|
4024
4442
|
deleteSkillDir(name) {
|
|
4443
|
+
assertSafePathSegment(name, "Skill name");
|
|
4025
4444
|
const dest = join(this.skillsDir, name);
|
|
4026
4445
|
try {
|
|
4027
4446
|
rmSync(dest, {
|
|
@@ -4033,25 +4452,21 @@ var ClaudeCodeApplier = class {
|
|
|
4033
4452
|
dest
|
|
4034
4453
|
}, "Claude Code removeSkill: removed skill from ~/.claude/skills");
|
|
4035
4454
|
} catch (err) {
|
|
4036
|
-
|
|
4455
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4456
|
+
log$2.error({
|
|
4037
4457
|
name,
|
|
4038
4458
|
dest,
|
|
4039
|
-
err:
|
|
4459
|
+
err: message
|
|
4040
4460
|
}, "Claude Code removeSkill: failed to remove skill directory");
|
|
4461
|
+
throw new Error(`Failed to remove Claude Code skill ${name}: ${message}`);
|
|
4041
4462
|
}
|
|
4042
4463
|
return Promise.resolve();
|
|
4043
4464
|
}
|
|
4044
4465
|
readLedger() {
|
|
4045
|
-
|
|
4046
|
-
try {
|
|
4047
|
-
return JSON.parse(readFileSync(this.ledgerPath, "utf-8"));
|
|
4048
|
-
} catch {
|
|
4049
|
-
return {};
|
|
4050
|
-
}
|
|
4466
|
+
return readJsonObjectFileSync(this.ledgerPath, "Claude Code integration ledger");
|
|
4051
4467
|
}
|
|
4052
4468
|
writeLedger(ledger) {
|
|
4053
|
-
|
|
4054
|
-
writeFileSync(this.ledgerPath, JSON.stringify(ledger, null, 2) + "\n", "utf-8");
|
|
4469
|
+
atomicWriteFileSync(this.ledgerPath, JSON.stringify(ledger, null, 2) + "\n");
|
|
4055
4470
|
}
|
|
4056
4471
|
};
|
|
4057
4472
|
//#endregion
|
|
@@ -4217,12 +4632,7 @@ var ClaudeCodeMcpSync = class {
|
|
|
4217
4632
|
const file = { mcpServers: Object.fromEntries(desired) };
|
|
4218
4633
|
const next = JSON.stringify(file, null, 2) + "\n";
|
|
4219
4634
|
if ((existsSync(this.configPath) ? readFileSync(this.configPath, "utf-8") : "") !== next) {
|
|
4220
|
-
|
|
4221
|
-
writeFileSync(this.configPath, next, {
|
|
4222
|
-
encoding: "utf-8",
|
|
4223
|
-
mode: 384
|
|
4224
|
-
});
|
|
4225
|
-
chmodSync(this.configPath, 384);
|
|
4635
|
+
atomicWriteFileSync(this.configPath, next);
|
|
4226
4636
|
log$1.info({
|
|
4227
4637
|
added: [...desiredIds],
|
|
4228
4638
|
removed: [...this.syncedIds].filter((id) => !desiredIds.has(id))
|
|
@@ -4262,8 +4672,8 @@ var ClaudeCodeMcpSync = class {
|
|
|
4262
4672
|
}
|
|
4263
4673
|
withAlfeApiKey(env) {
|
|
4264
4674
|
const merged = { ...env ?? {} };
|
|
4265
|
-
if ("ALFE_API_KEY" in merged) return merged;
|
|
4266
4675
|
if (!this.apiKey) {
|
|
4676
|
+
delete merged.ALFE_API_KEY;
|
|
4267
4677
|
log$1.warn("Claude Code MCP sync: no ALFE_API_KEY available — generated MCP servers will start in zero-accounts degraded mode");
|
|
4268
4678
|
return merged;
|
|
4269
4679
|
}
|
|
@@ -4279,8 +4689,7 @@ var ClaudeCodeMcpSync = class {
|
|
|
4279
4689
|
}
|
|
4280
4690
|
persistSyncedIds() {
|
|
4281
4691
|
try {
|
|
4282
|
-
|
|
4283
|
-
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");
|
|
4284
4693
|
} catch (err) {
|
|
4285
4694
|
log$1.warn({ err: errMsg$1(err) }, "Claude Code MCP sync: failed to persist synced-id sidecar");
|
|
4286
4695
|
}
|
|
@@ -4295,6 +4704,7 @@ const log = createLogger("McpApplier");
|
|
|
4295
4704
|
const CONFIG_TEMPLATE_RE = /\{\{config\.([a-zA-Z0-9_]+)\}\}/g;
|
|
4296
4705
|
const CREDENTIALS_TEMPLATE_RE = /\{\{credentials\.([a-z0-9-]+)\.([a-zA-Z0-9_]+)\}\}/g;
|
|
4297
4706
|
const ALFE_TEMPLATE_RE = /\{\{alfe\.([a-zA-Z0-9_]+)\}\}/g;
|
|
4707
|
+
const RESERVED_RUNTIME_ENV_KEYS = new Set(["ALFE_API_KEY"]);
|
|
4298
4708
|
/**
|
|
4299
4709
|
* Budget for the activation-time warm of each applied server. Matches the
|
|
4300
4710
|
* bundler's default `connectTimeoutMs` so the warm never reports "failed"
|
|
@@ -4329,10 +4739,12 @@ var McpApplier = class {
|
|
|
4329
4739
|
async applyForIntegration(integrationId, servers, mergedConfig, opts) {
|
|
4330
4740
|
const owner = `integration:${integrationId}`;
|
|
4331
4741
|
const applied = [];
|
|
4742
|
+
const declaredIds = new Set(servers.map((server) => `${integrationId}-${server.id}`));
|
|
4332
4743
|
for (const server of servers) {
|
|
4333
4744
|
const id = `${integrationId}-${server.id}`;
|
|
4334
4745
|
const envResolved = await this.resolveEnv(server, mergedConfig, opts?.connectionId);
|
|
4335
4746
|
if (envResolved == null) {
|
|
4747
|
+
await this.manager.removeServer(id, { expectedOwner: owner });
|
|
4336
4748
|
log.info({
|
|
4337
4749
|
integrationId,
|
|
4338
4750
|
server: server.id,
|
|
@@ -4346,6 +4758,7 @@ var McpApplier = class {
|
|
|
4346
4758
|
});
|
|
4347
4759
|
applied.push(id);
|
|
4348
4760
|
}
|
|
4761
|
+
for (const { id, entry } of this.manager.listServers()) if (entry.owner === owner && !declaredIds.has(id)) await this.manager.removeServer(id, { expectedOwner: owner });
|
|
4349
4762
|
for (const id of applied) this.manager.warmServer(id, MCP_ACTIVATION_WARM_TIMEOUT_MS).then((status) => {
|
|
4350
4763
|
if (!status) return;
|
|
4351
4764
|
if (status.connected) log.info({
|
|
@@ -4421,13 +4834,17 @@ var McpApplier = class {
|
|
|
4421
4834
|
}
|
|
4422
4835
|
const resolved = {};
|
|
4423
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
|
+
}
|
|
4424
4841
|
const interpolated = interpolateString(value, mergedConfig, credentialsCache, this.platform);
|
|
4425
|
-
if (
|
|
4842
|
+
if (hasUnresolvedTemplate(interpolated)) {
|
|
4426
4843
|
log.warn({
|
|
4427
4844
|
provider,
|
|
4428
4845
|
connectionId,
|
|
4429
4846
|
key
|
|
4430
|
-
}, "
|
|
4847
|
+
}, "MCP interpolation left a placeholder — skipping registration");
|
|
4431
4848
|
return null;
|
|
4432
4849
|
}
|
|
4433
4850
|
resolved[key] = interpolated;
|
|
@@ -4435,8 +4852,8 @@ var McpApplier = class {
|
|
|
4435
4852
|
return resolved;
|
|
4436
4853
|
}
|
|
4437
4854
|
};
|
|
4438
|
-
function
|
|
4439
|
-
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);
|
|
4440
4857
|
}
|
|
4441
4858
|
function interpolateString(value, mergedConfig, credentials, platform) {
|
|
4442
4859
|
let next = value.replace(CONFIG_TEMPLATE_RE, (match, configKey) => {
|