@alfe.ai/integrations 0.5.4 → 0.6.1

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