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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waha-openclaw-channel",
3
- "version": "1.16.21",
3
+ "version": "1.17.0",
4
4
  "description": "OpenClaw WAHA (WhatsApp HTTP API) channel plugin",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
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
- console.warn(`[waha] apiKeyFile "${merged.apiKeyFile}" unreadable: ${err}, falling back to inline apiKey`);
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) console.log("[waha] analytics: pruned " + r.changes + " events older than " + maxAgeDays + " days");
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
- console.warn(`[waha] auto-reply send failed for ${jid}: ${String(err)}`);
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
- console.warn(`[waha] checkGroupMembership: session=${session} not in group=${groupId}`);
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) { console.warn("[waha] analytics recording failed:", analyticsErr instanceof Error ? analyticsErr.message : String(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
- console.warn(`[waha] cross-session routing — no reachable session for ${chatId}, using default account`);
667
+ log.warn("cross-session routing — no reachable session, using default account", { chatId });
665
668
  } else {
666
- console.error(`[waha] cross-session routing unexpected error for ${chatId}: ${msg}`);
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) { console.warn("[waha] analytics recording failed:", analyticsErr instanceof Error ? analyticsErr.message : String(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
- console.log(`[waha] user ${id} approved for pairing`);
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
+ });
@@ -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
+ }
@@ -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
- healthCheckIntervalMs: z.number().int().positive().optional().default(60_000),
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;