@alfe.ai/integrations 0.1.5 → 0.1.6

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
@@ -287,6 +287,13 @@ interface RuntimeApplier {
287
287
  applyPlugin(pkg: string, integrationInstallPath: string, opts?: {
288
288
  force?: boolean;
289
289
  }): Promise<void>;
290
+ /**
291
+ * Optional: pre-trust an integration's full plugin set in one write before
292
+ * the per-plugin install loop. Runtimes whose config writes trigger a reload
293
+ * (OpenClaw) implement this to collapse N allowlist writes into one. Absent
294
+ * → the manager relies on `applyPlugin` to allowlist each plugin itself.
295
+ */
296
+ ensurePluginsAllowed?(pkgs: string[]): Promise<void>;
290
297
  /** Remove a plugin package from this runtime */
291
298
  removePlugin(pkg: string): Promise<void>;
292
299
  /** Copy a skill directory into this runtime's skills location */
@@ -748,6 +755,10 @@ interface OpenClawApplierOptions {
748
755
  skillsDir?: string;
749
756
  /** Path to the integration tracking file (defaults to {home}/config.json) */
750
757
  configPath?: string;
758
+ /** Max retries for a transient `openclaw config set` failure (default 3) */
759
+ configSetRetries?: number;
760
+ /** Backoff between `openclaw config set` retries, ms (default 750; set 0 in tests) */
761
+ configSetRetryDelayMs?: number;
751
762
  }
752
763
  declare class OpenClawApplier implements RuntimeApplier {
753
764
  readonly runtime = "openclaw";
@@ -755,15 +766,44 @@ declare class OpenClawApplier implements RuntimeApplier {
755
766
  private agentWorkspace;
756
767
  private skillsDir;
757
768
  private trackingPath;
769
+ private configSetRetries;
770
+ private configSetRetryDelayMs;
771
+ /** Serializes all `openclaw config set` writes so they never interleave with each other or the hot-reload they trigger. */
772
+ private configSetQueue;
758
773
  constructor(options: OpenClawApplierOptions);
774
+ /** Convenience: `openclaw config set <args>`, serialized + retried. */
775
+ private runConfigSet;
776
+ /**
777
+ * Run `openclaw config <args>` (set/unset), serialized against every other
778
+ * config write and retried with backoff. See CONFIG_SET_RETRIES for why:
779
+ * each write triggers a runtime hot-reload that rewrites openclaw.json, and a
780
+ * follow-up command that races the reload fails with a bare "Command failed".
781
+ *
782
+ * Throws an Error whose (scrubbed) message includes stderr after retries are
783
+ * exhausted, so the real cause propagates to the integration errorMessage
784
+ * without leaking the value payload.
785
+ */
786
+ private runConfigCommand;
759
787
  applyPlugin(spec: string, _installPath?: string, opts?: {
760
788
  force?: boolean;
761
789
  }): Promise<void>;
762
790
  /**
763
- * Ensure the plugin is in plugins.allow in openclaw.json.
791
+ * Ensure one or more plugins are in plugins.allow in openclaw.json.
764
792
  * Uses `openclaw config set` to avoid clobbering OpenClaw's own file format.
793
+ *
794
+ * Prefer passing the FULL set of plugins for an integration in a single call
795
+ * (see `ensurePluginsAllowed`): each write triggers a runtime hot-reload, so
796
+ * one write for N plugins is one reload instead of N.
765
797
  */
766
798
  private ensurePluginsAllow;
799
+ /**
800
+ * Pre-trust every plugin an integration ships in a SINGLE plugins.allow
801
+ * write, before any are installed. Called once by the manager ahead of the
802
+ * per-plugin install loop so activation triggers one hot-reload for the
803
+ * allowlist instead of one per plugin. `applyPlugin`'s own per-plugin
804
+ * `ensurePluginsAllow` then finds nothing missing and is a no-op.
805
+ */
806
+ ensurePluginsAllowed(specs: string[]): Promise<void>;
767
807
  /**
768
808
  * Check if a plugin is already installed.
769
809
  *
package/dist/index.js CHANGED
@@ -1113,7 +1113,7 @@ var IntegrationManager = class {
1113
1113
  message: "Already active"
1114
1114
  }
1115
1115
  };
1116
- if (entry.status !== "configured" && entry.status !== "installed") return this.err("INVALID_STATE", `Cannot activate integration in "${entry.status}" state`);
1116
+ if (entry.status !== "configured" && entry.status !== "installed" && entry.status !== "error") return this.err("INVALID_STATE", `Cannot activate integration in "${entry.status}" state`);
1117
1117
  this.log.info(`Activating integration: ${integrationId}`);
1118
1118
  try {
1119
1119
  const installPath = this.installer.getInstallPath(integrationId);
@@ -1136,6 +1136,11 @@ var IntegrationManager = class {
1136
1136
  continue;
1137
1137
  }
1138
1138
  const { plugins, skills, config: runtimeConfig } = resolveInstallsForRuntime(manifest, runtimeName);
1139
+ if (applier.ensurePluginsAllowed && plugins.length > 0) try {
1140
+ await applier.ensurePluginsAllowed(plugins.map((p) => p.package));
1141
+ } catch (err) {
1142
+ this.log.warn(`Failed to pre-trust plugins for ${integrationId} on ${runtimeName}: ${err instanceof Error ? err.message : String(err)}`);
1143
+ }
1139
1144
  const pluginFailures = [];
1140
1145
  for (const plugin of plugins) {
1141
1146
  this.log.info(`Applying plugin ${plugin.package} to ${runtimeName}`);
@@ -1613,6 +1618,45 @@ const DEFAULT_SKILLS_DIR = join(homedir(), ".alfe", "skills");
1613
1618
  * LLM/voice traffic through its own AI proxy, so leaving them gated off is correct.
1614
1619
  */
1615
1620
  const BUNDLED_BASELINE_ALLOW = ["browser"];
1621
+ /**
1622
+ * Every `openclaw config set` triggers an async hot-reload of the running
1623
+ * OpenClaw runtime, which itself rewrites openclaw.json. If the next
1624
+ * `config set` fires before that reload settles it collides with the
1625
+ * in-flight write (OpenClaw's clobber protection) and exits non-zero with a
1626
+ * bare "Command failed". We serialize all config writes through one promise
1627
+ * chain and retry transient failures with a short backoff so the reload has
1628
+ * time to settle between writes.
1629
+ */
1630
+ const CONFIG_SET_RETRIES = 3;
1631
+ const CONFIG_SET_RETRY_DELAY_MS = 750;
1632
+ const delay = (ms) => new Promise((resolve) => {
1633
+ setTimeout(resolve, ms);
1634
+ });
1635
+ /**
1636
+ * Describe a `config set` target WITHOUT leaking values. The value argument is
1637
+ * the JSON payload (model config, the gateway loopback token via the alfe hook,
1638
+ * etc.) and must never reach the integration's `errorMessage`, which projects
1639
+ * to the user-facing dashboard. Keep only the program + the path/flag.
1640
+ */
1641
+ function redactConfigSetTarget(args) {
1642
+ return `openclaw config ${args.slice(0, 2).join(" ")}`.trim();
1643
+ }
1644
+ /**
1645
+ * Build a diagnosable BUT scrubbed message from an execFile rejection. Node's
1646
+ * execFile error `message` is "Command failed: <full argv>" — which for
1647
+ * `config set` embeds the value payload — so we never surface it. We keep the
1648
+ * redacted target, the exit code, and `stderr` (openclaw's own error text).
1649
+ */
1650
+ function configSetErrorMessage(err, args) {
1651
+ const target = redactConfigSetTarget(args);
1652
+ if (err instanceof Error) {
1653
+ const e = err;
1654
+ const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
1655
+ const code = e.code !== void 0 ? ` (exit ${e.code})` : "";
1656
+ return stderr ? `${target} failed${code}: ${stderr}` : `${target} failed${code}`;
1657
+ }
1658
+ return `${target} failed: ${String(err)}`;
1659
+ }
1616
1660
  function flattenConfig(obj, prefix = "") {
1617
1661
  const entries = [];
1618
1662
  for (const [key, val] of Object.entries(obj)) {
@@ -1677,6 +1721,10 @@ var OpenClawApplier = class {
1677
1721
  agentWorkspace;
1678
1722
  skillsDir;
1679
1723
  trackingPath;
1724
+ configSetRetries;
1725
+ configSetRetryDelayMs;
1726
+ /** Serializes all `openclaw config set` writes so they never interleave with each other or the hot-reload they trigger. */
1727
+ configSetQueue = Promise.resolve();
1680
1728
  constructor(options) {
1681
1729
  const home = options.home ?? options.workspace;
1682
1730
  if (!home) throw new Error("OpenClawApplier requires `home` (or legacy `workspace`) option");
@@ -1684,6 +1732,39 @@ var OpenClawApplier = class {
1684
1732
  this.agentWorkspace = options.agentWorkspace ?? join(home, "workspace");
1685
1733
  this.skillsDir = options.skillsDir ?? DEFAULT_SKILLS_DIR;
1686
1734
  this.trackingPath = options.configPath ?? join(this.home, "config.json");
1735
+ this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES;
1736
+ this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS;
1737
+ }
1738
+ /** Convenience: `openclaw config set <args>`, serialized + retried. */
1739
+ runConfigSet(setArgs, opts = {}) {
1740
+ return this.runConfigCommand(["set", ...setArgs], opts);
1741
+ }
1742
+ /**
1743
+ * Run `openclaw config <args>` (set/unset), serialized against every other
1744
+ * config write and retried with backoff. See CONFIG_SET_RETRIES for why:
1745
+ * each write triggers a runtime hot-reload that rewrites openclaw.json, and a
1746
+ * follow-up command that races the reload fails with a bare "Command failed".
1747
+ *
1748
+ * Throws an Error whose (scrubbed) message includes stderr after retries are
1749
+ * exhausted, so the real cause propagates to the integration errorMessage
1750
+ * without leaking the value payload.
1751
+ */
1752
+ runConfigCommand(args, opts = {}) {
1753
+ const timeout = opts.timeout ?? 1e4;
1754
+ const run = async () => {
1755
+ let lastErr;
1756
+ for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
1757
+ await execFileAsync("openclaw", ["config", ...args], { timeout });
1758
+ return;
1759
+ } catch (err) {
1760
+ lastErr = err;
1761
+ if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay(this.configSetRetryDelayMs * 2 ** attempt);
1762
+ }
1763
+ throw new Error(configSetErrorMessage(lastErr, args));
1764
+ };
1765
+ const result = this.configSetQueue.then(run, run);
1766
+ this.configSetQueue = result.catch(() => void 0);
1767
+ return result;
1687
1768
  }
1688
1769
  async applyPlugin(spec, _installPath, opts) {
1689
1770
  const pkg = stripPluginVersion(spec);
@@ -1740,10 +1821,15 @@ var OpenClawApplier = class {
1740
1821
  }
1741
1822
  }
1742
1823
  /**
1743
- * Ensure the plugin is in plugins.allow in openclaw.json.
1824
+ * Ensure one or more plugins are in plugins.allow in openclaw.json.
1744
1825
  * Uses `openclaw config set` to avoid clobbering OpenClaw's own file format.
1826
+ *
1827
+ * Prefer passing the FULL set of plugins for an integration in a single call
1828
+ * (see `ensurePluginsAllowed`): each write triggers a runtime hot-reload, so
1829
+ * one write for N plugins is one reload instead of N.
1745
1830
  */
1746
- async ensurePluginsAllow(pkg) {
1831
+ async ensurePluginsAllow(pkgs) {
1832
+ const wanted = Array.isArray(pkgs) ? pkgs : [pkgs];
1747
1833
  let currentAllow = [];
1748
1834
  try {
1749
1835
  const { stdout } = await execFileAsync("openclaw", [
@@ -1754,24 +1840,30 @@ var OpenClawApplier = class {
1754
1840
  const parsed = JSON.parse(stdout.trim());
1755
1841
  if (Array.isArray(parsed)) currentAllow = parsed;
1756
1842
  } catch {}
1757
- const missing = [...new Set([pkg, ...BUNDLED_BASELINE_ALLOW])].filter((id) => !currentAllow.includes(id));
1843
+ const missing = [...new Set([...wanted, ...BUNDLED_BASELINE_ALLOW])].filter((id) => !currentAllow.includes(id));
1758
1844
  if (missing.length === 0) return;
1759
1845
  const updated = [...currentAllow, ...missing];
1760
1846
  try {
1761
- await execFileAsync("openclaw", [
1762
- "config",
1763
- "set",
1764
- "plugins.allow",
1765
- JSON.stringify(updated)
1766
- ], { timeout: 1e4 });
1847
+ await this.runConfigSet(["plugins.allow", JSON.stringify(updated)]);
1767
1848
  } catch (err) {
1768
1849
  log$1.warn({
1769
1850
  err: err instanceof Error ? err.message : String(err),
1770
- pkg
1851
+ pkgs: wanted
1771
1852
  }, "Failed to set plugins.allow via openclaw config set");
1772
1853
  }
1773
1854
  }
1774
1855
  /**
1856
+ * Pre-trust every plugin an integration ships in a SINGLE plugins.allow
1857
+ * write, before any are installed. Called once by the manager ahead of the
1858
+ * per-plugin install loop so activation triggers one hot-reload for the
1859
+ * allowlist instead of one per plugin. `applyPlugin`'s own per-plugin
1860
+ * `ensurePluginsAllow` then finds nothing missing and is a no-op.
1861
+ */
1862
+ async ensurePluginsAllowed(specs) {
1863
+ if (specs.length === 0) return;
1864
+ await this.ensurePluginsAllow(specs.map(stripPluginVersion));
1865
+ }
1866
+ /**
1775
1867
  * Check if a plugin is already installed.
1776
1868
  *
1777
1869
  * OpenClaw 2026.4 stored plugins under `~/.openclaw/extensions/{pkg-name-with-dashes}-{hash}`.
@@ -1902,12 +1994,7 @@ var OpenClawApplier = class {
1902
1994
  const merged = { ...await readParentObject(parentPath) };
1903
1995
  for (const [k, v] of dottedKvs) merged[k] = v;
1904
1996
  try {
1905
- await execFileAsync("openclaw", [
1906
- "config",
1907
- "set",
1908
- parentPath,
1909
- JSON.stringify(merged)
1910
- ], { timeout: 1e4 });
1997
+ await this.runConfigSet([parentPath, JSON.stringify(merged)]);
1911
1998
  } catch (err) {
1912
1999
  log$1.error({
1913
2000
  err: err instanceof Error ? err.message : String(err),
@@ -1917,12 +2004,7 @@ var OpenClawApplier = class {
1917
2004
  }
1918
2005
  }
1919
2006
  if (leaves.length > 0) try {
1920
- await execFileAsync("openclaw", [
1921
- "config",
1922
- "set",
1923
- "--batch-json",
1924
- JSON.stringify(leaves)
1925
- ], { timeout: 1e4 });
2007
+ await this.runConfigSet(["--batch-json", JSON.stringify(leaves)]);
1926
2008
  } catch (err) {
1927
2009
  log$1.error({
1928
2010
  err: err instanceof Error ? err.message : String(err),
@@ -1944,11 +2026,7 @@ var OpenClawApplier = class {
1944
2026
  const integrationConfig = integrations[integrationId];
1945
2027
  const { leaves, subtreesByParent } = partitionEntries(flattenConfig(integrationConfig));
1946
2028
  for (const { path } of leaves) try {
1947
- await execFileAsync("openclaw", [
1948
- "config",
1949
- "unset",
1950
- path
1951
- ], { timeout: 1e4 });
2029
+ await this.runConfigCommand(["unset", path]);
1952
2030
  } catch (err) {
1953
2031
  log$1.warn({
1954
2032
  err: err instanceof Error ? err.message : String(err),
@@ -1960,17 +2038,8 @@ var OpenClawApplier = class {
1960
2038
  const remaining = Object.fromEntries(Object.entries(existing).filter(([k]) => !dottedKvs.has(k)));
1961
2039
  if (Object.keys(existing).length === 0) continue;
1962
2040
  try {
1963
- if (Object.keys(remaining).length === 0) await execFileAsync("openclaw", [
1964
- "config",
1965
- "unset",
1966
- parentPath
1967
- ], { timeout: 1e4 });
1968
- else await execFileAsync("openclaw", [
1969
- "config",
1970
- "set",
1971
- parentPath,
1972
- JSON.stringify(remaining)
1973
- ], { timeout: 1e4 });
2041
+ if (Object.keys(remaining).length === 0) await this.runConfigCommand(["unset", parentPath]);
2042
+ else await this.runConfigSet([parentPath, JSON.stringify(remaining)]);
1974
2043
  } catch (err) {
1975
2044
  log$1.warn({
1976
2045
  err: err instanceof Error ? err.message : String(err),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",