fix(doctor): resolve plugin account identities

This commit is contained in:
Vincent Koc
2026-06-16 16:41:16 +08:00
parent 9afea700a3
commit da2ce686af
4 changed files with 99 additions and 21 deletions
@@ -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"],
});
});
});
+31 -4
View File
@@ -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;
@@ -9,17 +9,29 @@ vi.mock("../channel-capabilities.js", () => ({
groupAllowFromFallbackToAllowFrom: channelName !== "imessage",
warnOnEmptyGroupSenderAllowlist: channelName !== "discord",
}),
listDoctorChannelAccountIds: (
resolveDoctorChannelAccountIds: (
channelName: string,
cfg: { channels?: Record<string, { accounts?: Record<string, unknown>; baseUrl?: string }> },
cfg: {
channels?: Record<
string,
{ accounts?: Record<string, unknown>; 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(
{
@@ -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 &&