From cfd12503b6c1feb382b277676afa28fbdf98022e Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Tue, 3 Mar 2026 03:04:06 -0500 Subject: [PATCH] fix: harden multi-account default routing guidance --- CHANGELOG.md | 1 + docs/channels/channel-routing.md | 1 + docs/channels/telegram.md | 2 + docs/gateway/doctor.md | 5 + ...fault-account-bindings.integration.test.ts | 31 +++++ ...w.missing-explicit-default-account.test.ts | 111 ++++++++++++++++++ src/commands/doctor-config-flow.ts | 58 ++++++++- src/telegram/accounts.test.ts | 18 ++- src/telegram/accounts.ts | 2 +- 9 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 src/commands/doctor-config-flow.missing-explicit-default-account.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b29bd0799..81d73a467d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Telegram/multi-account default routing clarity: warn only for ambiguous (2+) account setups without an explicit default, add `openclaw doctor` warnings for missing/invalid multi-account defaults across channels, and document explicit-default guidance for channel routing and Telegram config. (#32544) thanks @Sid-Qin. - Agents/Skills runtime loading: propagate run config into embedded attempt and compaction skill-entry loading so explicitly enabled bundled companion skills are discovered consistently when skill snapshots do not already provide resolved entries. Thanks @gumadeiras. - Agents/Compaction continuity: expand staged-summary merge instructions to preserve active task status, batch progress, latest user request, and follow-up commitments so compaction handoffs retain in-flight work context. (#8903) thanks @joetomasone. - Gateway/status self version reporting: make Gateway self version in `openclaw status` prefer runtime `VERSION` (while preserving explicit `OPENCLAW_VERSION` override), preventing stale post-upgrade app version output. (#32655) thanks @liuxiaopai-ai. diff --git a/docs/channels/channel-routing.md b/docs/channels/channel-routing.md index f51f6c4147c..2d824359311 100644 --- a/docs/channels/channel-routing.md +++ b/docs/channels/channel-routing.md @@ -17,6 +17,7 @@ host configuration. - **AccountId**: per‑channel account instance (when supported). - Optional channel default account: `channels..defaultAccount` chooses which account is used when an outbound path does not specify `accountId`. + - In multi-account setups, set an explicit default (`defaultAccount` or `accounts.default`) when two or more accounts are configured. Without it, fallback routing may pick the first normalized account ID. - **AgentId**: an isolated workspace + session store (“brain”). - **SessionKey**: the bucket key used to store context and control concurrency. diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index d03530f30e9..32bed072e05 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -739,6 +739,8 @@ Primary reference: - `channels.telegram.groupPolicy`: `open | allowlist | disabled` (default: allowlist). - `channels.telegram.groupAllowFrom`: group sender allowlist (numeric Telegram user IDs). `openclaw doctor --fix` can resolve legacy `@username` entries to IDs. Non-numeric entries are ignored at auth time. Group auth does not use DM pairing-store fallback (`2026.2.25+`). - Multi-account precedence: + - When two or more account IDs are configured, set `channels.telegram.defaultAccount` (or include `channels.telegram.accounts.default`) to make default routing explicit. + - If neither is set, OpenClaw falls back to the first normalized account ID and `openclaw doctor` warns. - `channels.telegram.accounts.default.allowFrom` and `channels.telegram.accounts.default.groupAllowFrom` apply only to the `default` account. - Named accounts inherit `channels.telegram.allowFrom` and `channels.telegram.groupAllowFrom` when account-level values are unset. - Named accounts do not inherit `channels.telegram.accounts.default.allowFrom` / `groupAllowFrom`. diff --git a/docs/gateway/doctor.md b/docs/gateway/doctor.md index 87f2ff760cb..3718b01b2d3 100644 --- a/docs/gateway/doctor.md +++ b/docs/gateway/doctor.md @@ -128,6 +128,11 @@ Current migrations: → `agents.defaults.models` + `agents.defaults.model.primary/fallbacks` + `agents.defaults.imageModel.primary/fallbacks` - `browser.ssrfPolicy.allowPrivateNetwork` → `browser.ssrfPolicy.dangerouslyAllowPrivateNetwork` +Doctor warnings also include account-default guidance for multi-account channels: + +- If two or more `channels..accounts` entries are configured without `channels..defaultAccount` or `accounts.default`, doctor warns that fallback routing can pick an unexpected account. +- If `channels..defaultAccount` is set to an unknown account ID, doctor warns and lists configured account IDs. + ### 2b) OpenCode Zen provider overrides If you’ve added `models.providers.opencode` (or `opencode-zen`) manually, it diff --git a/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts b/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts index ee5ac2e13c6..a5e0be54d97 100644 --- a/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts +++ b/src/commands/doctor-config-flow.missing-default-account-bindings.integration.test.ts @@ -52,4 +52,35 @@ describe("doctor missing default account binding warning", () => { "Doctor warnings", ); }); + + it("emits a warning when multiple accounts have no explicit default", async () => { + await withEnvAsync( + { + TELEGRAM_BOT_TOKEN: undefined, + TELEGRAM_BOT_TOKEN_FILE: undefined, + }, + async () => { + await runDoctorConfigWithInput({ + config: { + channels: { + telegram: { + accounts: { + alerts: {}, + work: {}, + }, + }, + }, + }, + run: loadAndMaybeMigrateDoctorConfig, + }); + }, + ); + + expect(noteSpy).toHaveBeenCalledWith( + expect.stringContaining( + "channels.telegram: multiple accounts are configured but no explicit default is set", + ), + "Doctor warnings", + ); + }); }); diff --git a/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts b/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts new file mode 100644 index 00000000000..faf14653961 --- /dev/null +++ b/src/commands/doctor-config-flow.missing-explicit-default-account.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; +import { collectMissingExplicitDefaultAccountWarnings } from "./doctor-config-flow.js"; + +describe("collectMissingExplicitDefaultAccountWarnings", () => { + it("warns when multiple named accounts are configured without default selection", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + alerts: { botToken: "a" }, + work: { botToken: "w" }, + }, + }, + }, + }; + + const warnings = collectMissingExplicitDefaultAccountWarnings(cfg); + expect(warnings).toEqual([ + expect.stringContaining("channels.telegram: multiple accounts are configured"), + ]); + }); + + it("does not warn for a single named account without default", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + work: { botToken: "w" }, + }, + }, + }, + }; + + expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]); + }); + + it("does not warn when accounts.default exists", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + default: { botToken: "d" }, + work: { botToken: "w" }, + }, + }, + }, + }; + + expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]); + }); + + it("does not warn when defaultAccount points to a configured account", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + defaultAccount: "work", + accounts: { + alerts: { botToken: "a" }, + work: { botToken: "w" }, + }, + }, + }, + }; + + expect(collectMissingExplicitDefaultAccountWarnings(cfg)).toEqual([]); + }); + + it("warns when defaultAccount is invalid for configured accounts", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + defaultAccount: "missing", + accounts: { + alerts: { botToken: "a" }, + work: { botToken: "w" }, + }, + }, + }, + }; + + const warnings = collectMissingExplicitDefaultAccountWarnings(cfg); + expect(warnings).toEqual([ + expect.stringContaining('channels.telegram: defaultAccount is set to "missing"'), + ]); + }); + + it("warns across channels that support account maps", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { + alerts: { botToken: "a" }, + work: { botToken: "w" }, + }, + }, + slack: { + accounts: { + a: { botToken: "x" }, + b: { botToken: "y" }, + }, + }, + }, + }; + + const warnings = collectMissingExplicitDefaultAccountWarnings(cfg); + expect(warnings).toHaveLength(2); + expect(warnings.some((line) => line.includes("channels.telegram"))).toBe(true); + expect(warnings.some((line) => line.includes("channels.slack"))).toBe(true); + }); +}); diff --git a/src/commands/doctor-config-flow.ts b/src/commands/doctor-config-flow.ts index b61b7c06908..33b9facff42 100644 --- a/src/commands/doctor-config-flow.ts +++ b/src/commands/doctor-config-flow.ts @@ -26,7 +26,11 @@ import { normalizeTrustedSafeBinDirs, } from "../infra/exec-safe-bin-trust.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; +import { + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + normalizeOptionalAccountId, +} from "../routing/session-key.js"; import { isDiscordMutableAllowEntry, isGoogleChatMutableAllowEntry, @@ -304,6 +308,54 @@ export function collectMissingDefaultAccountBindingWarnings(cfg: OpenClawConfig) return warnings; } +export function collectMissingExplicitDefaultAccountWarnings(cfg: OpenClawConfig): string[] { + const channels = asObjectRecord(cfg.channels); + if (!channels) { + return []; + } + + const warnings: string[] = []; + for (const [channelKey, rawChannel] of Object.entries(channels)) { + const channel = asObjectRecord(rawChannel); + if (!channel) { + continue; + } + + const accounts = asObjectRecord(channel.accounts); + if (!accounts) { + continue; + } + + const normalizedAccountIds = Array.from( + new Set( + Object.keys(accounts) + .map((accountId) => normalizeAccountId(accountId)) + .filter(Boolean), + ), + ).toSorted((a, b) => a.localeCompare(b)); + if (normalizedAccountIds.length < 2 || normalizedAccountIds.includes(DEFAULT_ACCOUNT_ID)) { + continue; + } + + const preferredDefault = normalizeOptionalAccountId(channel.defaultAccount); + if (preferredDefault) { + if (normalizedAccountIds.includes(preferredDefault)) { + continue; + } + warnings.push( + `- channels.${channelKey}: defaultAccount is set to "${preferredDefault}" but does not match configured accounts (${normalizedAccountIds.join(", ")}). Set channels.${channelKey}.defaultAccount to one of these accounts, or add channels.${channelKey}.accounts.default, to avoid fallback routing.`, + ); + continue; + } + + warnings.push( + `- channels.${channelKey}: multiple accounts are configured but no explicit default is set. Add channels.${channelKey}.defaultAccount or channels.${channelKey}.accounts.default to avoid fallback routing.`, + ); + } + + return warnings; +} + function collectTelegramAccountScopes( cfg: OpenClawConfig, ): Array<{ prefix: string; account: Record }> { @@ -1812,6 +1864,10 @@ export async function loadAndMaybeMigrateDoctorConfig(params: { if (missingDefaultAccountBindingWarnings.length > 0) { note(missingDefaultAccountBindingWarnings.join("\n"), "Doctor warnings"); } + const missingExplicitDefaultWarnings = collectMissingExplicitDefaultAccountWarnings(candidate); + if (missingExplicitDefaultWarnings.length > 0) { + note(missingExplicitDefaultWarnings.join("\n"), "Doctor warnings"); + } if (shouldRepair) { const repair = await maybeRepairTelegramAllowFromUsernames(candidate); diff --git a/src/telegram/accounts.test.ts b/src/telegram/accounts.test.ts index 94b4b89a28c..2bf5aa74431 100644 --- a/src/telegram/accounts.test.ts +++ b/src/telegram/accounts.test.ts @@ -163,7 +163,7 @@ describe("resolveDefaultTelegramAccountId", () => { ); }); - it("warns only once per process lifetime", () => { + it("does not warn when only one non-default account is configured", () => { const cfg: OpenClawConfig = { channels: { telegram: { @@ -172,6 +172,22 @@ describe("resolveDefaultTelegramAccountId", () => { }, }; + resolveDefaultTelegramAccountId(cfg); + const warnLines = warnMock.mock.calls.map(([line]: [string]) => line); + expect(warnLines.every((line: string) => !line.includes("accounts.default is missing"))).toBe( + true, + ); + }); + + it("warns only once per process lifetime", () => { + const cfg: OpenClawConfig = { + channels: { + telegram: { + accounts: { work: { botToken: "tok-work" }, alerts: { botToken: "tok-alerts" } }, + }, + }, + }; + resolveDefaultTelegramAccountId(cfg); resolveDefaultTelegramAccountId(cfg); resolveDefaultTelegramAccountId(cfg); diff --git a/src/telegram/accounts.ts b/src/telegram/accounts.ts index bdc7fcfe5bd..5aad3154a24 100644 --- a/src/telegram/accounts.ts +++ b/src/telegram/accounts.ts @@ -86,7 +86,7 @@ export function resolveDefaultTelegramAccountId(cfg: OpenClawConfig): string { if (ids.includes(DEFAULT_ACCOUNT_ID)) { return DEFAULT_ACCOUNT_ID; } - if (ids.length > 0 && !emittedMissingDefaultWarn) { + if (ids.length > 1 && !emittedMissingDefaultWarn) { emittedMissingDefaultWarn = true; log.warn( `channels.telegram: accounts.default is missing; falling back to "${ids[0]}". ` +