@alfe.ai/integrations 0.6.0 → 0.6.2

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