From 35a57bc940833a6c1f594b2308e349e5ee0148db Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 21 Feb 2026 18:08:05 +0100 Subject: [PATCH] fix: gate doctor oauth-dir repair by channel config --- CHANGELOG.md | 1 + src/commands/doctor-state-integrity.test.ts | 133 ++++++++++++++++++++ src/commands/doctor-state-integrity.ts | 62 ++++++++- 3 files changed, 195 insertions(+), 1 deletion(-) create mode 100644 src/commands/doctor-state-integrity.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d42cd8ce6b..a1983ad17a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Doctor/State integrity: only require/create the OAuth credentials directory when WhatsApp or pairing-backed channels are configured, and downgrade fresh-install missing-dir noise to an informational warning. - Security/Agents: cap embedded Pi runner outer retry loop with a higher profile-aware dynamic limit (32-160 attempts) and return an explicit `retry_limit` error payload when retries never converge, preventing unbounded internal retry cycles (`GHSA-76m6-pj3w-v7mf`). - Telegram: detect duplicate bot-token ownership across Telegram accounts at startup/status time, mark secondary accounts as not configured with an explicit fix message, and block duplicate account startup before polling to avoid endless `getUpdates` conflict loops. - Agents/Tool images: include source filenames in `agents/tool-images` resize logs so compression events can be traced back to specific files. diff --git a/src/commands/doctor-state-integrity.test.ts b/src/commands/doctor-state-integrity.test.ts new file mode 100644 index 00000000000..907a7d71a51 --- /dev/null +++ b/src/commands/doctor-state-integrity.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveStorePath, resolveSessionTranscriptsDirForAgent } from "../config/sessions.js"; +import { note } from "../terminal/note.js"; +import { noteStateIntegrity } from "./doctor-state-integrity.js"; + +vi.mock("../terminal/note.js", () => ({ + note: vi.fn(), +})); + +type EnvSnapshot = { + HOME?: string; + OPENCLAW_HOME?: string; + OPENCLAW_STATE_DIR?: string; + OPENCLAW_OAUTH_DIR?: string; +}; + +function captureEnv(): EnvSnapshot { + return { + HOME: process.env.HOME, + OPENCLAW_HOME: process.env.OPENCLAW_HOME, + OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR, + OPENCLAW_OAUTH_DIR: process.env.OPENCLAW_OAUTH_DIR, + }; +} + +function restoreEnv(snapshot: EnvSnapshot) { + for (const key of Object.keys(snapshot) as Array) { + const value = snapshot[key]; + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +function setupSessionState(cfg: OpenClawConfig, env: NodeJS.ProcessEnv, homeDir: string) { + const agentId = "main"; + const sessionsDir = resolveSessionTranscriptsDirForAgent(agentId, env, () => homeDir); + const storePath = resolveStorePath(cfg.session?.store, { agentId }); + fs.mkdirSync(sessionsDir, { recursive: true }); + fs.mkdirSync(path.dirname(storePath), { recursive: true }); +} + +describe("doctor state integrity oauth dir checks", () => { + let envSnapshot: EnvSnapshot; + let tempHome = ""; + + beforeEach(() => { + envSnapshot = captureEnv(); + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-doctor-state-integrity-")); + process.env.HOME = tempHome; + process.env.OPENCLAW_HOME = tempHome; + process.env.OPENCLAW_STATE_DIR = path.join(tempHome, ".openclaw"); + delete process.env.OPENCLAW_OAUTH_DIR; + fs.mkdirSync(process.env.OPENCLAW_STATE_DIR, { recursive: true, mode: 0o700 }); + vi.mocked(note).mockReset(); + }); + + afterEach(() => { + restoreEnv(envSnapshot); + fs.rmSync(tempHome, { recursive: true, force: true }); + }); + + it("does not prompt for oauth dir when no whatsapp/pairing config is active", async () => { + const cfg: OpenClawConfig = {}; + setupSessionState(cfg, process.env, tempHome); + const confirmSkipInNonInteractive = vi.fn(async () => false); + + await noteStateIntegrity(cfg, { confirmSkipInNonInteractive }); + + expect(confirmSkipInNonInteractive).not.toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Create OAuth dir at"), + }), + ); + const stateIntegrityText = vi + .mocked(note) + .mock.calls.filter((call) => call[1] === "State integrity") + .map((call) => String(call[0])) + .join("\n"); + expect(stateIntegrityText).toContain("OAuth dir not present"); + expect(stateIntegrityText).not.toContain("CRITICAL: OAuth dir missing"); + }); + + it("prompts for oauth dir when whatsapp is configured", async () => { + const cfg: OpenClawConfig = { + channels: { + whatsapp: {}, + }, + }; + setupSessionState(cfg, process.env, tempHome); + const confirmSkipInNonInteractive = vi.fn(async () => false); + + await noteStateIntegrity(cfg, { confirmSkipInNonInteractive }); + + expect(confirmSkipInNonInteractive).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Create OAuth dir at"), + }), + ); + const stateIntegrityText = vi + .mocked(note) + .mock.calls.filter((call) => call[1] === "State integrity") + .map((call) => String(call[0])) + .join("\n"); + expect(stateIntegrityText).toContain("CRITICAL: OAuth dir missing"); + }); + + it("prompts for oauth dir when a channel dmPolicy is pairing", async () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + dmPolicy: "pairing", + }, + }, + }; + setupSessionState(cfg, process.env, tempHome); + const confirmSkipInNonInteractive = vi.fn(async () => false); + + await noteStateIntegrity(cfg, { confirmSkipInNonInteractive }); + + expect(confirmSkipInNonInteractive).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining("Create OAuth dir at"), + }), + ); + }); +}); diff --git a/src/commands/doctor-state-integrity.ts b/src/commands/doctor-state-integrity.ts index f896d7fbb80..a62fcfb3108 100644 --- a/src/commands/doctor-state-integrity.ts +++ b/src/commands/doctor-state-integrity.ts @@ -132,6 +132,59 @@ function findOtherStateDirs(stateDir: string): string[] { return found; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isPairingPolicy(value: unknown): boolean { + return typeof value === "string" && value.trim().toLowerCase() === "pairing"; +} + +function hasPairingPolicy(value: unknown): boolean { + if (!isRecord(value)) { + return false; + } + if (isPairingPolicy(value.dmPolicy)) { + return true; + } + if (isRecord(value.dm) && isPairingPolicy(value.dm.policy)) { + return true; + } + if (!isRecord(value.accounts)) { + return false; + } + for (const accountCfg of Object.values(value.accounts)) { + if (hasPairingPolicy(accountCfg)) { + return true; + } + } + return false; +} + +function shouldRequireOAuthDir(cfg: OpenClawConfig, env: NodeJS.ProcessEnv): boolean { + if (env.OPENCLAW_OAUTH_DIR?.trim()) { + return true; + } + const channels = cfg.channels; + if (!isRecord(channels)) { + return false; + } + // WhatsApp auth always uses the credentials tree. + if (isRecord(channels.whatsapp)) { + return true; + } + // Pairing allowlists are persisted under credentials/-allowFrom.json. + for (const [channelId, channelCfg] of Object.entries(channels)) { + if (channelId === "defaults" || channelId === "modelByChannel") { + continue; + } + if (hasPairingPolicy(channelCfg)) { + return true; + } + } + return false; +} + export async function noteStateIntegrity( cfg: OpenClawConfig, prompter: DoctorPrompterLike, @@ -153,6 +206,7 @@ export async function noteStateIntegrity( const displaySessionsDir = shortenHomePath(sessionsDir); const displayStoreDir = shortenHomePath(storeDir); const displayConfigPath = configPath ? shortenHomePath(configPath) : undefined; + const requireOAuthDir = shouldRequireOAuthDir(cfg, env); let stateDirExists = existsDir(stateDir); if (!stateDirExists) { @@ -250,7 +304,13 @@ export async function noteStateIntegrity( const dirCandidates = new Map(); dirCandidates.set(sessionsDir, "Sessions dir"); dirCandidates.set(storeDir, "Session store dir"); - dirCandidates.set(oauthDir, "OAuth dir"); + if (requireOAuthDir) { + dirCandidates.set(oauthDir, "OAuth dir"); + } else if (!existsDir(oauthDir)) { + warnings.push( + `- OAuth dir not present (${displayOauthDir}). Skipping create because no WhatsApp/pairing channel config is active.`, + ); + } const displayDirFor = (dir: string) => { if (dir === sessionsDir) { return displaySessionsDir;