From 1078d178d75d811a57c96c9fd8a8c3f13a80f761 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 03:51:43 +0000 Subject: [PATCH] fix: doctor ack reaction migration (#927) Thanks @grp06. Co-authored-by: George Pickett --- CHANGELOG.md | 1 + ...-contract-form-layout-act-commands.test.ts | 2 +- src/commands/doctor-legacy-config.test.ts | 124 ++++++++++++++++++ src/commands/doctor-legacy-config.ts | 50 ++++++- 4 files changed, 175 insertions(+), 2 deletions(-) create mode 100644 src/commands/doctor-legacy-config.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bfabffc1..dc44bd4ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia. ### Fixes +- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06. - Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06. - Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06. - Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight. diff --git a/src/browser/server.agent-contract-form-layout-act-commands.test.ts b/src/browser/server.agent-contract-form-layout-act-commands.test.ts index be289cd61..92895c0ff 100644 --- a/src/browser/server.agent-contract-form-layout-act-commands.test.ts +++ b/src/browser/server.agent-contract-form-layout-act-commands.test.ts @@ -328,7 +328,7 @@ describe("browser control server", () => { fn: "() => 1", ref: undefined, }); - }); + }, 20_000); it("agent contract: hooks + response + downloads + screenshot", async () => { const base = await startServerAndBase(); diff --git a/src/commands/doctor-legacy-config.test.ts b/src/commands/doctor-legacy-config.test.ts new file mode 100644 index 000000000..8cb19b5d5 --- /dev/null +++ b/src/commands/doctor-legacy-config.test.ts @@ -0,0 +1,124 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { normalizeLegacyConfigValues } from "./doctor-legacy-config.js"; + +describe("normalizeLegacyConfigValues", () => { + let previousOauthDir: string | undefined; + let tempOauthDir: string | undefined; + + const writeCreds = (dir: string) => { + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, "creds.json"), JSON.stringify({ me: {} })); + }; + + beforeEach(() => { + previousOauthDir = process.env.CLAWDBOT_OAUTH_DIR; + tempOauthDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-oauth-")); + process.env.CLAWDBOT_OAUTH_DIR = tempOauthDir; + }); + + afterEach(() => { + if (previousOauthDir === undefined) { + delete process.env.CLAWDBOT_OAUTH_DIR; + } else { + process.env.CLAWDBOT_OAUTH_DIR = previousOauthDir; + } + if (tempOauthDir) { + fs.rmSync(tempOauthDir, { recursive: true, force: true }); + tempOauthDir = undefined; + } + }); + + it("does not add whatsapp config when missing and no auth exists", () => { + const res = normalizeLegacyConfigValues({ + messages: { ackReaction: "👀" }, + }); + + expect(res.config.channels?.whatsapp).toBeUndefined(); + expect(res.changes).toEqual([]); + }); + + it("copies legacy ack reaction when whatsapp config exists", () => { + const res = normalizeLegacyConfigValues({ + messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + channels: { whatsapp: {} }, + }); + + expect(res.config.channels?.whatsapp?.ackReaction).toEqual({ + emoji: "👀", + direct: false, + group: "mentions", + }); + expect(res.changes).toEqual([ + "Copied messages.ackReaction → channels.whatsapp.ackReaction (scope: group-mentions).", + ]); + }); + + it("copies legacy ack reaction when whatsapp auth exists", () => { + const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "default"); + writeCreds(credsDir); + + const res = normalizeLegacyConfigValues({ + messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + }); + + expect(res.config.channels?.whatsapp?.ackReaction).toEqual({ + emoji: "👀", + direct: false, + group: "mentions", + }); + }); + + it("copies legacy ack reaction when legacy auth exists", () => { + const credsPath = path.join(tempOauthDir ?? "", "creds.json"); + fs.writeFileSync(credsPath, JSON.stringify({ me: {} })); + + const res = normalizeLegacyConfigValues({ + messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + }); + + expect(res.config.channels?.whatsapp?.ackReaction).toEqual({ + emoji: "👀", + direct: false, + group: "mentions", + }); + }); + + it("copies legacy ack reaction when non-default auth exists", () => { + const credsDir = path.join(tempOauthDir ?? "", "whatsapp", "work"); + writeCreds(credsDir); + + const res = normalizeLegacyConfigValues({ + messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + }); + + expect(res.config.channels?.whatsapp?.ackReaction).toEqual({ + emoji: "👀", + direct: false, + group: "mentions", + }); + }); + + it("copies legacy ack reaction when authDir override exists", () => { + const customDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-wa-auth-")); + try { + writeCreds(customDir); + + const res = normalizeLegacyConfigValues({ + messages: { ackReaction: "👀", ackReactionScope: "group-mentions" }, + channels: { whatsapp: { accounts: { work: { authDir: customDir } } } }, + }); + + expect(res.config.channels?.whatsapp?.ackReaction).toEqual({ + emoji: "👀", + direct: false, + group: "mentions", + }); + } finally { + fs.rmSync(customDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/commands/doctor-legacy-config.ts b/src/commands/doctor-legacy-config.ts index d8174aa6e..6e36550e3 100644 --- a/src/commands/doctor-legacy-config.ts +++ b/src/commands/doctor-legacy-config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import os from "node:os"; import path from "node:path"; @@ -9,9 +10,12 @@ import { readConfigFileSnapshot, writeConfigFile, } from "../config/config.js"; +import { resolveOAuthDir } from "../config/paths.js"; +import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { note } from "../terminal/note.js"; import { resolveUserPath } from "../utils.js"; +import { resolveWebCredsPath } from "../web/auth-store.js"; function resolveLegacyConfigPath(env: NodeJS.ProcessEnv): string { const override = env.CLAWDIS_CONFIG_PATH?.trim(); @@ -53,6 +57,48 @@ export function replaceModernName(value: string | undefined): string | undefined return value.replace(/clawdbot/g, "clawdis"); } +function hasWebCreds(authDir: string): boolean { + try { + const credsPath = resolveWebCredsPath(authDir); + const stats = fs.statSync(credsPath); + return stats.isFile() && stats.size > 1; + } catch { + return false; + } +} + +function listWhatsAppAuthDirs(cfg: ClawdbotConfig): string[] { + const oauthDir = resolveOAuthDir(); + const whatsappDir = path.join(oauthDir, "whatsapp"); + const authDirs = new Set([oauthDir, path.join(whatsappDir, DEFAULT_ACCOUNT_ID)]); + + const accounts = cfg.channels?.whatsapp?.accounts; + if (accounts && typeof accounts === "object") { + for (const [accountId, accountCfg] of Object.entries(accounts)) { + const configured = accountCfg?.authDir?.trim(); + const authDir = configured ? resolveUserPath(configured) : path.join(whatsappDir, accountId); + authDirs.add(authDir); + } + } + + try { + const entries = fs.readdirSync(whatsappDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + authDirs.add(path.join(whatsappDir, entry.name)); + } + } catch { + // ignore missing dirs + } + + return Array.from(authDirs); +} + +function hasWhatsAppAuthState(cfg: ClawdbotConfig): boolean { + const authDirs = listWhatsAppAuthDirs(cfg); + return authDirs.some((authDir) => hasWebCreds(authDir)); +} + export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): { config: ClawdbotConfig; changes: string[]; @@ -219,7 +265,9 @@ export function normalizeLegacyConfigValues(cfg: ClawdbotConfig): { } const legacyAckReaction = cfg.messages?.ackReaction?.trim(); - if (legacyAckReaction) { + const hasWhatsAppConfig = cfg.channels?.whatsapp !== undefined; + const hasWhatsAppAuth = hasWhatsAppAuthState(cfg); + if (legacyAckReaction && (hasWhatsAppConfig || hasWhatsAppAuth)) { const hasWhatsAppAck = cfg.channels?.whatsapp?.ackReaction !== undefined; if (!hasWhatsAppAck) { const legacyScope = cfg.messages?.ackReactionScope ?? "group-mentions";