diff --git a/src/commands/doctor/channel-capabilities.test.ts b/src/commands/doctor/channel-capabilities.test.ts index ae15b39fc269..7b293e6a49f2 100644 --- a/src/commands/doctor/channel-capabilities.test.ts +++ b/src/commands/doctor/channel-capabilities.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { getDoctorChannelCapabilities, - listDoctorChannelAccountIds, + resolveDoctorChannelAccountIds, } from "./channel-capabilities.js"; const channelPluginMocks = vi.hoisted(() => ({ @@ -74,6 +74,22 @@ describe("doctor channel capabilities", () => { throw new Error("missing generated bundled module"); }); - expect(listDoctorChannelAccountIds("telegram", {})).toBeUndefined(); + expect(resolveDoctorChannelAccountIds("telegram", {}, [])).toBeUndefined(); + }); + + it("resolves configured and runtime account ids through plugin semantics", () => { + channelPluginMocks.getChannelPlugin.mockReturnValue({ + config: { + listAccountIds: () => ["default", "Work"], + resolveAccount: (_cfg: unknown, accountId?: string | null) => ({ + accountId: accountId === "Work" ? "work" : accountId, + }), + }, + } as never); + + expect(resolveDoctorChannelAccountIds("signal", {}, ["Work"])).toEqual({ + configured: ["work"], + runtime: ["default", "work"], + }); }); }); diff --git a/src/commands/doctor/channel-capabilities.ts b/src/commands/doctor/channel-capabilities.ts index f17bf3c4b60c..dc0dfc7992c5 100644 --- a/src/commands/doctor/channel-capabilities.ts +++ b/src/commands/doctor/channel-capabilities.ts @@ -68,18 +68,45 @@ export function getDoctorChannelCapabilities(channelName?: string): DoctorChanne return mergeDoctorChannelCapabilities(getManifestDoctorCapabilities(channelId)); } -/** Resolve the account ids a channel plugin would activate for the current config. */ -export function listDoctorChannelAccountIds( +type DoctorChannelAccountIds = { + configured: string[]; + runtime: string[]; +}; + +function readResolvedAccountId(account: unknown): string | undefined { + if (!account || typeof account !== "object") { + return undefined; + } + const accountId = (account as { accountId?: unknown }).accountId; + return typeof accountId === "string" && accountId ? accountId : undefined; +} + +/** Resolve configured and runtime account ids through the channel plugin's own semantics. */ +export function resolveDoctorChannelAccountIds( channelName: string, cfg: OpenClawConfig, -): string[] | undefined { + configuredAccountIds: string[], +): DoctorChannelAccountIds | undefined { const channelId = normalizeAnyChannelId(channelName); if (!channelId) { return undefined; } try { const plugin = getChannelPlugin(channelId) ?? getBundledChannelPlugin(channelId); - return plugin?.config.listAccountIds(cfg); + if (!plugin) { + return undefined; + } + const resolveAccountIds = (accountIds: string[]): string[] | undefined => { + const resolved = accountIds.map((accountId) => + readResolvedAccountId(plugin.config.resolveAccount(cfg, accountId)), + ); + return resolved.every((accountId): accountId is string => accountId !== undefined) + ? resolved + : undefined; + }; + const configured = resolveAccountIds(configuredAccountIds); + const runtime = resolveAccountIds(plugin.config.listAccountIds(cfg)); + return configured && runtime ? { configured, runtime } : undefined; } catch { // Keep doctor warnings conservative when a plugin cannot inspect its account set. return undefined; diff --git a/src/commands/doctor/shared/empty-allowlist-scan.test.ts b/src/commands/doctor/shared/empty-allowlist-scan.test.ts index a1db3d33727a..74f1b0075cc1 100644 --- a/src/commands/doctor/shared/empty-allowlist-scan.test.ts +++ b/src/commands/doctor/shared/empty-allowlist-scan.test.ts @@ -9,17 +9,29 @@ vi.mock("../channel-capabilities.js", () => ({ groupAllowFromFallbackToAllowFrom: channelName !== "imessage", warnOnEmptyGroupSenderAllowlist: channelName !== "discord", }), - listDoctorChannelAccountIds: ( + resolveDoctorChannelAccountIds: ( channelName: string, - cfg: { channels?: Record; baseUrl?: string }> }, + cfg: { + channels?: Record< + string, + { accounts?: Record; appId?: string; baseUrl?: string } + >; + }, + configuredAccountIds: string[], ) => { const channel = cfg.channels?.[channelName]; const ids = Object.keys(channel?.accounts ?? {}); - const runtimeIds = - channelName === "matrix" ? ids.map((accountId) => accountId.toLowerCase()) : ids; - return channelName === "qa-channel" && channel?.baseUrl - ? ["default", ...runtimeIds] - : runtimeIds; + const resolveAccountId = (accountId: string) => + channelName === "matrix" || channelName === "signal" ? accountId.toLowerCase() : accountId; + const runtimeIds = [ + ...(channelName === "qa-channel" && channel?.baseUrl ? ["default"] : []), + ...(channelName === "qqbot" && channel?.appId ? ["default"] : []), + ...ids, + ]; + return { + configured: configuredAccountIds.map(resolveAccountId), + runtime: runtimeIds.map(resolveAccountId), + }; }, })); @@ -151,6 +163,28 @@ describe("doctor empty allowlist policy scan", () => { expect(warnings).toEqual([]); }); + it("keeps parent warning for a distinct case-sensitive implicit default account", () => { + const warnings = scanEmptyAllowlistPolicyWarnings( + { + channels: { + qqbot: { + appId: "top-level-app", + groupPolicy: "allowlist", + groupAllowFrom: [], + accounts: { + Default: { groupAllowFrom: ["qqbot:group:named"] }, + }, + }, + }, + }, + { doctorFixCommand: "openclaw doctor --fix" }, + ); + + expect(warnings).toContain( + '- channels.qqbot.groupPolicy is "allowlist" but groupAllowFrom (and allowFrom) is empty — all group messages will be silently dropped. Add sender IDs to channels.qqbot.groupAllowFrom or channels.qqbot.allowFrom, or set groupPolicy to "open".', + ); + }); + it("allows provider-specific extra warnings without importing providers", () => { const warnings = scanEmptyAllowlistPolicyWarnings( { diff --git a/src/commands/doctor/shared/empty-allowlist-scan.ts b/src/commands/doctor/shared/empty-allowlist-scan.ts index b82e6811aec5..f7b97bb38113 100644 --- a/src/commands/doctor/shared/empty-allowlist-scan.ts +++ b/src/commands/doctor/shared/empty-allowlist-scan.ts @@ -1,10 +1,9 @@ // Doctor scanner for empty allowlist policies across configured channels and accounts. import type { ChannelDoctorEmptyAllowlistAccountContext } from "../../../channels/plugins/types.adapters.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js"; -import { normalizeAccountId } from "../../../routing/account-id.js"; import { getDoctorChannelCapabilities, - listDoctorChannelAccountIds, + resolveDoctorChannelAccountIds, } from "../channel-capabilities.js"; import type { DoctorAccountRecord, DoctorAllowFromList } from "../types.js"; import { hasAllowFromEntries } from "./allowlist.js"; @@ -102,13 +101,15 @@ export function scanEmptyAllowlistPolicyWarnings( Boolean(account && typeof account === "object" && !isDisabledRecord(account)), ) : []; - const configuredAccountIds = new Set(Object.keys(accounts ?? {}).map(normalizeAccountId)); - const runtimeAccountIds = listDoctorChannelAccountIds(channelName, cfg); + const accountIds = resolveDoctorChannelAccountIds( + channelName, + cfg, + Object.keys(accounts ?? {}), + ); + const configuredAccountIds = new Set(accountIds?.configured); const hasImplicitActiveAccount = - runtimeAccountIds === undefined || - runtimeAccountIds.some( - (accountId) => !configuredAccountIds.has(normalizeAccountId(accountId)), - ); + accountIds === undefined || + accountIds.runtime.some((accountId) => !configuredAccountIds.has(accountId)); const suppressParentGroupAllowlistWarning = activeAccounts.length > 0 && !hasImplicitActiveAccount &&