waha-openclaw-channel 1.16.21 → 1.17.0
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/CHANGELOG.md +41 -0
- package/package.json +1 -1
- package/src/accounts.ts +4 -1
- package/src/analytics.ts +23 -1
- package/src/auto-reply.ts +4 -1
- package/src/channel.ts +11 -6
- package/src/config-io.test.ts +175 -0
- package/src/config-io.ts +151 -0
- package/src/config-schema.ts +13 -2
- package/src/directory.ts +1570 -1550
- package/src/dm-filter.ts +6 -2
- package/src/error-formatter.ts +77 -73
- package/src/health.ts +478 -441
- package/src/http-client.ts +119 -26
- package/src/inbound-queue.ts +174 -164
- package/src/logger.test.ts +124 -0
- package/src/logger.ts +124 -0
- package/src/media.ts +30 -10
- package/src/metrics.ts +229 -0
- package/src/monitor.ts +423 -193
- package/src/policy-edit.ts +4 -2
- package/src/policy-enforcer.ts +5 -2
- package/src/rate-limiter.test.ts +84 -0
- package/src/rate-limiter.ts +12 -2
- package/src/rules-loader.ts +8 -11
- package/src/rules-resolver.ts +6 -3
- package/src/send.ts +11 -8
- package/src/shutup.ts +6 -3
- package/src/signature.ts +4 -1
- package/src/sync.ts +47 -43
- package/src/types.ts +2 -0
- package/src/waha-client.ts +171 -170
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,47 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the OpenClaw WAHA Plugin are documented here.
|
|
4
4
|
|
|
5
|
+
## [1.17.0] - 2026-03-25 — Enterprise Hardening
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- **Admin API authentication** — Bearer token auth on all `/api/admin/*` routes. Configure via `adminToken` config field or `WAHA_ADMIN_TOKEN` env var. Backward-compatible: no token = no auth.
|
|
9
|
+
- **Structured JSON logging** — New `logger` module replaces all `console.log/warn/error` with machine-parseable JSON lines. Fields: `level`, `ts`, `component`, `sessionId`, `chatId`. Configurable via `logLevel` config field or `WAHA_LOG_LEVEL` env var.
|
|
10
|
+
- **Prometheus metrics endpoint** — `GET /metrics` exposes heap usage, event loop lag, HTTP request rates, queue depth, API call counts, processing latency. No auth required (scraper-accessible).
|
|
11
|
+
- **Config write mutex** — Promise-based mutex serializes all config file read-modify-write operations. Prevents concurrent write corruption from admin panel.
|
|
12
|
+
- **Atomic config writes** — Write-to-temp-then-rename pattern prevents zero-byte config on crash.
|
|
13
|
+
- **Circuit breaker** — `callWahaApi` fast-fails when session health is `unhealthy` instead of wasting 90s on retry cycles.
|
|
14
|
+
- **Recovery verification** — Auto-recovery polls session status after restart, only marks success when CONNECTED (30s timeout).
|
|
15
|
+
- **Graceful shutdown** — Tracks in-flight requests and waits for drain (10s timeout) before closing server.
|
|
16
|
+
- **SSE connection cap** — Max 50 SSE clients; new connections beyond cap rejected with 503.
|
|
17
|
+
- **Admin API rate limiting** — 60 req/min per IP sliding window on all `/api/admin/*` routes.
|
|
18
|
+
- **SQLite busy_timeout** — Both DirectoryDb and AnalyticsDb set `PRAGMA busy_timeout = 5000` to handle concurrent writers.
|
|
19
|
+
- **WAL checkpointing** — Periodic `PRAGMA wal_checkpoint(PASSIVE)` every 30 minutes on both databases.
|
|
20
|
+
- **Media temp cleanup** — Startup sweep deletes orphaned `/tmp/openclaw/waha-media-*` files older than 10 minutes.
|
|
21
|
+
- **JID path validation** — All URL path JID parameters validated against `@c.us|@g.us|@lid|@newsletter` regex.
|
|
22
|
+
- **Config import validation** — Rejects unknown top-level keys beyond the known allowlist.
|
|
23
|
+
- **HMAC auto-generation** — Random webhook HMAC secret generated and logged on startup when not configured. Opt-out via `webhookHmacKey: "disabled"`.
|
|
24
|
+
- **Config schema bounds** — `healthCheckIntervalMs` minimum 10s, prevents flooding WAHA with health pings.
|
|
25
|
+
- **Per-account rate limiting** — Each account gets its own token bucket instead of last-account-wins.
|
|
26
|
+
- **RateLimiter maxQueue** — Bounded queue prevents unbounded memory growth during WAHA degradation.
|
|
27
|
+
- **Timeout coverage** — All 9 bare `fetch()` calls now have `AbortSignal.timeout()`. Covers media downloads, Gemini polling, Nominatim geocoding, admin session checks.
|
|
28
|
+
- **Nominatim rate limiting** — 1 req/sec enforced for geocoding calls.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- **Timing-safe auth** — Admin token comparison uses `crypto.timingSafeEqual` instead of `===`.
|
|
32
|
+
- **Config import mutex** — Import endpoint now wrapped in `withConfigMutex` to prevent race conditions.
|
|
33
|
+
- **Bare catch blocks** — 8 silent `catch {}` blocks replaced with structured `log.warn/debug` calls.
|
|
34
|
+
- **Histogram double-counting** — Prometheus histogram buckets now increment only the first matching bucket.
|
|
35
|
+
- **Dead metrics code** — Removed unused `apiCallsTotal`/`apiCallsSuccess` counters.
|
|
36
|
+
- **InboundQueue drain safety** — `finally` block wraps recursive drain and callbacks in try/catch.
|
|
37
|
+
- **SSE timer cleanup** — Keep-alive intervals `.unref()`'d and cleared on shutdown.
|
|
38
|
+
- **req.url immutability** — Static file serving no longer mutates `req.url` in-place.
|
|
39
|
+
- **IP source priority** — Admin rate limiter uses `remoteAddress` as primary, `X-Forwarded-For` as fallback.
|
|
40
|
+
|
|
41
|
+
### New Files
|
|
42
|
+
- `src/config-io.ts` — Async config I/O with mutex, atomic writes, backup rotation
|
|
43
|
+
- `src/logger.ts` — Structured JSON logger with child pattern and runtime level control
|
|
44
|
+
- `src/metrics.ts` — Prometheus metrics collection and `/metrics` endpoint
|
|
45
|
+
|
|
5
46
|
## [1.16.21] - 2026-03-24
|
|
6
47
|
|
|
7
48
|
### Fixed
|
package/package.json
CHANGED
package/src/accounts.ts
CHANGED
|
@@ -8,8 +8,11 @@ import {
|
|
|
8
8
|
import { LRUCache } from "lru-cache";
|
|
9
9
|
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
|
10
10
|
import type { CoreConfig, WahaAccountConfig } from "./types.js";
|
|
11
|
+
import { createLogger } from "./logger.js";
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
|
|
15
|
+
const log = createLogger({ component: "accounts" });
|
|
13
16
|
function normalizeOptionalAccountId(value: string | null | undefined): string | undefined {
|
|
14
17
|
if (!value) return undefined;
|
|
15
18
|
const trimmed = value.trim();
|
|
@@ -102,7 +105,7 @@ function resolveWahaApiKey(cfg: CoreConfig, opts: { accountId?: string }): {
|
|
|
102
105
|
return { apiKey: fileKey, source: "secretFile" };
|
|
103
106
|
}
|
|
104
107
|
} catch (err) {
|
|
105
|
-
|
|
108
|
+
log.warn("apiKeyFile unreadable, falling back to inline apiKey", { apiKeyFile: merged.apiKeyFile, error: String(err) });
|
|
106
109
|
}
|
|
107
110
|
}
|
|
108
111
|
|
package/src/analytics.ts
CHANGED
|
@@ -2,7 +2,10 @@ import { createRequire } from "node:module";
|
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { createLogger } from "./logger.js";
|
|
5
6
|
|
|
7
|
+
|
|
8
|
+
const log = createLogger({ component: "analytics" });
|
|
6
9
|
const require = createRequire(import.meta.url);
|
|
7
10
|
|
|
8
11
|
export type AnalyticsEventDirection = "inbound" | "outbound";
|
|
@@ -16,15 +19,30 @@ export type AnalyticsTopChat = { chat_id: string; total: number; inbound: number
|
|
|
16
19
|
export class AnalyticsDb {
|
|
17
20
|
private db: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
18
21
|
private _stmtInsert: any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
22
|
+
private _walTimer: ReturnType<typeof setTimeout> | null = null;
|
|
19
23
|
constructor(dbPath: string) {
|
|
20
24
|
mkdirSync(join(dbPath, ".."), { recursive: true });
|
|
21
25
|
const Database = require("better-sqlite3") as any; // eslint-disable-line @typescript-eslint/no-explicit-any
|
|
22
26
|
this.db = new Database(dbPath);
|
|
23
27
|
this.db.pragma("journal_mode = WAL");
|
|
24
28
|
this.db.pragma("foreign_keys = ON");
|
|
29
|
+
// Phase 37 (MEM-03): Prevent SQLITE_BUSY errors under concurrent access. DO NOT REMOVE.
|
|
30
|
+
this.db.pragma("busy_timeout = 5000");
|
|
25
31
|
this._createSchema();
|
|
26
32
|
this._stmtInsert = this.db.prepare("INSERT INTO message_events (timestamp, direction, chat_type, action, duration_ms, status, chat_id, account_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?)");
|
|
27
33
|
this.prune(90);
|
|
34
|
+
this._startWalCheckpoint();
|
|
35
|
+
}
|
|
36
|
+
// Phase 37 (DI-01): Periodic WAL checkpoint to prevent unbounded WAL growth. DO NOT REMOVE.
|
|
37
|
+
private _startWalCheckpoint(): void {
|
|
38
|
+
const INTERVAL_MS = 30 * 60 * 1000; // 30 minutes
|
|
39
|
+
const tick = () => {
|
|
40
|
+
try { this.db.pragma("wal_checkpoint(PASSIVE)"); } catch (err) { log.warn("WAL checkpoint failed", { error: String(err) }); }
|
|
41
|
+
this._walTimer = setTimeout(tick, INTERVAL_MS);
|
|
42
|
+
this._walTimer.unref();
|
|
43
|
+
};
|
|
44
|
+
this._walTimer = setTimeout(tick, INTERVAL_MS);
|
|
45
|
+
this._walTimer.unref();
|
|
28
46
|
}
|
|
29
47
|
private _createSchema(): void {
|
|
30
48
|
const io = "CHECK(direction IN ('inbound', 'outbound'))"; const ct = "CHECK(chat_type IN ('dm', 'group', 'channel', 'status'))"; const st = "CHECK(status IN ('success', 'error'))";
|
|
@@ -48,10 +66,14 @@ export class AnalyticsDb {
|
|
|
48
66
|
const row = this.db.prepare("SELECT COUNT(*) AS total, SUM(CASE WHEN direction = 'inbound' THEN 1 ELSE 0 END) AS inbound, SUM(CASE WHEN direction = 'outbound' THEN 1 ELSE 0 END) AS outbound, SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) AS errors, COALESCE(AVG(CASE WHEN duration_ms IS NOT NULL THEN duration_ms END), 0) AS avg_duration_ms FROM message_events WHERE timestamp >= ? AND timestamp <= ?").get(p.startTime, p.endTime) as { total: number; inbound: number; outbound: number; errors: number; avg_duration_ms: number; };
|
|
49
67
|
return { total: Number(row.total), inbound: Number(row.inbound), outbound: Number(row.outbound), errors: Number(row.errors), avg_duration_ms: Math.round(Number(row.avg_duration_ms)) };
|
|
50
68
|
}
|
|
69
|
+
close(): void {
|
|
70
|
+
if (this._walTimer) { clearTimeout(this._walTimer); this._walTimer = null; }
|
|
71
|
+
this.db.close();
|
|
72
|
+
}
|
|
51
73
|
prune(maxAgeDays: number): void {
|
|
52
74
|
const cutoff = Date.now() - maxAgeDays * 86400000;
|
|
53
75
|
const r = this.db.prepare("DELETE FROM message_events WHERE timestamp < ?").run(cutoff) as { changes: number };
|
|
54
|
-
if (r.changes > 0)
|
|
76
|
+
if (r.changes > 0) log.info("analytics: pruned " + r.changes + " events older than " + maxAgeDays + " days");
|
|
55
77
|
}
|
|
56
78
|
}
|
|
57
79
|
let _analyticsDb: AnalyticsDb | null = null;
|
package/src/auto-reply.ts
CHANGED
|
@@ -17,7 +17,10 @@
|
|
|
17
17
|
import { getDirectoryDb } from "./directory.js";
|
|
18
18
|
import { sendWahaText } from "./send.js";
|
|
19
19
|
import type { CoreConfig } from "./types.js";
|
|
20
|
+
import { createLogger } from "./logger.js";
|
|
20
21
|
|
|
22
|
+
|
|
23
|
+
const log = createLogger({ component: "auto-reply" });
|
|
21
24
|
// ── AutoReplyEngine ───────────────────────────────────────────────────────────
|
|
22
25
|
|
|
23
26
|
export class AutoReplyEngine {
|
|
@@ -78,7 +81,7 @@ export class AutoReplyEngine {
|
|
|
78
81
|
} catch (err) {
|
|
79
82
|
// Log but don't throw — rejection send failure is non-fatal.
|
|
80
83
|
// The contact simply won't receive a rejection message.
|
|
81
|
-
|
|
84
|
+
log.warn("auto-reply send failed", { jid, error: String(err) });
|
|
82
85
|
return;
|
|
83
86
|
}
|
|
84
87
|
|
package/src/channel.ts
CHANGED
|
@@ -91,7 +91,10 @@ import { getRulesBasePath } from "./identity-resolver.js";
|
|
|
91
91
|
import { getDirectoryDb } from "./directory.js";
|
|
92
92
|
// Phase 30, Plan 01: Analytics instrumentation. DO NOT REMOVE.
|
|
93
93
|
import { recordAnalyticsEvent } from "./analytics.js";
|
|
94
|
+
import { createLogger } from "./logger.js";
|
|
94
95
|
|
|
96
|
+
|
|
97
|
+
const log = createLogger({ component: "channel" });
|
|
95
98
|
// Cached config for outbound adapter — handleAction receives cfg as a param
|
|
96
99
|
// and caches it here so outbound methods (sendText, sendMedia, sendPoll) can
|
|
97
100
|
// access config without calling readConfigFile() which may crash.
|
|
@@ -535,7 +538,7 @@ export async function checkGroupMembership(
|
|
|
535
538
|
(err instanceof Error && "status" in err && (err as any).status === 404) ||
|
|
536
539
|
/not found|404|does not exist/i.test(msg);
|
|
537
540
|
if (isNotFound) {
|
|
538
|
-
|
|
541
|
+
log.warn("checkGroupMembership: session not in group", { session, groupId });
|
|
539
542
|
return false;
|
|
540
543
|
}
|
|
541
544
|
throw err;
|
|
@@ -578,7 +581,7 @@ const wahaMessageActions: ChannelMessageActionAdapter = {
|
|
|
578
581
|
const _chatId = typeof p.chatId === "string" ? p.chatId : (typeof p.to === "string" ? p.to : undefined);
|
|
579
582
|
const _chatType = _chatId?.endsWith("@g.us") ? "group" : (_chatId?.endsWith("@newsletter") ? "channel" : "dm");
|
|
580
583
|
recordAnalyticsEvent({ direction: "outbound", chat_type: _chatType, action, duration_ms: Date.now() - _analyticsStart, status: "success", chat_id: _chatId, account_id: aid });
|
|
581
|
-
} catch (analyticsErr) {
|
|
584
|
+
} catch (analyticsErr) { log.warn("analytics recording failed", { error: analyticsErr instanceof Error ? analyticsErr.message : String(analyticsErr) }); }
|
|
582
585
|
return actionResult;
|
|
583
586
|
};
|
|
584
587
|
|
|
@@ -661,9 +664,9 @@ const wahaMessageActions: ChannelMessageActionAdapter = {
|
|
|
661
664
|
} catch (routeErr) {
|
|
662
665
|
const msg = routeErr instanceof Error ? routeErr.message : String(routeErr);
|
|
663
666
|
if (/No full-access sessions|No session is a member/i.test(msg)) {
|
|
664
|
-
|
|
667
|
+
log.warn("cross-session routing — no reachable session, using default account", { chatId });
|
|
665
668
|
} else {
|
|
666
|
-
|
|
669
|
+
log.error("cross-session routing unexpected error", { chatId, error: msg });
|
|
667
670
|
}
|
|
668
671
|
}
|
|
669
672
|
}
|
|
@@ -776,7 +779,7 @@ const wahaMessageActions: ChannelMessageActionAdapter = {
|
|
|
776
779
|
const _chatId = typeof p.chatId === "string" ? p.chatId : (typeof p.to === "string" ? p.to : undefined);
|
|
777
780
|
const _chatType = _chatId?.endsWith("@g.us") ? "group" : (_chatId?.endsWith("@newsletter") ? "channel" : "dm");
|
|
778
781
|
recordAnalyticsEvent({ direction: "outbound", chat_type: _chatType, action, duration_ms: Date.now() - _analyticsStart, status: "error", chat_id: _chatId, account_id: aid });
|
|
779
|
-
} catch (analyticsErr) {
|
|
782
|
+
} catch (analyticsErr) { log.warn("analytics recording failed", { error: analyticsErr instanceof Error ? analyticsErr.message : String(analyticsErr) }); }
|
|
780
783
|
|
|
781
784
|
return {
|
|
782
785
|
content: [{ type: "text" as const, text: formatActionError(err, { action, target }) }],
|
|
@@ -800,7 +803,7 @@ export const wahaPlugin: ChannelPlugin<ResolvedWahaAccount> = {
|
|
|
800
803
|
idLabel: "whatsappUserId",
|
|
801
804
|
normalizeAllowEntry: (entry) => normalizeWahaAllowEntry(entry),
|
|
802
805
|
notifyApproval: async ({ id }) => {
|
|
803
|
-
|
|
806
|
+
log.info(`user ${id} approved for pairing`);
|
|
804
807
|
},
|
|
805
808
|
},
|
|
806
809
|
capabilities: {
|
|
@@ -1035,7 +1038,9 @@ export const wahaPlugin: ChannelPlugin<ResolvedWahaAccount> = {
|
|
|
1035
1038
|
// Wire reliability config from plugin settings to http-client module.
|
|
1036
1039
|
// DO NOT REMOVE — configureReliability() sets timeout and rate limiter defaults.
|
|
1037
1040
|
// Added Phase 1 gap closure (2026-03-11).
|
|
1041
|
+
// Phase 40 (CFG-02): pass accountId for per-account rate limiting. DO NOT CHANGE.
|
|
1038
1042
|
configureReliability({
|
|
1043
|
+
accountId: account.accountId,
|
|
1039
1044
|
timeoutMs: account.config.timeoutMs,
|
|
1040
1045
|
capacity: account.config.rateLimitCapacity,
|
|
1041
1046
|
refillRate: account.config.rateLimitRefillRate,
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// Mock node:fs/promises before importing module
|
|
4
|
+
vi.mock("node:fs/promises", () => ({
|
|
5
|
+
readFile: vi.fn(),
|
|
6
|
+
writeFile: vi.fn(),
|
|
7
|
+
rename: vi.fn(),
|
|
8
|
+
copyFile: vi.fn(),
|
|
9
|
+
stat: vi.fn(),
|
|
10
|
+
unlink: vi.fn(),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Mock node:os to control homedir
|
|
14
|
+
vi.mock("node:os", () => ({
|
|
15
|
+
homedir: vi.fn(() => "/home/testuser"),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
import { readFile, writeFile, rename, copyFile, stat } from "node:fs/promises";
|
|
19
|
+
import { readConfig, writeConfig, modifyConfig, withConfigMutex, getConfigPath } from "./config-io.js";
|
|
20
|
+
|
|
21
|
+
const mockReadFile = vi.mocked(readFile);
|
|
22
|
+
const mockWriteFile = vi.mocked(writeFile);
|
|
23
|
+
const mockRename = vi.mocked(rename);
|
|
24
|
+
const mockCopyFile = vi.mocked(copyFile);
|
|
25
|
+
const mockStat = vi.mocked(stat);
|
|
26
|
+
|
|
27
|
+
beforeEach(() => {
|
|
28
|
+
vi.clearAllMocks();
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe("config-io", () => {
|
|
32
|
+
describe("getConfigPath", () => {
|
|
33
|
+
it("returns default path when no env var set", () => {
|
|
34
|
+
delete process.env.OPENCLAW_CONFIG_PATH;
|
|
35
|
+
const p = getConfigPath();
|
|
36
|
+
expect(p).toContain(".openclaw");
|
|
37
|
+
expect(p).toContain("openclaw.json");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("respects OPENCLAW_CONFIG_PATH env var", () => {
|
|
41
|
+
process.env.OPENCLAW_CONFIG_PATH = "/custom/config.json";
|
|
42
|
+
expect(getConfigPath()).toBe("/custom/config.json");
|
|
43
|
+
delete process.env.OPENCLAW_CONFIG_PATH;
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("readConfig", () => {
|
|
48
|
+
it("Test 1: returns parsed JSON object from file", async () => {
|
|
49
|
+
const data = { channels: { waha: { foo: "bar" } } };
|
|
50
|
+
mockReadFile.mockResolvedValue(JSON.stringify(data));
|
|
51
|
+
const result = await readConfig("/tmp/config.json");
|
|
52
|
+
expect(result).toEqual(data);
|
|
53
|
+
expect(mockReadFile).toHaveBeenCalledWith("/tmp/config.json", "utf-8");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("Test 2: throws on missing file", async () => {
|
|
57
|
+
mockReadFile.mockRejectedValue(new Error("ENOENT: no such file"));
|
|
58
|
+
await expect(readConfig("/tmp/missing.json")).rejects.toThrow("ENOENT");
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("writeConfig", () => {
|
|
63
|
+
it("Test 3: writes JSON to .tmp file then renames to target", async () => {
|
|
64
|
+
// stat rejects (no existing file to backup)
|
|
65
|
+
mockStat.mockRejectedValue(new Error("ENOENT"));
|
|
66
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
67
|
+
mockRename.mockResolvedValue(undefined);
|
|
68
|
+
|
|
69
|
+
const data = { channels: { waha: {} } };
|
|
70
|
+
await writeConfig("/tmp/config.json", data);
|
|
71
|
+
|
|
72
|
+
// Should write to .tmp first
|
|
73
|
+
expect(mockWriteFile).toHaveBeenCalledWith(
|
|
74
|
+
"/tmp/config.json.tmp",
|
|
75
|
+
JSON.stringify(data, null, 2),
|
|
76
|
+
"utf-8"
|
|
77
|
+
);
|
|
78
|
+
// Then rename .tmp -> target
|
|
79
|
+
expect(mockRename).toHaveBeenCalledWith(
|
|
80
|
+
"/tmp/config.json.tmp",
|
|
81
|
+
"/tmp/config.json"
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("Test 4: creates 3 rolling backups before writing", async () => {
|
|
86
|
+
// All backup files exist
|
|
87
|
+
mockStat.mockResolvedValue({ isFile: () => true } as any);
|
|
88
|
+
mockRename.mockResolvedValue(undefined);
|
|
89
|
+
mockCopyFile.mockResolvedValue(undefined);
|
|
90
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
91
|
+
|
|
92
|
+
await writeConfig("/tmp/config.json", { x: 1 });
|
|
93
|
+
|
|
94
|
+
// Should rotate: .bak.2 -> .bak.3, .bak.1 -> .bak.2
|
|
95
|
+
const renameCalls = mockRename.mock.calls;
|
|
96
|
+
expect(renameCalls).toContainEqual(["/tmp/config.json.bak.2", "/tmp/config.json.bak.3"]);
|
|
97
|
+
expect(renameCalls).toContainEqual(["/tmp/config.json.bak.1", "/tmp/config.json.bak.2"]);
|
|
98
|
+
// Should copy current -> .bak.1
|
|
99
|
+
expect(mockCopyFile).toHaveBeenCalledWith("/tmp/config.json", "/tmp/config.json.bak.1");
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("withConfigMutex", () => {
|
|
104
|
+
it("Test 5: serializes concurrent calls", async () => {
|
|
105
|
+
const order: number[] = [];
|
|
106
|
+
let resolveFirst: () => void;
|
|
107
|
+
const firstBlocks = new Promise<void>((r) => { resolveFirst = r; });
|
|
108
|
+
|
|
109
|
+
const call1 = withConfigMutex(async () => {
|
|
110
|
+
order.push(1);
|
|
111
|
+
await firstBlocks;
|
|
112
|
+
order.push(2);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const call2 = withConfigMutex(async () => {
|
|
116
|
+
order.push(3);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// Let first call complete
|
|
120
|
+
resolveFirst!();
|
|
121
|
+
await call1;
|
|
122
|
+
await call2;
|
|
123
|
+
|
|
124
|
+
// call2 (push 3) should happen AFTER call1 finishes (push 2)
|
|
125
|
+
expect(order).toEqual([1, 2, 3]);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("modifyConfig", () => {
|
|
130
|
+
it("Test 6: does atomic read-modify-write under mutex", async () => {
|
|
131
|
+
const original = { channels: { waha: { count: 0 } } };
|
|
132
|
+
mockReadFile.mockResolvedValue(JSON.stringify(original));
|
|
133
|
+
mockStat.mockRejectedValue(new Error("ENOENT"));
|
|
134
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
135
|
+
mockRename.mockResolvedValue(undefined);
|
|
136
|
+
|
|
137
|
+
await modifyConfig("/tmp/config.json", (config) => {
|
|
138
|
+
(config as any).channels.waha.count = 42;
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// Should have written the modified config
|
|
142
|
+
const writtenData = JSON.parse(mockWriteFile.mock.calls[0][1] as string);
|
|
143
|
+
expect(writtenData.channels.waha.count).toBe(42);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("Test 6b: modifyConfig uses return value if fn returns an object", async () => {
|
|
147
|
+
mockReadFile.mockResolvedValue(JSON.stringify({ old: true }));
|
|
148
|
+
mockStat.mockRejectedValue(new Error("ENOENT"));
|
|
149
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
150
|
+
mockRename.mockResolvedValue(undefined);
|
|
151
|
+
|
|
152
|
+
await modifyConfig("/tmp/config.json", () => {
|
|
153
|
+
return { new: true };
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const writtenData = JSON.parse(mockWriteFile.mock.calls[0][1] as string);
|
|
157
|
+
expect(writtenData).toEqual({ new: true });
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("atomic write safety", () => {
|
|
162
|
+
it("Test 7: original file intact if writeFile crashes", async () => {
|
|
163
|
+
mockStat.mockRejectedValue(new Error("ENOENT"));
|
|
164
|
+
mockWriteFile.mockRejectedValue(new Error("disk full"));
|
|
165
|
+
|
|
166
|
+
await expect(writeConfig("/tmp/config.json", { x: 1 })).rejects.toThrow("disk full");
|
|
167
|
+
|
|
168
|
+
// rename should NOT have been called (write failed before rename)
|
|
169
|
+
const renameCallsForFinal = mockRename.mock.calls.filter(
|
|
170
|
+
(c) => c[0] === "/tmp/config.json.tmp" && c[1] === "/tmp/config.json"
|
|
171
|
+
);
|
|
172
|
+
expect(renameCallsForFinal).toHaveLength(0);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
});
|
package/src/config-io.ts
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* config-io.ts — Centralized config I/O module with safety guarantees.
|
|
3
|
+
*
|
|
4
|
+
* Provides:
|
|
5
|
+
* - Async file operations (node:fs/promises, never sync)
|
|
6
|
+
* - Promise-based mutex serializing all config writes (CON-01)
|
|
7
|
+
* - Atomic write-to-temp-then-rename so crash mid-write leaves previous valid file (DI-02)
|
|
8
|
+
* - Rolling backup rotation (3 backups) before each write
|
|
9
|
+
*
|
|
10
|
+
* Added Phase 33 (config-infrastructure). DO NOT CHANGE without reading all comments.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile, writeFile, rename, copyFile, stat } from "node:fs/promises";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
import { homedir } from "node:os";
|
|
16
|
+
import { createLogger } from "./logger.js";
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
const log = createLogger({ component: "config-io" });
|
|
20
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
21
|
+
// Config path
|
|
22
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Returns the path to the OpenClaw config file.
|
|
26
|
+
* Respects OPENCLAW_CONFIG_PATH env var, defaults to ~/.openclaw/openclaw.json.
|
|
27
|
+
* Config save path: must write to ~/.openclaw/openclaw.json (NOT workspace subfolder).
|
|
28
|
+
* DO NOT CHANGE — matches monitor.ts getConfigPath behavior.
|
|
29
|
+
*/
|
|
30
|
+
export function getConfigPath(): string {
|
|
31
|
+
return process.env.OPENCLAW_CONFIG_PATH ?? join(homedir(), ".openclaw", "openclaw.json");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
// Promise-based mutex (CON-01)
|
|
36
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Simple promise-chain mutex that serializes all config writes.
|
|
40
|
+
* Prevents concurrent read-modify-write races on openclaw.json.
|
|
41
|
+
* DO NOT CHANGE — concurrent config writes depend on this serialization.
|
|
42
|
+
*/
|
|
43
|
+
let configMutexChain = Promise.resolve();
|
|
44
|
+
|
|
45
|
+
export function withConfigMutex<T>(fn: () => Promise<T>): Promise<T> {
|
|
46
|
+
const result = configMutexChain.then(fn, fn);
|
|
47
|
+
configMutexChain = result.then(
|
|
48
|
+
() => {},
|
|
49
|
+
() => {}
|
|
50
|
+
);
|
|
51
|
+
return result;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
// Read config (MEM-01 — async I/O)
|
|
56
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Reads and parses the OpenClaw config file.
|
|
60
|
+
* Uses async fs/promises — never readFileSync.
|
|
61
|
+
* Throws on missing file or invalid JSON.
|
|
62
|
+
*/
|
|
63
|
+
export async function readConfig(configPath: string): Promise<Record<string, unknown>> {
|
|
64
|
+
const raw = await readFile(configPath, "utf-8");
|
|
65
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
// Backup rotation
|
|
70
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Async version of rotateConfigBackups from monitor.ts.
|
|
74
|
+
* Creates rolling backups: .bak.1 (newest), .bak.2, .bak.3 (oldest).
|
|
75
|
+
* Rotation: delete .bak.3 (overwritten by rename), shift .bak.2->.bak.3, .bak.1->.bak.2, copy current->.bak.1.
|
|
76
|
+
* Failure is non-fatal: logs a warning but does NOT block the save.
|
|
77
|
+
* Added Phase 33. DO NOT REMOVE.
|
|
78
|
+
*/
|
|
79
|
+
async function rotateConfigBackups(configPath: string): Promise<void> {
|
|
80
|
+
try {
|
|
81
|
+
const bak1 = configPath + ".bak.1";
|
|
82
|
+
const bak2 = configPath + ".bak.2";
|
|
83
|
+
const bak3 = configPath + ".bak.3";
|
|
84
|
+
|
|
85
|
+
// Check which files exist using async stat
|
|
86
|
+
const exists = async (p: string): Promise<boolean> => {
|
|
87
|
+
try {
|
|
88
|
+
await stat(p);
|
|
89
|
+
return true;
|
|
90
|
+
} catch {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
// Shift existing backups: .bak.2 -> .bak.3, .bak.1 -> .bak.2
|
|
96
|
+
if (await exists(bak2)) await rename(bak2, bak3);
|
|
97
|
+
if (await exists(bak1)) await rename(bak1, bak2);
|
|
98
|
+
// Copy current config as newest backup
|
|
99
|
+
if (await exists(configPath)) await copyFile(configPath, bak1);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
log.warn("rotateConfigBackups: backup failed (non-fatal)", { error: String(err) });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
106
|
+
// Write config (DI-02 — atomic write-to-temp-then-rename)
|
|
107
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Atomically writes config to disk.
|
|
111
|
+
* 1. Rotates backups (non-fatal if rotation fails)
|
|
112
|
+
* 2. Writes to configPath.tmp
|
|
113
|
+
* 3. Renames .tmp -> configPath (atomic on most filesystems)
|
|
114
|
+
*
|
|
115
|
+
* If crash/error occurs during writeFile, the original config file is untouched.
|
|
116
|
+
* Uses async fs/promises — never writeFileSync.
|
|
117
|
+
* DO NOT CHANGE — atomic write pattern prevents data loss on crash.
|
|
118
|
+
*/
|
|
119
|
+
export async function writeConfig(configPath: string, config: Record<string, unknown>): Promise<void> {
|
|
120
|
+
await rotateConfigBackups(configPath);
|
|
121
|
+
const tmpPath = configPath + ".tmp";
|
|
122
|
+
await writeFile(tmpPath, JSON.stringify(config, null, 2), "utf-8");
|
|
123
|
+
await rename(tmpPath, configPath);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
127
|
+
// Modify config (convenience wrapper)
|
|
128
|
+
// ──────────────────────────────────────────────────────────────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Atomic read-modify-write under mutex.
|
|
132
|
+
* 1. Acquires config mutex (serializes with all other config writes)
|
|
133
|
+
* 2. Reads current config
|
|
134
|
+
* 3. Calls fn(config) — if fn returns a new object, uses that; if void, uses mutated original
|
|
135
|
+
* 4. Writes result atomically
|
|
136
|
+
*
|
|
137
|
+
* Usage:
|
|
138
|
+
* await modifyConfig(path, (cfg) => { cfg.channels.waha.foo = 'bar'; });
|
|
139
|
+
* await modifyConfig(path, (cfg) => ({ ...cfg, newKey: 'val' }));
|
|
140
|
+
*/
|
|
141
|
+
export async function modifyConfig(
|
|
142
|
+
configPath: string,
|
|
143
|
+
fn: (config: Record<string, unknown>) => Record<string, unknown> | void
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
return withConfigMutex(async () => {
|
|
146
|
+
const config = await readConfig(configPath);
|
|
147
|
+
const result = fn(config);
|
|
148
|
+
const toWrite = result !== undefined && result !== null ? result : config;
|
|
149
|
+
await writeConfig(configPath, toWrite);
|
|
150
|
+
});
|
|
151
|
+
}
|
package/src/config-schema.ts
CHANGED
|
@@ -96,7 +96,8 @@ export const WahaAccountSchemaBase = z
|
|
|
96
96
|
rateLimitRefillRate: z.number().positive().optional().default(15),
|
|
97
97
|
// Phase 2 config — health monitoring and inbound queue sizing.
|
|
98
98
|
// Added in Phase 2, Plan 01 (2026-03-11). DO NOT REMOVE.
|
|
99
|
-
|
|
99
|
+
// Phase 40 (CFG-01): .min(10000) prevents dangerously fast health checks. DO NOT REMOVE.
|
|
100
|
+
healthCheckIntervalMs: z.number().int().positive().min(10_000).optional().default(60_000),
|
|
100
101
|
dmQueueSize: z.number().int().positive().optional().default(50),
|
|
101
102
|
groupQueueSize: z.number().int().positive().optional().default(50),
|
|
102
103
|
// Phase 3 config — auto link preview in sendWahaText.
|
|
@@ -146,6 +147,11 @@ export const WahaAccountSchemaBase = z
|
|
|
146
147
|
),
|
|
147
148
|
}).optional().default({}),
|
|
148
149
|
|
|
150
|
+
// Phase 35 (OBS-01): Structured log level. Overrides WAHA_LOG_LEVEL env var.
|
|
151
|
+
// Accepted values: "debug", "info", "warn", "error". Default: "info".
|
|
152
|
+
// DO NOT REMOVE — used by logger.ts to configure runtime log verbosity.
|
|
153
|
+
logLevel: z.enum(["debug", "info", "warn", "error"]).optional(),
|
|
154
|
+
|
|
149
155
|
autoReply: z.object({
|
|
150
156
|
enabled: z.boolean().optional().default(false),
|
|
151
157
|
message: z.string().optional().default(
|
|
@@ -170,6 +176,11 @@ export const WahaAccountSchema = WahaAccountSchemaBase.superRefine((value, ctx)
|
|
|
170
176
|
export const WahaConfigSchema = WahaAccountSchemaBase.extend({
|
|
171
177
|
accounts: z.record(z.string(), WahaAccountSchema.optional()).optional(),
|
|
172
178
|
defaultAccount: z.string().optional(),
|
|
179
|
+
// Phase 34 (SEC-01): Bearer token for admin API authentication.
|
|
180
|
+
// When set, all /api/admin/* routes require Authorization: Bearer <token>.
|
|
181
|
+
// Also supports WAHA_ADMIN_TOKEN env var. When neither is set, no auth required (backward compat).
|
|
182
|
+
// DO NOT REMOVE — removing this disables admin panel authentication.
|
|
183
|
+
adminToken: z.string().optional(),
|
|
173
184
|
}).superRefine((value, ctx) => {
|
|
174
185
|
requireOpenAllowFrom({
|
|
175
186
|
policy: value.dmPolicy,
|
|
@@ -201,7 +212,7 @@ export type ConfigValidationResult =
|
|
|
201
212
|
export function validateWahaConfig(value: unknown): ConfigValidationResult {
|
|
202
213
|
// WahaConfigSchema is WahaAccountSchemaBase.extend({accounts,defaultAccount}).superRefine(...)
|
|
203
214
|
// .superRefine() returns ZodEffects which has no .shape — derive keys from the base + extension.
|
|
204
|
-
const knownKeys = new Set([...Object.keys(WahaAccountSchemaBase.shape), 'accounts', 'defaultAccount']);
|
|
215
|
+
const knownKeys = new Set([...Object.keys(WahaAccountSchemaBase.shape), 'accounts', 'defaultAccount', 'adminToken']);
|
|
205
216
|
const stripped = typeof value === 'object' && value !== null
|
|
206
217
|
? Object.fromEntries(Object.entries(value).filter(([k]) => knownKeys.has(k)))
|
|
207
218
|
: value;
|