@alfe.ai/integrations 0.2.5 → 0.2.7

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.
Files changed (3) hide show
  1. package/dist/index.d.ts +137 -13
  2. package/dist/index.js +264 -69
  3. package/package.json +1 -1
package/dist/index.d.ts CHANGED
@@ -746,6 +746,79 @@ declare function runHook(integrationPath: string, hookScript: string, env?: Reco
746
746
  */
747
747
  declare function runHookWithContext(integrationPath: string, hookScript: string, options: HookEnvOptions): Promise<HookResult>;
748
748
  //#endregion
749
+ //#region src/openclaw-cli-lock.d.ts
750
+ /**
751
+ * OpenClawCliLock — one process-wide async mutex that every openclaw
752
+ * config/state-mutating CLI invocation acquires.
753
+ *
754
+ * ## Why this exists
755
+ *
756
+ * OpenClaw's config store uses OPTIMISTIC CONCURRENCY — a `config set` /
757
+ * `plugins install` reads the current config version, applies its change, and
758
+ * writes back only if the version is unchanged. Two openclaw CLI processes that
759
+ * mutate the store concurrently therefore lose the race: the second write fails
760
+ * with `ConfigMutationConflictError: config changed since last load` (exit 1).
761
+ * And when the long-running runtime (`openclaw gateway run`) is ALSO writing the
762
+ * same `~/.openclaw/state/openclaw.sqlite`, two independent SQLite writers with
763
+ * no shared locking corrupt the DB ("database disk image is malformed").
764
+ *
765
+ * During a DESIRED_STATE reconcile the daemon fires a SWARM of openclaw CLI
766
+ * processes — integration `config set` / `plugins install/uninstall`, the
767
+ * mcp-cleanup `config unset`, the `alfe.config_set` raw write — all mutating the
768
+ * one config store at the same time. This lock serializes ALL of them into a
769
+ * single strict queue so no two openclaw mutations ever overlap.
770
+ *
771
+ * This is the second of two layers. The first is the gateway's `RuntimeGate`,
772
+ * which suspends the runtime CHILD around mutating reconcile branches (so the
773
+ * runtime isn't writing while a CLI subprocess is). This lock covers the daemon's
774
+ * OWN subprocesses racing EACH OTHER — including cross-subsystem races the
775
+ * RuntimeGate doesn't see (mcp-cleanup vs a reconcile plugins-install, a raw
776
+ * `alfe.config_set` vs an in-flight integration apply). Both layers are needed.
777
+ *
778
+ * ## Read-modify-write atomicity
779
+ *
780
+ * A `config get` whose value is then written back (e.g. read `plugins.allow`,
781
+ * union in a plugin, write it back) MUST hold the lock across the WHOLE get→set,
782
+ * not lock the get and set independently — otherwise another openclaw process can
783
+ * mutate the same key between the read and the write and the write clobbers it.
784
+ * Callers pass the entire read-modify-write as one `lock.run(async () => { … })`.
785
+ *
786
+ * ## Re-entrancy
787
+ *
788
+ * This is a plain promise-chain mutex — it is NOT re-entrant. A `run()` callback
789
+ * MUST NOT call `run()` again (it would deadlock: the inner call chains behind
790
+ * the outer, which can't complete until the inner does). Consumers keep their
791
+ * locked-section internals as UNLOCKED helpers and acquire the lock exactly once
792
+ * at the public entry point.
793
+ */
794
+ interface OpenClawCliLock {
795
+ /**
796
+ * Run `fn` with exclusive access to the openclaw config/state store. Calls are
797
+ * serialized in invocation order; each waits for the previous to settle. The
798
+ * caller's result (or rejection) is returned faithfully — a rejection does NOT
799
+ * poison the queue for the next caller.
800
+ */
801
+ run<T>(fn: () => Promise<T>): Promise<T>;
802
+ }
803
+ /**
804
+ * Promise-chain mutex. The tail is kept as a swallowed continuation so a rejected
805
+ * critical section never fails the NEXT unrelated caller, while the current caller
806
+ * still sees the real result/rejection.
807
+ */
808
+ declare class SerialOpenClawCliLock implements OpenClawCliLock {
809
+ private tail;
810
+ run<T>(fn: () => Promise<T>): Promise<T>;
811
+ }
812
+ /**
813
+ * A lock that provides NO serialization — every `run` executes immediately. For
814
+ * runtimes/contexts with no concurrent-openclaw-writer hazard, and for tests that
815
+ * don't need serialization. Keeps consumers from having to special-case an absent
816
+ * lock.
817
+ */
818
+ declare class NoopOpenClawCliLock implements OpenClawCliLock {
819
+ run<T>(fn: () => Promise<T>): Promise<T>;
820
+ }
821
+ //#endregion
749
822
  //#region src/appliers/openclaw-applier.d.ts
750
823
  interface OpenClawApplierOptions {
751
824
  /**
@@ -771,6 +844,14 @@ interface OpenClawApplierOptions {
771
844
  configSetRetries?: number;
772
845
  /** Backoff between `openclaw config set` retries, ms (default 750; set 0 in tests) */
773
846
  configSetRetryDelayMs?: number;
847
+ /**
848
+ * Shared process-wide lock serializing ALL openclaw config/state-mutating CLI
849
+ * calls (this applier's + the gateway's mcp-cleanup / alfe.config_set). Inject
850
+ * the daemon's single instance so every openclaw mutation across BOTH packages
851
+ * queues behind one mutex. Defaults to a private `SerialOpenClawCliLock` so a
852
+ * standalone applier (and existing tests) still serialize their own writes.
853
+ */
854
+ cliLock?: OpenClawCliLock;
774
855
  }
775
856
  declare class OpenClawApplier implements RuntimeApplier {
776
857
  readonly runtime = "openclaw";
@@ -780,22 +861,38 @@ declare class OpenClawApplier implements RuntimeApplier {
780
861
  private trackingPath;
781
862
  private configSetRetries;
782
863
  private configSetRetryDelayMs;
783
- /** Serializes all `openclaw config set` writes so they never interleave with each other or the hot-reload they trigger. */
784
- private configSetQueue;
864
+ /**
865
+ * Shared process-wide mutex serializing ALL openclaw config/state-mutating CLI
866
+ * calls. Replaces the old per-applier `configSetQueue` promise-chain — a single
867
+ * lock so config-set can't interleave with plugins-install (or the gateway's
868
+ * mcp-cleanup / alfe.config_set), which is what corrupts the config store under
869
+ * OpenClaw's optimistic concurrency. NOT re-entrant: every locked public method
870
+ * acquires it ONCE at the top and calls only `*Unlocked` helpers inside.
871
+ */
872
+ private cliLock;
785
873
  constructor(options: OpenClawApplierOptions);
786
- /** Convenience: `openclaw config set <args>`, serialized + retried. */
787
- private runConfigSet;
788
874
  /**
789
- * Run `openclaw config <args>` (set/unset), serialized against every other
790
- * config write and retried with backoff. See CONFIG_SET_RETRIES for why:
791
- * each write triggers a runtime hot-reload that rewrites openclaw.json, and a
792
- * follow-up command that races the reload fails with a bare "Command failed".
875
+ * Convenience: `openclaw config set <args>`, UNLOCKED + retried.
876
+ *
877
+ * Assumes the shared CLI lock is already held by the calling public method.
878
+ * Never call this from outside a `cliLock.run(...)` section.
879
+ */
880
+ private runConfigSetUnlocked;
881
+ /**
882
+ * Run `openclaw config <args>` (set/unset), UNLOCKED, retried with backoff. See
883
+ * CONFIG_SET_RETRIES for why the retry loop exists: each write triggers a
884
+ * runtime hot-reload that rewrites openclaw.json, and a follow-up command that
885
+ * races the reload fails with a bare "Command failed".
886
+ *
887
+ * Serialization across every openclaw mutation is provided by `cliLock`, held
888
+ * by the public entry point — this helper assumes it and MUST NOT acquire the
889
+ * lock itself (that would deadlock the non-re-entrant promise-chain mutex).
793
890
  *
794
891
  * Throws an Error whose (scrubbed) message includes stderr after retries are
795
892
  * exhausted, so the real cause propagates to the integration errorMessage
796
893
  * without leaking the value payload.
797
894
  */
798
- private runConfigCommand;
895
+ private runConfigCommandUnlocked;
799
896
  /**
800
897
  * Read back a single config path and compare it to the value we tried to
801
898
  * write. Used by `applyConfig`'s verify-after-write: a `config set` can exit
@@ -819,21 +916,30 @@ declare class OpenClawApplier implements RuntimeApplier {
819
916
  applyPlugin(spec: string, _installPath?: string, opts?: {
820
917
  force?: boolean;
821
918
  }): Promise<void>;
919
+ private applyPluginLocked;
822
920
  /**
823
- * Ensure one or more plugins are in plugins.allow in openclaw.json.
921
+ * Ensure one or more plugins are in plugins.allow in openclaw.json, UNLOCKED.
824
922
  * Uses `openclaw config set` to avoid clobbering OpenClaw's own file format.
825
923
  *
924
+ * This is a read-modify-write (`config get plugins.allow` → union → `config
925
+ * set`). The caller MUST hold the shared CLI lock across the WHOLE call so no
926
+ * other openclaw process mutates `plugins.allow` between the read and the write
927
+ * (that would clobber the concurrent change). All callers wrap it in
928
+ * `cliLock.run(...)` — never invoke it outside a locked section.
929
+ *
826
930
  * Prefer passing the FULL set of plugins for an integration in a single call
827
931
  * (see `ensurePluginsAllowed`): each write triggers a runtime hot-reload, so
828
932
  * one write for N plugins is one reload instead of N.
829
933
  */
830
- private ensurePluginsAllow;
934
+ private ensurePluginsAllowUnlocked;
831
935
  /**
832
936
  * Pre-trust every plugin an integration ships in a SINGLE plugins.allow
833
937
  * write, before any are installed. Called once by the manager ahead of the
834
938
  * per-plugin install loop so activation triggers one hot-reload for the
835
939
  * allowlist instead of one per plugin. `applyPlugin`'s own per-plugin
836
- * `ensurePluginsAllow` then finds nothing missing and is a no-op.
940
+ * `ensurePluginsAllowUnlocked` then finds nothing missing and is a no-op.
941
+ *
942
+ * Acquires the shared CLI lock for the whole read-modify-write.
837
943
  */
838
944
  ensurePluginsAllowed(specs: string[]): Promise<void>;
839
945
  /**
@@ -845,6 +951,16 @@ declare class OpenClawApplier implements RuntimeApplier {
845
951
  * plugins under 2026.5+, which made force-mode skip its uninstall step.
846
952
  */
847
953
  private isPluginInstalled;
954
+ /**
955
+ * Best-effort read of the installed version of a plugin, or `undefined` when
956
+ * it can't be determined. Reads the version from the installed package's
957
+ * package.json under the npm registry path OpenClaw 2026.5+ installs to
958
+ * (`~/.openclaw/npm/node_modules/<pkg>/package.json`) — the same root
959
+ * `isPluginInstalled` checks. 2026.4 extensions-dir installs have no
960
+ * stable package.json version here and return `undefined` (callers fail
961
+ * open to the safe reinstall path).
962
+ */
963
+ private installedPluginVersion;
848
964
  /**
849
965
  * Remove an extensions/ install that has no matching record in
850
966
  * plugins/installs.json. OpenClaw 2026.5+ tracks installs via the records
@@ -856,6 +972,12 @@ declare class OpenClawApplier implements RuntimeApplier {
856
972
  */
857
973
  private cleanupUntrackedExtensionInstall;
858
974
  removePlugin(spec: string): Promise<void>;
975
+ /**
976
+ * `openclaw plugins uninstall`, UNLOCKED. Assumes the shared CLI lock is held
977
+ * by the caller (the public `removePlugin`, or `applyPluginLocked`'s force
978
+ * path). Never call outside a locked section.
979
+ */
980
+ private removePluginUnlocked;
859
981
  applySkill(name: string, srcPath: string): Promise<void>;
860
982
  applyClawHubSkill(slug: string): Promise<void>;
861
983
  removeClawHubSkill(slug: string): Promise<void>;
@@ -867,6 +989,7 @@ declare class OpenClawApplier implements RuntimeApplier {
867
989
  * (config.json) so it can be cleanly removed later.
868
990
  */
869
991
  applyConfig(integrationId: string, config: Record<string, unknown>): Promise<void>;
992
+ private applyConfigLocked;
870
993
  /**
871
994
  * Remove config previously applied by an integration.
872
995
  *
@@ -874,6 +997,7 @@ declare class OpenClawApplier implements RuntimeApplier {
874
997
  * then removes them via `openclaw config unset`.
875
998
  */
876
999
  removeConfig(integrationId: string): Promise<void>;
1000
+ private removeConfigLocked;
877
1001
  /**
878
1002
  * Raw single-key config write — `openclaw config set <key> <value>`.
879
1003
  *
@@ -1261,4 +1385,4 @@ declare class IntegrationManagerAdapter implements IIntegrationManager {
1261
1385
  resetReinstallAttempts(integrationId: string): void;
1262
1386
  }
1263
1387
  //#endregion
1264
- export { type CredentialsResolver, DEFAULT_REGISTRY_TTL_MS, HermesApplier, type HermesApplierOptions, HermesMcpSync, type HermesMcpSyncOptions, type HookEnvOptions, type HookResult, type IIntegrationManager, type InstalledInfo, Installer, InstallerError, type IntegrationConfigureParams, type IntegrationHealthParams, type IntegrationInfo, type IntegrationInstallParams, IntegrationManager, IntegrationManagerAdapter, type IntegrationManagerOptions, type IntegrationRemoveParams, type LoadOptions, LockManager, McpApplier, type McpApplierOptions, type McpStoreReader, OpenClawApplier, type OpenClawApplierOptions, type PlatformContext, Registry, type RegistryEntry, type RegistryFetcher, type RegistryIndex, type RegistryOptions, RegistryResolveError, type ResolveOptions, type ResolvedIntegration, Resolver, type RuntimeApplier, type RuntimeConfigEntry, type RuntimeDesiredState, type RuntimeLockFile, type RuntimePluginEntry, type RuntimeSkillEntry, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };
1388
+ export { type CredentialsResolver, DEFAULT_REGISTRY_TTL_MS, HermesApplier, type HermesApplierOptions, HermesMcpSync, type HermesMcpSyncOptions, type HookEnvOptions, type HookResult, type IIntegrationManager, type InstalledInfo, Installer, InstallerError, type IntegrationConfigureParams, type IntegrationHealthParams, type IntegrationInfo, type IntegrationInstallParams, IntegrationManager, IntegrationManagerAdapter, type IntegrationManagerOptions, type IntegrationRemoveParams, type LoadOptions, LockManager, McpApplier, type McpApplierOptions, type McpStoreReader, NoopOpenClawCliLock, OpenClawApplier, type OpenClawApplierOptions, type OpenClawCliLock, type PlatformContext, Registry, type RegistryEntry, type RegistryFetcher, type RegistryIndex, type RegistryOptions, RegistryResolveError, type ResolveOptions, type ResolvedIntegration, Resolver, type RuntimeApplier, type RuntimeConfigEntry, type RuntimeDesiredState, type RuntimeLockFile, type RuntimePluginEntry, type RuntimeSkillEntry, SerialOpenClawCliLock, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };
package/dist/index.js CHANGED
@@ -856,6 +856,22 @@ function stripPluginVersion(spec) {
856
856
  if (at <= 0) return spec;
857
857
  return spec.slice(0, at);
858
858
  }
859
+ /**
860
+ * Return the pinned version from a plugin spec, or `undefined` when the spec
861
+ * carries no version (bare package name). Mirror of `stripPluginVersion`: the
862
+ * version is everything after the LAST '@' when that '@' sits at index > 0
863
+ * (i.e. it's a real version delimiter, not the scope's leading '@').
864
+ *
865
+ * Used by the force-mode reinstall path to compare the pinned version against
866
+ * the installed one so an already-current plugin can skip a ~45s
867
+ * uninstall+reinstall cycle.
868
+ */
869
+ function pluginSpecVersion(spec) {
870
+ const at = spec.lastIndexOf("@");
871
+ if (at <= 0) return void 0;
872
+ const version = spec.slice(at + 1);
873
+ return version.length > 0 ? version : void 0;
874
+ }
859
875
  //#endregion
860
876
  //#region src/integration-manager.ts
861
877
  /**
@@ -1617,6 +1633,32 @@ var IntegrationManager = class {
1617
1633
  }
1618
1634
  };
1619
1635
  //#endregion
1636
+ //#region src/openclaw-cli-lock.ts
1637
+ /**
1638
+ * Promise-chain mutex. The tail is kept as a swallowed continuation so a rejected
1639
+ * critical section never fails the NEXT unrelated caller, while the current caller
1640
+ * still sees the real result/rejection.
1641
+ */
1642
+ var SerialOpenClawCliLock = class {
1643
+ tail = Promise.resolve();
1644
+ run(fn) {
1645
+ const result = this.tail.then(fn, fn);
1646
+ this.tail = result.then(() => void 0, () => void 0);
1647
+ return result;
1648
+ }
1649
+ };
1650
+ /**
1651
+ * A lock that provides NO serialization — every `run` executes immediately. For
1652
+ * runtimes/contexts with no concurrent-openclaw-writer hazard, and for tests that
1653
+ * don't need serialization. Keeps consumers from having to special-case an absent
1654
+ * lock.
1655
+ */
1656
+ var NoopOpenClawCliLock = class {
1657
+ run(fn) {
1658
+ return fn();
1659
+ }
1660
+ };
1661
+ //#endregion
1620
1662
  //#region src/appliers/config-flatten.ts
1621
1663
  function flattenConfig(obj, prefix = "") {
1622
1664
  const entries = [];
@@ -1703,6 +1745,15 @@ const BUNDLED_BASELINE_ALLOW = ["browser"];
1703
1745
  const CONFIG_SET_RETRIES$1 = 3;
1704
1746
  const CONFIG_SET_RETRY_DELAY_MS$1 = 750;
1705
1747
  /**
1748
+ * Max `openclaw config get` spawns in flight at once during the batch
1749
+ * verify-after-write. A naive `Promise.all(leaves.map(...))` fires one CLI
1750
+ * process per leaf simultaneously — a 36-leaf model-provider batch on a 2-vCPU
1751
+ * box means 36 concurrent `config get`s, each needing ~1.9s of CPU, so every
1752
+ * process blows its own 10s timeout and the verify reports false even when the
1753
+ * write actually landed. A small pool keeps the read-backs honest under load.
1754
+ */
1755
+ const VERIFY_GET_CONCURRENCY = 4;
1756
+ /**
1706
1757
  * Sentinel OpenClaw returns from `config get` in place of a sensitive value —
1707
1758
  * the key IS set, OpenClaw is just hiding it. Verify-after-write must treat a
1708
1759
  * read-back of this as "present/matches" rather than a mismatch (see
@@ -1713,6 +1764,29 @@ const delay$1 = (ms) => new Promise((resolve) => {
1713
1764
  setTimeout(resolve, ms);
1714
1765
  });
1715
1766
  /**
1767
+ * Run `fn` over `items` with at most `limit` invocations in flight at once,
1768
+ * preserving result order. A tiny local pool (no new dependency) so the batch
1769
+ * verify-after-write doesn't stampede one `openclaw config get` per leaf — see
1770
+ * VERIFY_GET_CONCURRENCY.
1771
+ *
1772
+ * NOTE: relies on `fn` never rejecting (configValueMatches catches internally).
1773
+ * A throwing `fn` would reject the pool while sibling workers keep draining
1774
+ * items in the background — don't wire a throwing `fn` in here.
1775
+ */
1776
+ async function mapWithConcurrency(items, limit, fn) {
1777
+ const results = new Array(items.length);
1778
+ let cursor = 0;
1779
+ const worker = async () => {
1780
+ while (cursor < items.length) {
1781
+ const index = cursor++;
1782
+ results[index] = await fn(items[index]);
1783
+ }
1784
+ };
1785
+ const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
1786
+ await Promise.all(workers);
1787
+ return results;
1788
+ }
1789
+ /**
1716
1790
  * Asymmetric structural containment for verify-after-write: is every field we
1717
1791
  * INTENDED present in the ACTUAL read-back with our value? `actual` may carry
1718
1792
  * EXTRA keys that `intended` does not.
@@ -1755,19 +1829,70 @@ function deepSubset(intended, actual) {
1755
1829
  function redactConfigSetTarget(args) {
1756
1830
  return `openclaw config ${args.slice(0, 2).join(" ")}`.trim();
1757
1831
  }
1832
+ /** Cap a captured stream so a runaway OpenClaw dump can't bloat errorMessage. */
1833
+ const ERROR_STREAM_MAX = 400;
1834
+ function truncateForError(s) {
1835
+ return s.length > ERROR_STREAM_MAX ? `${s.slice(0, ERROR_STREAM_MAX)}… (truncated)` : s;
1836
+ }
1837
+ /**
1838
+ * Collect the value strings a `config set` argv carries so they can be scrubbed
1839
+ * out of OpenClaw's own output. OpenClaw validation errors can echo the
1840
+ * offending VALUE back in their diagnostic text, so stdout/stderr must be
1841
+ * treated as value-bearing, not just the argv. For `{path, value}` batch
1842
+ * entries only the `value` side is collected — redacting the path out of
1843
+ * "invalid path models.providers…" would gut the diagnostic.
1844
+ */
1845
+ function collectArgvValues(args) {
1846
+ const out = [];
1847
+ const collect = (v) => {
1848
+ if (typeof v === "string") {
1849
+ if (v.length >= 4) out.push(v);
1850
+ } else if (Array.isArray(v)) v.forEach(collect);
1851
+ else if (v !== null && typeof v === "object") {
1852
+ const o = v;
1853
+ if (typeof o.path === "string" && "value" in o) collect(o.value);
1854
+ else Object.values(o).forEach(collect);
1855
+ }
1856
+ };
1857
+ for (const token of args.slice(2)) {
1858
+ if (token.startsWith("--")) continue;
1859
+ try {
1860
+ collect(JSON.parse(token));
1861
+ } catch {
1862
+ collect(token);
1863
+ }
1864
+ }
1865
+ return out.sort((a, b) => b.length - a.length);
1866
+ }
1867
+ /** Replace every occurrence of the argv-derived values in a captured stream. */
1868
+ function scrubStream(s, values) {
1869
+ let out = s;
1870
+ for (const v of values) if (out.includes(v)) out = out.split(v).join("[REDACTED]");
1871
+ return out;
1872
+ }
1758
1873
  /**
1759
1874
  * Build a diagnosable BUT scrubbed message from an execFile rejection. Node's
1760
1875
  * execFile error `message` is "Command failed: <full argv>" — which for
1761
1876
  * `config set` embeds the value payload — so we never surface it. We keep the
1762
- * redacted target, the exit code, and `stderr` (openclaw's own error text).
1877
+ * redacted target, the exit code, `stderr`, and `stdout`.
1878
+ *
1879
+ * OpenClaw prints its own error text to STDOUT (not stderr), so on a genuine
1880
+ * failure `stderr` is often empty and the real cause is in stdout — surfacing
1881
+ * only stderr left days of bare "(exit 1)" in the logs. We include a truncated
1882
+ * stdout too — but scrubbed first: OpenClaw's error text can echo the offending
1883
+ * value, so both streams are run through `scrubStream` with the argv values
1884
+ * before anything reaches the dashboard-visible errorMessage.
1763
1885
  */
1764
1886
  function configSetErrorMessage(err, args) {
1765
1887
  const target = redactConfigSetTarget(args);
1766
1888
  if (err instanceof Error) {
1767
1889
  const e = err;
1768
- const stderr = typeof e.stderr === "string" ? e.stderr.trim() : "";
1890
+ const values = collectArgvValues(args);
1891
+ const stderr = typeof e.stderr === "string" ? scrubStream(e.stderr.trim(), values) : "";
1892
+ const stdout = typeof e.stdout === "string" ? scrubStream(e.stdout.trim(), values) : "";
1769
1893
  const code = e.code !== void 0 ? ` (exit ${e.code})` : "";
1770
- return stderr ? `${target} failed${code}: ${stderr}` : `${target} failed${code}`;
1894
+ const details = [stderr, stdout ? `stdout: ${truncateForError(stdout)}` : ""].filter(Boolean).join("; ");
1895
+ return details ? `${target} failed${code}: ${details}` : `${target} failed${code}`;
1771
1896
  }
1772
1897
  return `${target} failed: ${String(err)}`;
1773
1898
  }
@@ -1791,8 +1916,15 @@ var OpenClawApplier = class {
1791
1916
  trackingPath;
1792
1917
  configSetRetries;
1793
1918
  configSetRetryDelayMs;
1794
- /** Serializes all `openclaw config set` writes so they never interleave with each other or the hot-reload they trigger. */
1795
- configSetQueue = Promise.resolve();
1919
+ /**
1920
+ * Shared process-wide mutex serializing ALL openclaw config/state-mutating CLI
1921
+ * calls. Replaces the old per-applier `configSetQueue` promise-chain — a single
1922
+ * lock so config-set can't interleave with plugins-install (or the gateway's
1923
+ * mcp-cleanup / alfe.config_set), which is what corrupts the config store under
1924
+ * OpenClaw's optimistic concurrency. NOT re-entrant: every locked public method
1925
+ * acquires it ONCE at the top and calls only `*Unlocked` helpers inside.
1926
+ */
1927
+ cliLock;
1796
1928
  constructor(options) {
1797
1929
  const home = options.home ?? options.workspace;
1798
1930
  if (!home) throw new Error("OpenClawApplier requires `home` (or legacy `workspace`) option");
@@ -1802,37 +1934,42 @@ var OpenClawApplier = class {
1802
1934
  this.trackingPath = options.configPath ?? join(this.home, "config.json");
1803
1935
  this.configSetRetries = options.configSetRetries ?? CONFIG_SET_RETRIES$1;
1804
1936
  this.configSetRetryDelayMs = options.configSetRetryDelayMs ?? CONFIG_SET_RETRY_DELAY_MS$1;
1937
+ this.cliLock = options.cliLock ?? new SerialOpenClawCliLock();
1805
1938
  }
1806
- /** Convenience: `openclaw config set <args>`, serialized + retried. */
1807
- runConfigSet(setArgs, opts = {}) {
1808
- return this.runConfigCommand(["set", ...setArgs], opts);
1939
+ /**
1940
+ * Convenience: `openclaw config set <args>`, UNLOCKED + retried.
1941
+ *
1942
+ * Assumes the shared CLI lock is already held by the calling public method.
1943
+ * Never call this from outside a `cliLock.run(...)` section.
1944
+ */
1945
+ runConfigSetUnlocked(setArgs, opts = {}) {
1946
+ return this.runConfigCommandUnlocked(["set", ...setArgs], opts);
1809
1947
  }
1810
1948
  /**
1811
- * Run `openclaw config <args>` (set/unset), serialized against every other
1812
- * config write and retried with backoff. See CONFIG_SET_RETRIES for why:
1813
- * each write triggers a runtime hot-reload that rewrites openclaw.json, and a
1814
- * follow-up command that races the reload fails with a bare "Command failed".
1949
+ * Run `openclaw config <args>` (set/unset), UNLOCKED, retried with backoff. See
1950
+ * CONFIG_SET_RETRIES for why the retry loop exists: each write triggers a
1951
+ * runtime hot-reload that rewrites openclaw.json, and a follow-up command that
1952
+ * races the reload fails with a bare "Command failed".
1953
+ *
1954
+ * Serialization across every openclaw mutation is provided by `cliLock`, held
1955
+ * by the public entry point — this helper assumes it and MUST NOT acquire the
1956
+ * lock itself (that would deadlock the non-re-entrant promise-chain mutex).
1815
1957
  *
1816
1958
  * Throws an Error whose (scrubbed) message includes stderr after retries are
1817
1959
  * exhausted, so the real cause propagates to the integration errorMessage
1818
1960
  * without leaking the value payload.
1819
1961
  */
1820
- runConfigCommand(args, opts = {}) {
1962
+ async runConfigCommandUnlocked(args, opts = {}) {
1821
1963
  const timeout = opts.timeout ?? 1e4;
1822
- const run = async () => {
1823
- let lastErr;
1824
- for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
1825
- await execFileAsync$1("openclaw", ["config", ...args], { timeout });
1826
- return;
1827
- } catch (err) {
1828
- lastErr = err;
1829
- if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay$1(this.configSetRetryDelayMs * 2 ** attempt);
1830
- }
1831
- throw new Error(configSetErrorMessage(lastErr, args));
1832
- };
1833
- const result = this.configSetQueue.then(run, run);
1834
- this.configSetQueue = result.catch(() => void 0);
1835
- return result;
1964
+ let lastErr;
1965
+ for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
1966
+ await execFileAsync$1("openclaw", ["config", ...args], { timeout });
1967
+ return;
1968
+ } catch (err) {
1969
+ lastErr = err;
1970
+ if (attempt < this.configSetRetries && this.configSetRetryDelayMs > 0) await delay$1(this.configSetRetryDelayMs * 2 ** attempt);
1971
+ }
1972
+ throw new Error(configSetErrorMessage(lastErr, args));
1836
1973
  }
1837
1974
  /**
1838
1975
  * Read back a single config path and compare it to the value we tried to
@@ -1873,17 +2010,32 @@ var OpenClawApplier = class {
1873
2010
  }
1874
2011
  return false;
1875
2012
  }
1876
- async applyPlugin(spec, _installPath, opts) {
2013
+ applyPlugin(spec, _installPath, opts) {
2014
+ return this.cliLock.run(() => this.applyPluginLocked(spec, opts));
2015
+ }
2016
+ async applyPluginLocked(spec, opts) {
1877
2017
  const pkg = stripPluginVersion(spec);
1878
- await this.ensurePluginsAllow(pkg);
2018
+ await this.ensurePluginsAllowUnlocked(pkg);
1879
2019
  this.cleanupUntrackedExtensionInstall(pkg);
1880
2020
  if (opts?.force && this.isPluginInstalled(pkg)) {
2021
+ const pinnedVersion = pluginSpecVersion(spec);
2022
+ const installedVersion = this.installedPluginVersion(pkg);
2023
+ if (pinnedVersion && installedVersion && installedVersion === pinnedVersion) {
2024
+ log$3.info({
2025
+ pkg,
2026
+ spec,
2027
+ version: installedVersion
2028
+ }, "Force mode — installed version already matches pinned spec, skipping uninstall+reinstall");
2029
+ return;
2030
+ }
1881
2031
  log$3.info({
1882
2032
  pkg,
1883
- spec
2033
+ spec,
2034
+ installedVersion,
2035
+ pinnedVersion
1884
2036
  }, "Force mode — uninstalling plugin before reinstall");
1885
2037
  try {
1886
- await this.removePlugin(pkg);
2038
+ await this.removePluginUnlocked(pkg);
1887
2039
  } catch (err) {
1888
2040
  log$3.warn({
1889
2041
  pkg,
@@ -1928,14 +2080,20 @@ var OpenClawApplier = class {
1928
2080
  }
1929
2081
  }
1930
2082
  /**
1931
- * Ensure one or more plugins are in plugins.allow in openclaw.json.
2083
+ * Ensure one or more plugins are in plugins.allow in openclaw.json, UNLOCKED.
1932
2084
  * Uses `openclaw config set` to avoid clobbering OpenClaw's own file format.
1933
2085
  *
2086
+ * This is a read-modify-write (`config get plugins.allow` → union → `config
2087
+ * set`). The caller MUST hold the shared CLI lock across the WHOLE call so no
2088
+ * other openclaw process mutates `plugins.allow` between the read and the write
2089
+ * (that would clobber the concurrent change). All callers wrap it in
2090
+ * `cliLock.run(...)` — never invoke it outside a locked section.
2091
+ *
1934
2092
  * Prefer passing the FULL set of plugins for an integration in a single call
1935
2093
  * (see `ensurePluginsAllowed`): each write triggers a runtime hot-reload, so
1936
2094
  * one write for N plugins is one reload instead of N.
1937
2095
  */
1938
- async ensurePluginsAllow(pkgs) {
2096
+ async ensurePluginsAllowUnlocked(pkgs) {
1939
2097
  const wanted = Array.isArray(pkgs) ? pkgs : [pkgs];
1940
2098
  let currentAllow = [];
1941
2099
  try {
@@ -1951,7 +2109,7 @@ var OpenClawApplier = class {
1951
2109
  if (missing.length === 0) return;
1952
2110
  const updated = [...currentAllow, ...missing];
1953
2111
  try {
1954
- await this.runConfigSet([
2112
+ await this.runConfigSetUnlocked([
1955
2113
  "plugins.allow",
1956
2114
  JSON.stringify(updated),
1957
2115
  "--merge"
@@ -1968,11 +2126,13 @@ var OpenClawApplier = class {
1968
2126
  * write, before any are installed. Called once by the manager ahead of the
1969
2127
  * per-plugin install loop so activation triggers one hot-reload for the
1970
2128
  * allowlist instead of one per plugin. `applyPlugin`'s own per-plugin
1971
- * `ensurePluginsAllow` then finds nothing missing and is a no-op.
2129
+ * `ensurePluginsAllowUnlocked` then finds nothing missing and is a no-op.
2130
+ *
2131
+ * Acquires the shared CLI lock for the whole read-modify-write.
1972
2132
  */
1973
- async ensurePluginsAllowed(specs) {
1974
- if (specs.length === 0) return;
1975
- await this.ensurePluginsAllow(specs.map(stripPluginVersion));
2133
+ ensurePluginsAllowed(specs) {
2134
+ if (specs.length === 0) return Promise.resolve();
2135
+ return this.cliLock.run(() => this.ensurePluginsAllowUnlocked(specs.map(stripPluginVersion)));
1976
2136
  }
1977
2137
  /**
1978
2138
  * Check if a plugin is already installed.
@@ -1990,6 +2150,25 @@ var OpenClawApplier = class {
1990
2150
  return readdirSync(extensionsDir).some((dir) => dir.startsWith(prefix) && /^[0-9a-f]+$/i.test(dir.slice(prefix.length)));
1991
2151
  }
1992
2152
  /**
2153
+ * Best-effort read of the installed version of a plugin, or `undefined` when
2154
+ * it can't be determined. Reads the version from the installed package's
2155
+ * package.json under the npm registry path OpenClaw 2026.5+ installs to
2156
+ * (`~/.openclaw/npm/node_modules/<pkg>/package.json`) — the same root
2157
+ * `isPluginInstalled` checks. 2026.4 extensions-dir installs have no
2158
+ * stable package.json version here and return `undefined` (callers fail
2159
+ * open to the safe reinstall path).
2160
+ */
2161
+ installedPluginVersion(pkg) {
2162
+ const pkgJsonPath = join(this.home, "npm", "node_modules", ...pkg.split("/"), "package.json");
2163
+ if (!existsSync(pkgJsonPath)) return void 0;
2164
+ try {
2165
+ const data = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
2166
+ return typeof data.version === "string" ? data.version : void 0;
2167
+ } catch {
2168
+ return;
2169
+ }
2170
+ }
2171
+ /**
1993
2172
  * Remove an extensions/ install that has no matching record in
1994
2173
  * plugins/installs.json. OpenClaw 2026.5+ tracks installs via the records
1995
2174
  * file — a leftover dir from 2026.4 is invisible to `openclaw plugins
@@ -2034,7 +2213,15 @@ var OpenClawApplier = class {
2034
2213
  }
2035
2214
  }
2036
2215
  }
2037
- async removePlugin(spec) {
2216
+ removePlugin(spec) {
2217
+ return this.cliLock.run(() => this.removePluginUnlocked(spec));
2218
+ }
2219
+ /**
2220
+ * `openclaw plugins uninstall`, UNLOCKED. Assumes the shared CLI lock is held
2221
+ * by the caller (the public `removePlugin`, or `applyPluginLocked`'s force
2222
+ * path). Never call outside a locked section.
2223
+ */
2224
+ async removePluginUnlocked(spec) {
2038
2225
  await execFileAsync$1("openclaw", [
2039
2226
  "plugins",
2040
2227
  "uninstall",
@@ -2048,26 +2235,28 @@ var OpenClawApplier = class {
2048
2235
  cpSync(srcPath, join(this.skillsDir, name), { recursive: true });
2049
2236
  return Promise.resolve();
2050
2237
  }
2051
- async applyClawHubSkill(slug) {
2052
- log$3.info({ slug }, "Installing skill from ClawHub");
2053
- try {
2054
- await execFileAsync$1("openclaw", [
2055
- "skills",
2056
- "install",
2057
- slug
2058
- ], { timeout: 6e4 });
2059
- log$3.info({ slug }, "ClawHub skill installed");
2060
- } catch (err) {
2061
- const msg = err instanceof Error ? err.message : String(err);
2062
- if (msg.includes("already exists") || msg.includes("Skill already exists")) {
2063
- log$3.info({ slug }, "ClawHub skill already installed (skipped)");
2064
- return;
2238
+ applyClawHubSkill(slug) {
2239
+ return this.cliLock.run(async () => {
2240
+ log$3.info({ slug }, "Installing skill from ClawHub");
2241
+ try {
2242
+ await execFileAsync$1("openclaw", [
2243
+ "skills",
2244
+ "install",
2245
+ slug
2246
+ ], { timeout: 6e4 });
2247
+ log$3.info({ slug }, "ClawHub skill installed");
2248
+ } catch (err) {
2249
+ const msg = err instanceof Error ? err.message : String(err);
2250
+ if (msg.includes("already exists") || msg.includes("Skill already exists")) {
2251
+ log$3.info({ slug }, "ClawHub skill already installed (skipped)");
2252
+ return;
2253
+ }
2254
+ log$3.error({
2255
+ slug,
2256
+ err: msg
2257
+ }, "ClawHub skill install failed");
2065
2258
  }
2066
- log$3.error({
2067
- slug,
2068
- err: msg
2069
- }, "ClawHub skill install failed");
2070
- }
2259
+ });
2071
2260
  }
2072
2261
  removeClawHubSkill(slug) {
2073
2262
  const workspaceSkillsDir = join(this.agentWorkspace, "skills", slug);
@@ -2094,7 +2283,10 @@ var OpenClawApplier = class {
2094
2283
  * Each integration's config contribution is tracked in the tracking file
2095
2284
  * (config.json) so it can be cleanly removed later.
2096
2285
  */
2097
- async applyConfig(integrationId, config) {
2286
+ applyConfig(integrationId, config) {
2287
+ return this.cliLock.run(() => this.applyConfigLocked(integrationId, config));
2288
+ }
2289
+ async applyConfigLocked(integrationId, config) {
2098
2290
  const tracking = this.readTracking();
2099
2291
  const integrations = tracking._integrations ?? {};
2100
2292
  integrations[integrationId] = config;
@@ -2105,7 +2297,7 @@ var OpenClawApplier = class {
2105
2297
  const merged = { ...await readParentObject(parentPath) };
2106
2298
  for (const [k, v] of dottedKvs) merged[k] = v;
2107
2299
  try {
2108
- await this.runConfigSet([
2300
+ await this.runConfigSetUnlocked([
2109
2301
  parentPath,
2110
2302
  JSON.stringify(merged),
2111
2303
  "--merge"
@@ -2126,14 +2318,14 @@ var OpenClawApplier = class {
2126
2318
  }
2127
2319
  }
2128
2320
  if (leaves.length > 0) try {
2129
- await this.runConfigSet([
2321
+ await this.runConfigSetUnlocked([
2130
2322
  "--batch-json",
2131
2323
  JSON.stringify(leaves),
2132
2324
  "--replace"
2133
- ]);
2325
+ ], { timeout: Math.min(Math.max(3e4, leaves.length * 2e3), 12e4) });
2134
2326
  } catch (err) {
2135
2327
  if (await this.verifyApplied(async () => {
2136
- return (await Promise.all(leaves.map((l) => this.configValueMatches(l.path, l.value)))).every(Boolean);
2328
+ return (await mapWithConcurrency(leaves, VERIFY_GET_CONCURRENCY, (l) => this.configValueMatches(l.path, l.value))).every(Boolean);
2137
2329
  })) log$3.warn({ count: leaves.length }, "openclaw config set --batch-json exited non-zero but config landed — continuing");
2138
2330
  else {
2139
2331
  log$3.error({
@@ -2150,14 +2342,17 @@ var OpenClawApplier = class {
2150
2342
  * Reads the tracking file to find which config keys this integration set,
2151
2343
  * then removes them via `openclaw config unset`.
2152
2344
  */
2153
- async removeConfig(integrationId) {
2345
+ removeConfig(integrationId) {
2346
+ return this.cliLock.run(() => this.removeConfigLocked(integrationId));
2347
+ }
2348
+ async removeConfigLocked(integrationId) {
2154
2349
  const tracking = this.readTracking();
2155
2350
  const integrations = tracking._integrations ?? {};
2156
2351
  if (!(integrationId in integrations)) return;
2157
2352
  const integrationConfig = integrations[integrationId];
2158
2353
  const { leaves, subtreesByParent } = partitionEntries(flattenConfig(integrationConfig));
2159
2354
  for (const { path } of leaves) try {
2160
- await this.runConfigCommand(["unset", path]);
2355
+ await this.runConfigCommandUnlocked(["unset", path]);
2161
2356
  } catch (err) {
2162
2357
  log$3.warn({
2163
2358
  err: err instanceof Error ? err.message : String(err),
@@ -2169,8 +2364,8 @@ var OpenClawApplier = class {
2169
2364
  const remaining = Object.fromEntries(Object.entries(existing).filter(([k]) => !dottedKvs.has(k)));
2170
2365
  if (Object.keys(existing).length === 0) continue;
2171
2366
  try {
2172
- if (Object.keys(remaining).length === 0) await this.runConfigCommand(["unset", parentPath]);
2173
- else await this.runConfigSet([
2367
+ if (Object.keys(remaining).length === 0) await this.runConfigCommandUnlocked(["unset", parentPath]);
2368
+ else await this.runConfigSetUnlocked([
2174
2369
  parentPath,
2175
2370
  JSON.stringify(remaining),
2176
2371
  "--replace"
@@ -2196,7 +2391,7 @@ var OpenClawApplier = class {
2196
2391
  * config writes trigger.
2197
2392
  */
2198
2393
  setConfigRaw(key, value) {
2199
- return this.runConfigSet([key, value]);
2394
+ return this.cliLock.run(() => this.runConfigSetUnlocked([key, value]));
2200
2395
  }
2201
2396
  isAvailable() {
2202
2397
  return Promise.resolve(existsSync(this.home));
@@ -2948,4 +3143,4 @@ var IntegrationManagerAdapter = class {
2948
3143
  }
2949
3144
  };
2950
3145
  //#endregion
2951
- export { DEFAULT_REGISTRY_TTL_MS, HermesApplier, HermesMcpSync, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, OpenClawApplier, Registry, RegistryResolveError, Resolver, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };
3146
+ export { DEFAULT_REGISTRY_TTL_MS, HermesApplier, HermesMcpSync, Installer, InstallerError, IntegrationManager, IntegrationManagerAdapter, LockManager, McpApplier, NoopOpenClawCliLock, OpenClawApplier, Registry, RegistryResolveError, Resolver, SerialOpenClawCliLock, StateManager, buildHookEnv, resolveInstallsForRuntime, runHook, runHookWithContext, upsertEnvVar };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alfe.ai/integrations",
3
- "version": "0.2.5",
3
+ "version": "0.2.7",
4
4
  "description": "Integration lifecycle management for Alfe — registry, resolution, installation, and state",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",