@alfe.ai/integrations 0.2.6 → 0.2.8
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 +55 -0
- package/dist/index.js +279 -12
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -870,6 +870,11 @@ declare class OpenClawApplier implements RuntimeApplier {
|
|
|
870
870
|
* acquires it ONCE at the top and calls only `*Unlocked` helpers inside.
|
|
871
871
|
*/
|
|
872
872
|
private cliLock;
|
|
873
|
+
/**
|
|
874
|
+
* Monotonic-ish guard for the state-DB self-heal — timestamp of the last heal
|
|
875
|
+
* this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
|
|
876
|
+
*/
|
|
877
|
+
private lastHealAt;
|
|
873
878
|
constructor(options: OpenClawApplierOptions);
|
|
874
879
|
/**
|
|
875
880
|
* Convenience: `openclaw config set <args>`, UNLOCKED + retried.
|
|
@@ -913,6 +918,46 @@ declare class OpenClawApplier implements RuntimeApplier {
|
|
|
913
918
|
* on the first success, false once all attempts are exhausted.
|
|
914
919
|
*/
|
|
915
920
|
private verifyApplied;
|
|
921
|
+
/**
|
|
922
|
+
* `openclaw <args>` with automatic malformed-state-DB self-heal. On a
|
|
923
|
+
* malformed-DB failure (see isMalformedStateDbError): quarantine + recreate the
|
|
924
|
+
* state DB (healMalformedStateDb), then retry the ORIGINAL command exactly
|
|
925
|
+
* once. If the heal is rate-limited/failed, or the single retry also fails, the
|
|
926
|
+
* error propagates unchanged — callers keep their existing failure semantics
|
|
927
|
+
* (retry loops, install-tolerance checks, log.warn) as if healing never ran.
|
|
928
|
+
*
|
|
929
|
+
* Assumes the shared CLI lock is already held by the calling public method: the
|
|
930
|
+
* heal runs INSIDE the same locked section as the failed command. NEVER
|
|
931
|
+
* re-acquires the non-re-entrant `cliLock`.
|
|
932
|
+
*
|
|
933
|
+
* Runtime-alive safety: reconcile-path callers run under the gateway's
|
|
934
|
+
* RuntimeGate (runtime stopped), but the `setConfigRaw` hook (daemon
|
|
935
|
+
* `alfe.config_set`) can heal while `openclaw gateway run` still holds the DB.
|
|
936
|
+
* That is safe by fd/inode decoupling, not by exclusivity: renameSync is
|
|
937
|
+
* inode-level, the live runtime keeps writing its orphaned inode (discarded on
|
|
938
|
+
* its next restart — the state DB is disposable), and the recreated DB gets
|
|
939
|
+
* fresh -wal/-shm files the old process never attaches to.
|
|
940
|
+
*/
|
|
941
|
+
private execOpenClawHealing;
|
|
942
|
+
/**
|
|
943
|
+
* Quarantine the corrupt OpenClaw state DB and recreate it. Never throws
|
|
944
|
+
* (logs + returns whether the heal succeeded). Rate-limited to one attempt per
|
|
945
|
+
* HEAL_MIN_INTERVAL_MS so a heal→re-corrupt→heal loop can't thrash the box.
|
|
946
|
+
*
|
|
947
|
+
* Steps (human-verified against a live managed agent): rename
|
|
948
|
+
* `state/openclaw.sqlite{,-wal,-shm}` aside with a `.quarantined-<ISO>` suffix
|
|
949
|
+
* (keep for forensics; -wal/-shm may be absent), then run `openclaw plugins
|
|
950
|
+
* list` to rebuild the DB via OpenClaw's migration framework (slow on a 2-vCPU
|
|
951
|
+
* box — 90s budget). Assumes the caller holds `cliLock` (see
|
|
952
|
+
* execOpenClawHealing for the runtime-alive rename semantics).
|
|
953
|
+
*
|
|
954
|
+
* A SUCCESSFUL heal consumes the full rate-limit window (a fresh DB exists for
|
|
955
|
+
* the runtime to re-corrupt — that loop is what the window prevents). A FAILED
|
|
956
|
+
* heal only gets the short cooldown: no fresh DB was produced, and a full
|
|
957
|
+
* window would strand the box with the main DB quarantined and no recreate for
|
|
958
|
+
* 10 minutes.
|
|
959
|
+
*/
|
|
960
|
+
private healMalformedStateDb;
|
|
916
961
|
applyPlugin(spec: string, _installPath?: string, opts?: {
|
|
917
962
|
force?: boolean;
|
|
918
963
|
}): Promise<void>;
|
|
@@ -951,6 +996,16 @@ declare class OpenClawApplier implements RuntimeApplier {
|
|
|
951
996
|
* plugins under 2026.5+, which made force-mode skip its uninstall step.
|
|
952
997
|
*/
|
|
953
998
|
private isPluginInstalled;
|
|
999
|
+
/**
|
|
1000
|
+
* Best-effort read of the installed version of a plugin, or `undefined` when
|
|
1001
|
+
* it can't be determined. Reads the version from the installed package's
|
|
1002
|
+
* package.json under the npm registry path OpenClaw 2026.5+ installs to
|
|
1003
|
+
* (`~/.openclaw/npm/node_modules/<pkg>/package.json`) — the same root
|
|
1004
|
+
* `isPluginInstalled` checks. 2026.4 extensions-dir installs have no
|
|
1005
|
+
* stable package.json version here and return `undefined` (callers fail
|
|
1006
|
+
* open to the safe reinstall path).
|
|
1007
|
+
*/
|
|
1008
|
+
private installedPluginVersion;
|
|
954
1009
|
/**
|
|
955
1010
|
* Remove an extensions/ install that has no matching record in
|
|
956
1011
|
* plugins/installs.json. OpenClaw 2026.5+ tracks installs via the records
|
package/dist/index.js
CHANGED
|
@@ -2,7 +2,7 @@ import { execFile, spawn } from "node:child_process";
|
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
3
|
import { basename, dirname, join } from "node:path";
|
|
4
4
|
import { homedir, platform, tmpdir } from "node:os";
|
|
5
|
-
import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { closeSync, copyFileSync, cpSync, existsSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { buildConfigValidationSchema, parseManifestFile } from "@alfe.ai/integration-manifest";
|
|
7
7
|
import { createLogger } from "@auriclabs/logger";
|
|
8
8
|
import { parseDocument } from "yaml";
|
|
@@ -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
|
/**
|
|
@@ -1729,16 +1745,86 @@ const BUNDLED_BASELINE_ALLOW = ["browser"];
|
|
|
1729
1745
|
const CONFIG_SET_RETRIES$1 = 3;
|
|
1730
1746
|
const CONFIG_SET_RETRY_DELAY_MS$1 = 750;
|
|
1731
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
|
+
/**
|
|
1732
1757
|
* Sentinel OpenClaw returns from `config get` in place of a sensitive value —
|
|
1733
1758
|
* the key IS set, OpenClaw is just hiding it. Verify-after-write must treat a
|
|
1734
1759
|
* read-back of this as "present/matches" rather than a mismatch (see
|
|
1735
1760
|
* `configValueMatches`).
|
|
1736
1761
|
*/
|
|
1737
1762
|
const OPENCLAW_REDACTED = "__OPENCLAW_REDACTED__";
|
|
1763
|
+
/**
|
|
1764
|
+
* OpenClaw's gateway runtime deterministically truncates its own WAL-mode state
|
|
1765
|
+
* DB (`<home>/state/openclaw.sqlite`) ~50s after boot: the page_count header
|
|
1766
|
+
* shrinks while btree/freelist entries still reference pages past the new end.
|
|
1767
|
+
* Once corrupt, EVERY `openclaw` CLI invocation that walks the freelist dies at
|
|
1768
|
+
* startup with this exact phrase (printed to STDOUT, exit 1), which kills every
|
|
1769
|
+
* `plugins install` during activation. Upstream (no fix, suggests exactly this
|
|
1770
|
+
* quarantine-and-recreate recovery): https://github.com/openclaw/openclaw/issues/71689.
|
|
1771
|
+
* `doctor --fix` does NOT detect it. The state DB is disposable — plugin installs
|
|
1772
|
+
* live in `<home>/npm/node_modules` + the on-disk registry, so quarantine + any
|
|
1773
|
+
* CLI command rebuilds it via migrations with no meaningful loss.
|
|
1774
|
+
*/
|
|
1775
|
+
const MALFORMED_STATE_DB_PHRASE = "database disk image is malformed";
|
|
1776
|
+
/**
|
|
1777
|
+
* Rate-limit the state-DB self-heal to ONE attempt per applier instance per
|
|
1778
|
+
* window so a heal→corrupt→heal loop (the runtime re-corrupts the fresh DB ~50s
|
|
1779
|
+
* later) can't thrash the box. Wall-clock `Date.now()` is fine here — this is
|
|
1780
|
+
* runtime daemon code, not a workflow script.
|
|
1781
|
+
*/
|
|
1782
|
+
const HEAL_MIN_INTERVAL_MS = 600 * 1e3;
|
|
1783
|
+
/**
|
|
1784
|
+
* Cooldown after a FAILED heal attempt. A failed heal produced no fresh DB, so
|
|
1785
|
+
* the full HEAL_MIN_INTERVAL_MS lockout is unjustified (it would strand the box
|
|
1786
|
+
* with the main DB quarantined and no recreate); a short cooldown just stops a
|
|
1787
|
+
* tight retry loop from hammering rename/recreate.
|
|
1788
|
+
*/
|
|
1789
|
+
const FAILED_HEAL_COOLDOWN_MS = 60 * 1e3;
|
|
1790
|
+
/**
|
|
1791
|
+
* True when an execFile rejection carries the malformed-state-DB signature.
|
|
1792
|
+
* OpenClaw prints the reason to STDOUT (not stderr), and Node's execFile error
|
|
1793
|
+
* embeds the argv in `message`, so all three streams are checked. The phrase is
|
|
1794
|
+
* specific enough to match on directly (case-insensitive).
|
|
1795
|
+
*/
|
|
1796
|
+
function isMalformedStateDbError(err) {
|
|
1797
|
+
if (!(err instanceof Error)) return false;
|
|
1798
|
+
const e = err;
|
|
1799
|
+
return `${e.message}\n${e.stderr ?? ""}\n${e.stdout ?? ""}`.toLowerCase().includes(MALFORMED_STATE_DB_PHRASE);
|
|
1800
|
+
}
|
|
1738
1801
|
const delay$1 = (ms) => new Promise((resolve) => {
|
|
1739
1802
|
setTimeout(resolve, ms);
|
|
1740
1803
|
});
|
|
1741
1804
|
/**
|
|
1805
|
+
* Run `fn` over `items` with at most `limit` invocations in flight at once,
|
|
1806
|
+
* preserving result order. A tiny local pool (no new dependency) so the batch
|
|
1807
|
+
* verify-after-write doesn't stampede one `openclaw config get` per leaf — see
|
|
1808
|
+
* VERIFY_GET_CONCURRENCY.
|
|
1809
|
+
*
|
|
1810
|
+
* NOTE: relies on `fn` never rejecting (configValueMatches catches internally).
|
|
1811
|
+
* A throwing `fn` would reject the pool while sibling workers keep draining
|
|
1812
|
+
* items in the background — don't wire a throwing `fn` in here.
|
|
1813
|
+
*/
|
|
1814
|
+
async function mapWithConcurrency(items, limit, fn) {
|
|
1815
|
+
const results = new Array(items.length);
|
|
1816
|
+
let cursor = 0;
|
|
1817
|
+
const worker = async () => {
|
|
1818
|
+
while (cursor < items.length) {
|
|
1819
|
+
const index = cursor++;
|
|
1820
|
+
results[index] = await fn(items[index]);
|
|
1821
|
+
}
|
|
1822
|
+
};
|
|
1823
|
+
const workers = Array.from({ length: Math.min(limit, items.length) }, () => worker());
|
|
1824
|
+
await Promise.all(workers);
|
|
1825
|
+
return results;
|
|
1826
|
+
}
|
|
1827
|
+
/**
|
|
1742
1828
|
* Asymmetric structural containment for verify-after-write: is every field we
|
|
1743
1829
|
* INTENDED present in the ACTUAL read-back with our value? `actual` may carry
|
|
1744
1830
|
* EXTRA keys that `intended` does not.
|
|
@@ -1781,19 +1867,70 @@ function deepSubset(intended, actual) {
|
|
|
1781
1867
|
function redactConfigSetTarget(args) {
|
|
1782
1868
|
return `openclaw config ${args.slice(0, 2).join(" ")}`.trim();
|
|
1783
1869
|
}
|
|
1870
|
+
/** Cap a captured stream so a runaway OpenClaw dump can't bloat errorMessage. */
|
|
1871
|
+
const ERROR_STREAM_MAX = 400;
|
|
1872
|
+
function truncateForError(s) {
|
|
1873
|
+
return s.length > ERROR_STREAM_MAX ? `${s.slice(0, ERROR_STREAM_MAX)}… (truncated)` : s;
|
|
1874
|
+
}
|
|
1875
|
+
/**
|
|
1876
|
+
* Collect the value strings a `config set` argv carries so they can be scrubbed
|
|
1877
|
+
* out of OpenClaw's own output. OpenClaw validation errors can echo the
|
|
1878
|
+
* offending VALUE back in their diagnostic text, so stdout/stderr must be
|
|
1879
|
+
* treated as value-bearing, not just the argv. For `{path, value}` batch
|
|
1880
|
+
* entries only the `value` side is collected — redacting the path out of
|
|
1881
|
+
* "invalid path models.providers…" would gut the diagnostic.
|
|
1882
|
+
*/
|
|
1883
|
+
function collectArgvValues(args) {
|
|
1884
|
+
const out = [];
|
|
1885
|
+
const collect = (v) => {
|
|
1886
|
+
if (typeof v === "string") {
|
|
1887
|
+
if (v.length >= 4) out.push(v);
|
|
1888
|
+
} else if (Array.isArray(v)) v.forEach(collect);
|
|
1889
|
+
else if (v !== null && typeof v === "object") {
|
|
1890
|
+
const o = v;
|
|
1891
|
+
if (typeof o.path === "string" && "value" in o) collect(o.value);
|
|
1892
|
+
else Object.values(o).forEach(collect);
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
for (const token of args.slice(2)) {
|
|
1896
|
+
if (token.startsWith("--")) continue;
|
|
1897
|
+
try {
|
|
1898
|
+
collect(JSON.parse(token));
|
|
1899
|
+
} catch {
|
|
1900
|
+
collect(token);
|
|
1901
|
+
}
|
|
1902
|
+
}
|
|
1903
|
+
return out.sort((a, b) => b.length - a.length);
|
|
1904
|
+
}
|
|
1905
|
+
/** Replace every occurrence of the argv-derived values in a captured stream. */
|
|
1906
|
+
function scrubStream(s, values) {
|
|
1907
|
+
let out = s;
|
|
1908
|
+
for (const v of values) if (out.includes(v)) out = out.split(v).join("[REDACTED]");
|
|
1909
|
+
return out;
|
|
1910
|
+
}
|
|
1784
1911
|
/**
|
|
1785
1912
|
* Build a diagnosable BUT scrubbed message from an execFile rejection. Node's
|
|
1786
1913
|
* execFile error `message` is "Command failed: <full argv>" — which for
|
|
1787
1914
|
* `config set` embeds the value payload — so we never surface it. We keep the
|
|
1788
|
-
* redacted target, the exit code,
|
|
1915
|
+
* redacted target, the exit code, `stderr`, and `stdout`.
|
|
1916
|
+
*
|
|
1917
|
+
* OpenClaw prints its own error text to STDOUT (not stderr), so on a genuine
|
|
1918
|
+
* failure `stderr` is often empty and the real cause is in stdout — surfacing
|
|
1919
|
+
* only stderr left days of bare "(exit 1)" in the logs. We include a truncated
|
|
1920
|
+
* stdout too — but scrubbed first: OpenClaw's error text can echo the offending
|
|
1921
|
+
* value, so both streams are run through `scrubStream` with the argv values
|
|
1922
|
+
* before anything reaches the dashboard-visible errorMessage.
|
|
1789
1923
|
*/
|
|
1790
1924
|
function configSetErrorMessage(err, args) {
|
|
1791
1925
|
const target = redactConfigSetTarget(args);
|
|
1792
1926
|
if (err instanceof Error) {
|
|
1793
1927
|
const e = err;
|
|
1794
|
-
const
|
|
1928
|
+
const values = collectArgvValues(args);
|
|
1929
|
+
const stderr = typeof e.stderr === "string" ? scrubStream(e.stderr.trim(), values) : "";
|
|
1930
|
+
const stdout = typeof e.stdout === "string" ? scrubStream(e.stdout.trim(), values) : "";
|
|
1795
1931
|
const code = e.code !== void 0 ? ` (exit ${e.code})` : "";
|
|
1796
|
-
|
|
1932
|
+
const details = [stderr, stdout ? `stdout: ${truncateForError(stdout)}` : ""].filter(Boolean).join("; ");
|
|
1933
|
+
return details ? `${target} failed${code}: ${details}` : `${target} failed${code}`;
|
|
1797
1934
|
}
|
|
1798
1935
|
return `${target} failed: ${String(err)}`;
|
|
1799
1936
|
}
|
|
@@ -1826,6 +1963,11 @@ var OpenClawApplier = class {
|
|
|
1826
1963
|
* acquires it ONCE at the top and calls only `*Unlocked` helpers inside.
|
|
1827
1964
|
*/
|
|
1828
1965
|
cliLock;
|
|
1966
|
+
/**
|
|
1967
|
+
* Monotonic-ish guard for the state-DB self-heal — timestamp of the last heal
|
|
1968
|
+
* this instance performed (0 = never). See HEAL_MIN_INTERVAL_MS.
|
|
1969
|
+
*/
|
|
1970
|
+
lastHealAt = 0;
|
|
1829
1971
|
constructor(options) {
|
|
1830
1972
|
const home = options.home ?? options.workspace;
|
|
1831
1973
|
if (!home) throw new Error("OpenClawApplier requires `home` (or legacy `workspace`) option");
|
|
@@ -1864,7 +2006,7 @@ var OpenClawApplier = class {
|
|
|
1864
2006
|
const timeout = opts.timeout ?? 1e4;
|
|
1865
2007
|
let lastErr;
|
|
1866
2008
|
for (let attempt = 0; attempt <= this.configSetRetries; attempt++) try {
|
|
1867
|
-
await
|
|
2009
|
+
await this.execOpenClawHealing(["config", ...args], { timeout });
|
|
1868
2010
|
return;
|
|
1869
2011
|
} catch (err) {
|
|
1870
2012
|
lastErr = err;
|
|
@@ -1911,6 +2053,99 @@ var OpenClawApplier = class {
|
|
|
1911
2053
|
}
|
|
1912
2054
|
return false;
|
|
1913
2055
|
}
|
|
2056
|
+
/**
|
|
2057
|
+
* `openclaw <args>` with automatic malformed-state-DB self-heal. On a
|
|
2058
|
+
* malformed-DB failure (see isMalformedStateDbError): quarantine + recreate the
|
|
2059
|
+
* state DB (healMalformedStateDb), then retry the ORIGINAL command exactly
|
|
2060
|
+
* once. If the heal is rate-limited/failed, or the single retry also fails, the
|
|
2061
|
+
* error propagates unchanged — callers keep their existing failure semantics
|
|
2062
|
+
* (retry loops, install-tolerance checks, log.warn) as if healing never ran.
|
|
2063
|
+
*
|
|
2064
|
+
* Assumes the shared CLI lock is already held by the calling public method: the
|
|
2065
|
+
* heal runs INSIDE the same locked section as the failed command. NEVER
|
|
2066
|
+
* re-acquires the non-re-entrant `cliLock`.
|
|
2067
|
+
*
|
|
2068
|
+
* Runtime-alive safety: reconcile-path callers run under the gateway's
|
|
2069
|
+
* RuntimeGate (runtime stopped), but the `setConfigRaw` hook (daemon
|
|
2070
|
+
* `alfe.config_set`) can heal while `openclaw gateway run` still holds the DB.
|
|
2071
|
+
* That is safe by fd/inode decoupling, not by exclusivity: renameSync is
|
|
2072
|
+
* inode-level, the live runtime keeps writing its orphaned inode (discarded on
|
|
2073
|
+
* its next restart — the state DB is disposable), and the recreated DB gets
|
|
2074
|
+
* fresh -wal/-shm files the old process never attaches to.
|
|
2075
|
+
*/
|
|
2076
|
+
async execOpenClawHealing(args, opts = {}) {
|
|
2077
|
+
try {
|
|
2078
|
+
await execFileAsync$1("openclaw", args, opts);
|
|
2079
|
+
return;
|
|
2080
|
+
} catch (err) {
|
|
2081
|
+
if (!isMalformedStateDbError(err)) throw err;
|
|
2082
|
+
log$3.warn({ args: args.slice(0, 2) }, "openclaw CLI failed with a malformed state DB — attempting self-heal then one retry");
|
|
2083
|
+
if (!await this.healMalformedStateDb()) throw err;
|
|
2084
|
+
await execFileAsync$1("openclaw", args, opts);
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
/**
|
|
2088
|
+
* Quarantine the corrupt OpenClaw state DB and recreate it. Never throws
|
|
2089
|
+
* (logs + returns whether the heal succeeded). Rate-limited to one attempt per
|
|
2090
|
+
* HEAL_MIN_INTERVAL_MS so a heal→re-corrupt→heal loop can't thrash the box.
|
|
2091
|
+
*
|
|
2092
|
+
* Steps (human-verified against a live managed agent): rename
|
|
2093
|
+
* `state/openclaw.sqlite{,-wal,-shm}` aside with a `.quarantined-<ISO>` suffix
|
|
2094
|
+
* (keep for forensics; -wal/-shm may be absent), then run `openclaw plugins
|
|
2095
|
+
* list` to rebuild the DB via OpenClaw's migration framework (slow on a 2-vCPU
|
|
2096
|
+
* box — 90s budget). Assumes the caller holds `cliLock` (see
|
|
2097
|
+
* execOpenClawHealing for the runtime-alive rename semantics).
|
|
2098
|
+
*
|
|
2099
|
+
* A SUCCESSFUL heal consumes the full rate-limit window (a fresh DB exists for
|
|
2100
|
+
* the runtime to re-corrupt — that loop is what the window prevents). A FAILED
|
|
2101
|
+
* heal only gets the short cooldown: no fresh DB was produced, and a full
|
|
2102
|
+
* window would strand the box with the main DB quarantined and no recreate for
|
|
2103
|
+
* 10 minutes.
|
|
2104
|
+
*/
|
|
2105
|
+
async healMalformedStateDb() {
|
|
2106
|
+
const now = Date.now();
|
|
2107
|
+
if (now - this.lastHealAt < HEAL_MIN_INTERVAL_MS) {
|
|
2108
|
+
log$3.warn({ sinceLastHealMs: now - this.lastHealAt }, "openclaw state DB malformed but a heal ran within the rate-limit window — skipping to avoid a heal→corrupt→heal loop");
|
|
2109
|
+
return false;
|
|
2110
|
+
}
|
|
2111
|
+
this.lastHealAt = now;
|
|
2112
|
+
const stateDir = join(this.home, "state");
|
|
2113
|
+
const stamp = new Date(now).toISOString().replaceAll(":", "-");
|
|
2114
|
+
const quarantined = [];
|
|
2115
|
+
try {
|
|
2116
|
+
for (const name of [
|
|
2117
|
+
"openclaw.sqlite",
|
|
2118
|
+
"openclaw.sqlite-wal",
|
|
2119
|
+
"openclaw.sqlite-shm"
|
|
2120
|
+
]) {
|
|
2121
|
+
const src = join(stateDir, name);
|
|
2122
|
+
if (!existsSync(src)) continue;
|
|
2123
|
+
const dest = `${src}.quarantined-${stamp}`;
|
|
2124
|
+
renameSync(src, dest);
|
|
2125
|
+
quarantined.push(dest);
|
|
2126
|
+
}
|
|
2127
|
+
if (quarantined.length === 0) {
|
|
2128
|
+
log$3.warn({ stateDir }, "malformed openclaw state DB reported but no state/openclaw.sqlite* files found to quarantine");
|
|
2129
|
+
this.lastHealAt = now - HEAL_MIN_INTERVAL_MS + FAILED_HEAL_COOLDOWN_MS;
|
|
2130
|
+
return false;
|
|
2131
|
+
}
|
|
2132
|
+
log$3.warn({
|
|
2133
|
+
stateDir,
|
|
2134
|
+
quarantined
|
|
2135
|
+
}, "quarantined malformed openclaw state DB — recreating via `openclaw plugins list` (upstream openclaw/openclaw#71689)");
|
|
2136
|
+
await execFileAsync$1("openclaw", ["plugins", "list"], { timeout: 9e4 });
|
|
2137
|
+
log$3.warn({ stateDir }, "recreated openclaw state DB after quarantine");
|
|
2138
|
+
return true;
|
|
2139
|
+
} catch (err) {
|
|
2140
|
+
log$3.warn({
|
|
2141
|
+
stateDir,
|
|
2142
|
+
quarantined,
|
|
2143
|
+
err: err instanceof Error ? err.message : String(err)
|
|
2144
|
+
}, "failed to heal malformed openclaw state DB — leaving quarantined files for forensics");
|
|
2145
|
+
this.lastHealAt = now - HEAL_MIN_INTERVAL_MS + FAILED_HEAL_COOLDOWN_MS;
|
|
2146
|
+
return false;
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
1914
2149
|
applyPlugin(spec, _installPath, opts) {
|
|
1915
2150
|
return this.cliLock.run(() => this.applyPluginLocked(spec, opts));
|
|
1916
2151
|
}
|
|
@@ -1919,9 +2154,21 @@ var OpenClawApplier = class {
|
|
|
1919
2154
|
await this.ensurePluginsAllowUnlocked(pkg);
|
|
1920
2155
|
this.cleanupUntrackedExtensionInstall(pkg);
|
|
1921
2156
|
if (opts?.force && this.isPluginInstalled(pkg)) {
|
|
2157
|
+
const pinnedVersion = pluginSpecVersion(spec);
|
|
2158
|
+
const installedVersion = this.installedPluginVersion(pkg);
|
|
2159
|
+
if (pinnedVersion && installedVersion && installedVersion === pinnedVersion) {
|
|
2160
|
+
log$3.info({
|
|
2161
|
+
pkg,
|
|
2162
|
+
spec,
|
|
2163
|
+
version: installedVersion
|
|
2164
|
+
}, "Force mode — installed version already matches pinned spec, skipping uninstall+reinstall");
|
|
2165
|
+
return;
|
|
2166
|
+
}
|
|
1922
2167
|
log$3.info({
|
|
1923
2168
|
pkg,
|
|
1924
|
-
spec
|
|
2169
|
+
spec,
|
|
2170
|
+
installedVersion,
|
|
2171
|
+
pinnedVersion
|
|
1925
2172
|
}, "Force mode — uninstalling plugin before reinstall");
|
|
1926
2173
|
try {
|
|
1927
2174
|
await this.removePluginUnlocked(pkg);
|
|
@@ -1944,12 +2191,12 @@ var OpenClawApplier = class {
|
|
|
1944
2191
|
const args = useUnsafeFlag ? [...baseArgs, "--dangerously-force-unsafe-install"] : baseArgs;
|
|
1945
2192
|
try {
|
|
1946
2193
|
try {
|
|
1947
|
-
await
|
|
2194
|
+
await this.execOpenClawHealing(args, { timeout: 6e4 });
|
|
1948
2195
|
} catch (err) {
|
|
1949
2196
|
const errText = err instanceof Error ? `${err.message}\n${err.stderr ?? ""}` : String(err);
|
|
1950
2197
|
if (useUnsafeFlag && errText.includes("unknown option")) {
|
|
1951
2198
|
log$3.info({ pkg }, "OpenClaw does not support --dangerously-force-unsafe-install, retrying without");
|
|
1952
|
-
await
|
|
2199
|
+
await this.execOpenClawHealing(baseArgs, { timeout: 6e4 });
|
|
1953
2200
|
} else throw err;
|
|
1954
2201
|
}
|
|
1955
2202
|
} catch (err) {
|
|
@@ -2039,6 +2286,25 @@ var OpenClawApplier = class {
|
|
|
2039
2286
|
return readdirSync(extensionsDir).some((dir) => dir.startsWith(prefix) && /^[0-9a-f]+$/i.test(dir.slice(prefix.length)));
|
|
2040
2287
|
}
|
|
2041
2288
|
/**
|
|
2289
|
+
* Best-effort read of the installed version of a plugin, or `undefined` when
|
|
2290
|
+
* it can't be determined. Reads the version from the installed package's
|
|
2291
|
+
* package.json under the npm registry path OpenClaw 2026.5+ installs to
|
|
2292
|
+
* (`~/.openclaw/npm/node_modules/<pkg>/package.json`) — the same root
|
|
2293
|
+
* `isPluginInstalled` checks. 2026.4 extensions-dir installs have no
|
|
2294
|
+
* stable package.json version here and return `undefined` (callers fail
|
|
2295
|
+
* open to the safe reinstall path).
|
|
2296
|
+
*/
|
|
2297
|
+
installedPluginVersion(pkg) {
|
|
2298
|
+
const pkgJsonPath = join(this.home, "npm", "node_modules", ...pkg.split("/"), "package.json");
|
|
2299
|
+
if (!existsSync(pkgJsonPath)) return void 0;
|
|
2300
|
+
try {
|
|
2301
|
+
const data = JSON.parse(readFileSync(pkgJsonPath, "utf8"));
|
|
2302
|
+
return typeof data.version === "string" ? data.version : void 0;
|
|
2303
|
+
} catch {
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
}
|
|
2307
|
+
/**
|
|
2042
2308
|
* Remove an extensions/ install that has no matching record in
|
|
2043
2309
|
* plugins/installs.json. OpenClaw 2026.5+ tracks installs via the records
|
|
2044
2310
|
* file — a leftover dir from 2026.4 is invisible to `openclaw plugins
|
|
@@ -2092,11 +2358,12 @@ var OpenClawApplier = class {
|
|
|
2092
2358
|
* path). Never call outside a locked section.
|
|
2093
2359
|
*/
|
|
2094
2360
|
async removePluginUnlocked(spec) {
|
|
2095
|
-
|
|
2361
|
+
const pkg = stripPluginVersion(spec);
|
|
2362
|
+
await this.execOpenClawHealing([
|
|
2096
2363
|
"plugins",
|
|
2097
2364
|
"uninstall",
|
|
2098
2365
|
"--force",
|
|
2099
|
-
|
|
2366
|
+
pkg
|
|
2100
2367
|
], { timeout: 3e4 });
|
|
2101
2368
|
}
|
|
2102
2369
|
applySkill(name, srcPath) {
|
|
@@ -2192,10 +2459,10 @@ var OpenClawApplier = class {
|
|
|
2192
2459
|
"--batch-json",
|
|
2193
2460
|
JSON.stringify(leaves),
|
|
2194
2461
|
"--replace"
|
|
2195
|
-
]);
|
|
2462
|
+
], { timeout: Math.min(Math.max(3e4, leaves.length * 2e3), 12e4) });
|
|
2196
2463
|
} catch (err) {
|
|
2197
2464
|
if (await this.verifyApplied(async () => {
|
|
2198
|
-
return (await
|
|
2465
|
+
return (await mapWithConcurrency(leaves, VERIFY_GET_CONCURRENCY, (l) => this.configValueMatches(l.path, l.value))).every(Boolean);
|
|
2199
2466
|
})) log$3.warn({ count: leaves.length }, "openclaw config set --batch-json exited non-zero but config landed — continuing");
|
|
2200
2467
|
else {
|
|
2201
2468
|
log$3.error({
|
package/package.json
CHANGED