@alfe.ai/integrations 0.2.0 → 0.2.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.d.ts CHANGED
@@ -796,6 +796,26 @@ declare class OpenClawApplier implements RuntimeApplier {
796
796
  * without leaking the value payload.
797
797
  */
798
798
  private runConfigCommand;
799
+ /**
800
+ * Read back a single config path and compare it to the value we tried to
801
+ * write. Used by `applyConfig`'s verify-after-write: a `config set` can exit
802
+ * non-zero (a hot-reload races the write, OpenClaw's clobber protection
803
+ * fires) even though the value actually landed, exactly like `applyPlugin`'s
804
+ * install-then-check tolerance.
805
+ *
806
+ * OpenClaw REDACTS sensitive values on `config get`, returning the literal
807
+ * `__OPENCLAW_REDACTED__` instead of the real value. Treat that as a match:
808
+ * the key exists and OpenClaw is hiding it, so a deep-equal against the
809
+ * intended value would otherwise be a false negative and force a re-throw.
810
+ */
811
+ private configValueMatches;
812
+ /**
813
+ * Run a read-back `check` with the same retry/backoff cadence as the config
814
+ * writes — the settling hot-reload may still be rewriting openclaw.json when
815
+ * we first read back, so a single check can be a false negative. Returns true
816
+ * on the first success, false once all attempts are exhausted.
817
+ */
818
+ private verifyApplied;
799
819
  applyPlugin(spec: string, _installPath?: string, opts?: {
800
820
  force?: boolean;
801
821
  }): Promise<void>;
@@ -893,9 +913,11 @@ declare class HermesApplier implements RuntimeApplier {
893
913
  readonly runtime = "hermes";
894
914
  private home;
895
915
  private trackingPath;
916
+ /** `~/.hermes/config.yaml` — read-merge-written on removal (no `config unset`). */
917
+ private configYamlPath;
896
918
  private configSetRetries;
897
919
  private configSetRetryDelayMs;
898
- /** Serializes all `hermes config` writes so they never interleave. */
920
+ /** Serializes all config mutations (CLI `set` + config.yaml delete) so they never interleave. */
899
921
  private configSetQueue;
900
922
  constructor(options?: HermesApplierOptions);
901
923
  /**
@@ -909,8 +931,9 @@ declare class HermesApplier implements RuntimeApplier {
909
931
  /**
910
932
  * Remove config previously applied by an integration.
911
933
  *
912
- * Reads the tracking file to find which keys this integration set, then
913
- * removes each via `hermes config unset`. Clears the tracking entry.
934
+ * Reads the tracking file to find which dotted keys this integration set, then
935
+ * DELETES each from `~/.hermes/config.yaml` via a read-merge-write (there is no
936
+ * `hermes config unset` verb — see the file header). Clears the tracking entry.
914
937
  */
915
938
  removeConfig(integrationId: string): Promise<void>;
916
939
  /**
@@ -947,21 +970,25 @@ declare class HermesApplier implements RuntimeApplier {
947
970
  /** Convenience: `hermes config set <args>`, serialized + retried. */
948
971
  private runConfigSet;
949
972
  /**
950
- * Unset a single config key. The unset verb is isolated HERE so there is one
951
- * place to change if the spike proves `hermes config unset` is unavailable.
952
- *
953
- * FALLBACK (do NOT pre-build): if `hermes config unset` does not exist, the
954
- * substitute is `hermes config set <key> ""` (clear the value) or a
955
- * read-merge-write of config.yaml. Not implemented now — `unset` is the
956
- * documented verb; confirm in the Phase-0 spike before adding a fallback.
957
- * TODO(phase-0 spike): confirm `hermes config unset <key>` exists.
958
- */
959
- private unsetConfigKey;
960
- /**
961
- * Run `hermes config <args>` (set/unset), serialized against every other
962
- * config write and retried with backoff. Throws an Error whose (scrubbed)
963
- * message includes stderr after retries are exhausted, so the real cause
964
- * propagates to the integration errorMessage without leaking the value.
973
+ * Delete a set of dotted config keys from `~/.hermes/config.yaml`. There is no
974
+ * `hermes config unset` verb (see file header), so removal is a read-merge-write
975
+ * of the YAML via the Document API — preserving user-authored keys, comments,
976
+ * and formatting. Serialized against every other config mutation on the same
977
+ * queue so a delete can't interleave with an in-flight `hermes config set`.
978
+ */
979
+ private deleteConfigKeys;
980
+ /**
981
+ * Synchronous config.yaml read-merge-write: parse the doc, delete each dotted
982
+ * key (`model.base_url` → `deleteIn(['model','base_url'])`), and write back only
983
+ * if the text actually changed. No-op if config.yaml doesn't exist yet.
984
+ */
985
+ private deleteConfigKeysSync;
986
+ /**
987
+ * Run `hermes config <args>` (only `set` is used — removal goes via
988
+ * {@link deleteConfigKeys}), serialized against every other config mutation and
989
+ * retried with backoff. Throws an Error whose (scrubbed) message includes stderr
990
+ * after retries are exhausted, so the real cause propagates to the integration
991
+ * errorMessage without leaking the value.
965
992
  */
966
993
  private runConfigCommand;
967
994
  private readTracking;
package/dist/index.js CHANGED
@@ -1702,10 +1702,40 @@ const BUNDLED_BASELINE_ALLOW = ["browser"];
1702
1702
  */
1703
1703
  const CONFIG_SET_RETRIES$1 = 3;
1704
1704
  const CONFIG_SET_RETRY_DELAY_MS$1 = 750;
1705
+ /**
1706
+ * Sentinel OpenClaw returns from `config get` in place of a sensitive value —
1707
+ * the key IS set, OpenClaw is just hiding it. Verify-after-write must treat a
1708
+ * read-back of this as "present/matches" rather than a mismatch (see
1709
+ * `configValueMatches`).
1710
+ */
1711
+ const OPENCLAW_REDACTED = "__OPENCLAW_REDACTED__";
1705
1712
  const delay$1 = (ms) => new Promise((resolve) => {
1706
1713
  setTimeout(resolve, ms);
1707
1714
  });
1708
1715
  /**
1716
+ * Structural equality for config read-back comparison. Primitives compare by
1717
+ * `Object.is`; arrays are order-SENSITIVE (config arrays are positional);
1718
+ * plain objects are order-INSENSITIVE (key order in JSON is not meaningful).
1719
+ * Used only to confirm a value landed after a `config set` exited non-zero.
1720
+ */
1721
+ function deepEqual(a, b) {
1722
+ if (Object.is(a, b)) return true;
1723
+ if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
1724
+ const aIsArr = Array.isArray(a);
1725
+ const bIsArr = Array.isArray(b);
1726
+ if (aIsArr !== bIsArr) return false;
1727
+ if (aIsArr && bIsArr) {
1728
+ if (a.length !== b.length) return false;
1729
+ return a.every((v, i) => deepEqual(v, b[i]));
1730
+ }
1731
+ const ao = a;
1732
+ const bo = b;
1733
+ const aKeys = Object.keys(ao);
1734
+ const bKeys = Object.keys(bo);
1735
+ if (aKeys.length !== bKeys.length) return false;
1736
+ return aKeys.every((k) => Object.prototype.hasOwnProperty.call(bo, k) && deepEqual(ao[k], bo[k]));
1737
+ }
1738
+ /**
1709
1739
  * Describe a `config set` target WITHOUT leaking values. The value argument is
1710
1740
  * the JSON payload (model config, the gateway loopback token via the alfe hook,
1711
1741
  * etc.) and must never reach the integration's `errorMessage`, which projects
@@ -1793,6 +1823,45 @@ var OpenClawApplier = class {
1793
1823
  this.configSetQueue = result.catch(() => void 0);
1794
1824
  return result;
1795
1825
  }
1826
+ /**
1827
+ * Read back a single config path and compare it to the value we tried to
1828
+ * write. Used by `applyConfig`'s verify-after-write: a `config set` can exit
1829
+ * non-zero (a hot-reload races the write, OpenClaw's clobber protection
1830
+ * fires) even though the value actually landed, exactly like `applyPlugin`'s
1831
+ * install-then-check tolerance.
1832
+ *
1833
+ * OpenClaw REDACTS sensitive values on `config get`, returning the literal
1834
+ * `__OPENCLAW_REDACTED__` instead of the real value. Treat that as a match:
1835
+ * the key exists and OpenClaw is hiding it, so a deep-equal against the
1836
+ * intended value would otherwise be a false negative and force a re-throw.
1837
+ */
1838
+ async configValueMatches(path, expected) {
1839
+ try {
1840
+ const { stdout } = await execFileAsync$1("openclaw", [
1841
+ "config",
1842
+ "get",
1843
+ path
1844
+ ], { timeout: 1e4 });
1845
+ const actual = JSON.parse(stdout.trim());
1846
+ if (actual === OPENCLAW_REDACTED) return true;
1847
+ return deepEqual(actual, expected);
1848
+ } catch {
1849
+ return false;
1850
+ }
1851
+ }
1852
+ /**
1853
+ * Run a read-back `check` with the same retry/backoff cadence as the config
1854
+ * writes — the settling hot-reload may still be rewriting openclaw.json when
1855
+ * we first read back, so a single check can be a false negative. Returns true
1856
+ * on the first success, false once all attempts are exhausted.
1857
+ */
1858
+ async verifyApplied(check) {
1859
+ for (let attempt = 0; attempt <= this.configSetRetries; attempt++) {
1860
+ if (await check()) return true;
1861
+ if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay$1(this.configSetRetryDelayMs * 2 ** attempt);
1862
+ }
1863
+ return false;
1864
+ }
1796
1865
  async applyPlugin(spec, _installPath, opts) {
1797
1866
  const pkg = stripPluginVersion(spec);
1798
1867
  await this.ensurePluginsAllow(pkg);
@@ -2023,6 +2092,13 @@ var OpenClawApplier = class {
2023
2092
  try {
2024
2093
  await this.runConfigSet([parentPath, JSON.stringify(merged)]);
2025
2094
  } catch (err) {
2095
+ if (await this.verifyApplied(async () => {
2096
+ const after = await readParentObject(parentPath);
2097
+ return [...dottedKvs.entries()].every(([k, v]) => deepEqual(after[k], v));
2098
+ })) {
2099
+ log$3.warn({ parentPath }, "openclaw config set exited non-zero but config landed — continuing");
2100
+ continue;
2101
+ }
2026
2102
  log$3.error({
2027
2103
  err: err instanceof Error ? err.message : String(err),
2028
2104
  parentPath
@@ -2033,11 +2109,16 @@ var OpenClawApplier = class {
2033
2109
  if (leaves.length > 0) try {
2034
2110
  await this.runConfigSet(["--batch-json", JSON.stringify(leaves)]);
2035
2111
  } catch (err) {
2036
- log$3.error({
2037
- err: err instanceof Error ? err.message : String(err),
2038
- batch: leaves
2039
- }, "Failed to set config via openclaw config set --batch-json");
2040
- throw err;
2112
+ if (await this.verifyApplied(async () => {
2113
+ return (await Promise.all(leaves.map((l) => this.configValueMatches(l.path, l.value)))).every(Boolean);
2114
+ })) log$3.warn({ count: leaves.length }, "openclaw config set --batch-json exited non-zero but config landed — continuing");
2115
+ else {
2116
+ log$3.error({
2117
+ err: err instanceof Error ? err.message : String(err),
2118
+ batch: leaves
2119
+ }, "Failed to set config via openclaw config set --batch-json");
2120
+ throw err;
2121
+ }
2041
2122
  }
2042
2123
  }
2043
2124
  /**
@@ -2112,11 +2193,17 @@ var OpenClawApplier = class {
2112
2193
  * HermesApplier — applies integration config (and, later, native plugins) to
2113
2194
  * the Hermes runtime (Nous Research's Python agent).
2114
2195
  *
2115
- * Hermes config lives in `~/.hermes/config.yaml` (YAML) and is mutated via the
2116
- * `hermes config set/unset <dotted.key> <value>` CLI we never write the YAML
2117
- * file directly (mirrors the OpenClaw rule of letting the runtime own its own
2118
- * config format). Per-integration contributions are tracked in a separate file
2119
- * (`~/.hermes/.alfe-integrations.json`) so removal is precise.
2196
+ * Hermes config lives in `~/.hermes/config.yaml` (YAML). We APPLY config via the
2197
+ * `hermes config set <dotted.key> <value>` CLI (letting the runtime own its
2198
+ * write path, mirroring the OpenClaw rule). REMOVAL is different: `hermes config
2199
+ * unset` does NOT exist CONFIRMED against Hermes source, whose `config`
2200
+ * subcommands are only show/set/edit/path/env-path/check/migrate. So teardown is
2201
+ * a config.yaml read-merge-write that deletes the Alfe-applied dotted keys via
2202
+ * the `yaml` Document API (`parseDocument` + `deleteIn`), preserving
2203
+ * user-authored keys, comments, and formatting (the same approach
2204
+ * `hermes-mcp-sync.ts` uses for `mcp_servers`). Per-integration contributions are
2205
+ * tracked in a separate file (`~/.hermes/.alfe-integrations.json`) so we know
2206
+ * exactly which keys to delete.
2120
2207
  *
2121
2208
  * Scope (Phase 1, MCP-first hybrid):
2122
2209
  * - config: SUPPORTED — consumes `installs.runtimes.hermes.config` (the AI-proxy
@@ -2162,9 +2249,9 @@ function stringifyConfigValue(value) {
2162
2249
  return typeof value === "string" ? value : JSON.stringify(value);
2163
2250
  }
2164
2251
  /**
2165
- * Describe a `config set/unset` target WITHOUT leaking values. The value
2166
- * argument can be a secret (`model.api_key`), so it must never reach the
2167
- * integration's user-facing `errorMessage`. Keep only the program + verb + key.
2252
+ * Describe a `config set` target WITHOUT leaking values. The value argument can
2253
+ * be a secret (`model.api_key`), so it must never reach the integration's
2254
+ * user-facing `errorMessage`. Keep only the program + verb + key.
2168
2255
  */
2169
2256
  function redactConfigTarget(args) {
2170
2257
  return `hermes config ${args.slice(0, 2).join(" ")}`.trim();
@@ -2189,13 +2276,16 @@ var HermesApplier = class {
2189
2276
  runtime = "hermes";
2190
2277
  home;
2191
2278
  trackingPath;
2279
+ /** `~/.hermes/config.yaml` — read-merge-written on removal (no `config unset`). */
2280
+ configYamlPath;
2192
2281
  configSetRetries;
2193
2282
  configSetRetryDelayMs;
2194
- /** Serializes all `hermes config` writes so they never interleave. */
2283
+ /** Serializes all config mutations (CLI `set` + config.yaml delete) so they never interleave. */
2195
2284
  configSetQueue = Promise.resolve();
2196
2285
  constructor(options = {}) {
2197
2286
  this.home = options.home ?? options.workspace ?? DEFAULT_HERMES_HOME$1;
2198
2287
  this.trackingPath = options.configPath ?? join(this.home, ".alfe-integrations.json");
2288
+ this.configYamlPath = join(this.home, "config.yaml");
2199
2289
  this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES;
2200
2290
  this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS;
2201
2291
  }
@@ -2230,8 +2320,9 @@ var HermesApplier = class {
2230
2320
  /**
2231
2321
  * Remove config previously applied by an integration.
2232
2322
  *
2233
- * Reads the tracking file to find which keys this integration set, then
2234
- * removes each via `hermes config unset`. Clears the tracking entry.
2323
+ * Reads the tracking file to find which dotted keys this integration set, then
2324
+ * DELETES each from `~/.hermes/config.yaml` via a read-merge-write (there is no
2325
+ * `hermes config unset` verb — see the file header). Clears the tracking entry.
2235
2326
  */
2236
2327
  async removeConfig(integrationId) {
2237
2328
  const tracking = this.readTracking();
@@ -2239,13 +2330,13 @@ var HermesApplier = class {
2239
2330
  if (!(integrationId in integrations)) return;
2240
2331
  const integrationConfig = integrations[integrationId];
2241
2332
  const { leaves } = partitionEntries(flattenConfig(integrationConfig));
2242
- for (const { path } of leaves) try {
2243
- await this.unsetConfigKey(path);
2333
+ try {
2334
+ await this.deleteConfigKeys(leaves.map(({ path }) => path));
2244
2335
  } catch (err) {
2245
2336
  log$2.warn({
2246
2337
  err: err instanceof Error ? err.message : String(err),
2247
- key: path
2248
- }, "Failed to unset config via hermes config unset");
2338
+ integrationId
2339
+ }, "Failed to delete Alfe config keys from ~/.hermes/config.yaml");
2249
2340
  }
2250
2341
  tracking._integrations = Object.fromEntries(Object.entries(integrations).filter(([key]) => key !== integrationId));
2251
2342
  this.writeTracking(tracking);
@@ -2329,23 +2420,40 @@ var HermesApplier = class {
2329
2420
  return this.runConfigCommand(["set", ...setArgs]);
2330
2421
  }
2331
2422
  /**
2332
- * Unset a single config key. The unset verb is isolated HERE so there is one
2333
- * place to change if the spike proves `hermes config unset` is unavailable.
2334
- *
2335
- * FALLBACK (do NOT pre-build): if `hermes config unset` does not exist, the
2336
- * substitute is `hermes config set <key> ""` (clear the value) or a
2337
- * read-merge-write of config.yaml. Not implemented now — `unset` is the
2338
- * documented verb; confirm in the Phase-0 spike before adding a fallback.
2339
- * TODO(phase-0 spike): confirm `hermes config unset <key>` exists.
2423
+ * Delete a set of dotted config keys from `~/.hermes/config.yaml`. There is no
2424
+ * `hermes config unset` verb (see file header), so removal is a read-merge-write
2425
+ * of the YAML via the Document API — preserving user-authored keys, comments,
2426
+ * and formatting. Serialized against every other config mutation on the same
2427
+ * queue so a delete can't interleave with an in-flight `hermes config set`.
2340
2428
  */
2341
- unsetConfigKey(key) {
2342
- return this.runConfigCommand(["unset", key]);
2429
+ deleteConfigKeys(paths) {
2430
+ const run = () => {
2431
+ this.deleteConfigKeysSync(paths);
2432
+ return Promise.resolve();
2433
+ };
2434
+ const result = this.configSetQueue.then(run, run);
2435
+ this.configSetQueue = result.catch(() => void 0);
2436
+ return result;
2343
2437
  }
2344
2438
  /**
2345
- * Run `hermes config <args>` (set/unset), serialized against every other
2346
- * config write and retried with backoff. Throws an Error whose (scrubbed)
2347
- * message includes stderr after retries are exhausted, so the real cause
2348
- * propagates to the integration errorMessage without leaking the value.
2439
+ * Synchronous config.yaml read-merge-write: parse the doc, delete each dotted
2440
+ * key (`model.base_url` `deleteIn(['model','base_url'])`), and write back only
2441
+ * if the text actually changed. No-op if config.yaml doesn't exist yet.
2442
+ */
2443
+ deleteConfigKeysSync(paths) {
2444
+ if (paths.length === 0 || !existsSync(this.configYamlPath)) return;
2445
+ const doc = parseDocument(readFileSync(this.configYamlPath, "utf-8"));
2446
+ const before = doc.toString();
2447
+ for (const path of paths) doc.deleteIn(path.split("."));
2448
+ const after = doc.toString();
2449
+ if (before !== after) writeFileSync(this.configYamlPath, after, "utf-8");
2450
+ }
2451
+ /**
2452
+ * Run `hermes config <args>` (only `set` is used — removal goes via
2453
+ * {@link deleteConfigKeys}), serialized against every other config mutation and
2454
+ * retried with backoff. Throws an Error whose (scrubbed) message includes stderr
2455
+ * after retries are exhausted, so the real cause propagates to the integration
2456
+ * errorMessage without leaking the value.
2349
2457
  */
2350
2458
  runConfigCommand(args) {
2351
2459
  const run = async () => {
@@ -2421,15 +2529,13 @@ var HermesApplier = class {
2421
2529
  const log$1 = createLogger("HermesMcpSync");
2422
2530
  const DEFAULT_HERMES_HOME = join(homedir(), ".hermes");
2423
2531
  /**
2424
- * SPIKE-PENDING SEAM #1 — `${VAR}` interpolation from `~/.hermes/.env`.
2425
- *
2426
- * We inject `ALFE_API_KEY=${ALFE_API_KEY}` into every Alfe-owned stdio server's
2427
- * env and put the real value in `~/.hermes/.env`. This assumes Hermes
2428
- * interpolates `${VAR}` references in `mcp_servers.<id>.env` from `.env` at spawn
2429
- * time. TODO(phase-0 spike): confirm. If Hermes does NOT interpolate, the
2430
- * fallback (NOT built here) is to write the literal `ALFE_API_KEY` VALUE inline
2431
- * into each `mcp_servers.<id>.env` and skip the `.env` file entirely — change
2432
- * `withAlfeApiKey()` + `ensureEnvApiKey()` together in that one case.
2532
+ * SEAM #1 — `${VAR}` interpolation from `~/.hermes/.env`. CONFIRMED (Hermes
2533
+ * source `config.py:5790` `_expand_env_vars`): Hermes loads `~/.hermes/.env` via
2534
+ * dotenv into `os.environ`, then recursively expands `${VAR}` refs across the
2535
+ * ENTIRE merged config including `mcp_servers.<id>.env.*` (and `model.api_key`
2536
+ * / `model.base_url`). So injecting `ALFE_API_KEY=${ALFE_API_KEY}` into every
2537
+ * Alfe-owned stdio server's env and writing the real value to `~/.hermes/.env`
2538
+ * resolves at spawn time. The inline-literal fallback is therefore not needed.
2433
2539
  */
2434
2540
  const ALFE_API_KEY_ENV_REF = "${ALFE_API_KEY}";
2435
2541
  const DEFAULT_DEBOUNCE_MS = 250;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",