refactor(doctor): continue provider and shared extractions (#51905)

* refactor(doctor): extract empty allowlist scanning

* refactor(doctor): extract matrix provider helpers

* refactor(doctor): extract matrix repair orchestration
This commit is contained in:
Vincent Koc
2026-03-21 15:57:08 -07:00
committed by GitHub
parent 5024967e57
commit 5b31b3400e
7 changed files with 506 additions and 235 deletions

View File

@@ -3,23 +3,8 @@ import type { OpenClawConfig } from "../config/config.js";
import { CONFIG_PATH, migrateLegacyConfig } from "../config/config.js";
import { formatConfigIssueLines } from "../config/issue-format.js";
import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js";
import {
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
} from "../infra/matrix-legacy-crypto.js";
import {
autoMigrateLegacyMatrixState,
detectLegacyMatrixState,
} from "../infra/matrix-legacy-state.js";
import {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
} from "../infra/matrix-migration-snapshot.js";
import {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
} from "../infra/plugin-install-path-warnings.js";
import { detectLegacyMatrixCrypto } from "../infra/matrix-legacy-crypto.js";
import { detectLegacyMatrixState } from "../infra/matrix-legacy-state.js";
import { sanitizeForLog } from "../terminal/ansi.js";
import { note } from "../terminal/note.js";
import { noteOpencodeProviderOverrides, stripUnknownConfigKeys } from "./doctor-config-analysis.js";
@@ -31,7 +16,13 @@ import {
scanDiscordNumericIdEntries,
} from "./doctor/providers/discord.js";
import {
collectTelegramGroupPolicyWarnings,
applyMatrixDoctorRepair,
collectMatrixInstallPathWarnings,
formatMatrixLegacyCryptoPreview,
formatMatrixLegacyStatePreview,
} from "./doctor/providers/matrix.js";
import {
collectTelegramEmptyAllowlistExtraWarnings,
maybeRepairTelegramAllowFromUsernames,
scanTelegramAllowFromUsernameEntries,
} from "./doctor/providers/telegram.js";
@@ -40,7 +31,7 @@ import {
collectMissingDefaultAccountBindingWarnings,
collectMissingExplicitDefaultAccountWarnings,
} from "./doctor/shared/default-account-warnings.js";
import { collectEmptyAllowlistPolicyWarningsForAccount } from "./doctor/shared/empty-allowlist-policy.js";
import { scanEmptyAllowlistPolicyWarnings } from "./doctor/shared/empty-allowlist-scan.js";
import {
maybeRepairExecSafeBinProfiles,
scanExecSafeBinCoverage,
@@ -51,150 +42,8 @@ import {
scanLegacyToolsBySenderKeys,
} from "./doctor/shared/legacy-tools-by-sender.js";
import { scanMutableAllowlistEntries } from "./doctor/shared/mutable-allowlist.js";
import { asObjectRecord } from "./doctor/shared/object.js";
import { maybeRepairOpenPolicyAllowFrom } from "./doctor/shared/open-policy-allowfrom.js";
function formatMatrixLegacyStatePreview(
detection: Exclude<ReturnType<typeof detectLegacyMatrixState>, null | { warning: string }>,
): string {
return [
"- Matrix plugin upgraded in place.",
`- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`,
`- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`,
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
].join("\n");
}
function formatMatrixLegacyCryptoPreview(
detection: ReturnType<typeof detectLegacyMatrixCrypto>,
): string[] {
const notes: string[] = [];
for (const warning of detection.warnings) {
notes.push(`- ${warning}`);
}
for (const plan of detection.plans) {
notes.push(
[
`- Matrix encrypted-state migration is pending for account "${plan.accountId}".`,
`- Legacy crypto store: ${plan.legacyCryptoPath}`,
`- New recovery key file: ${plan.recoveryKeyPath}`,
`- Migration state file: ${plan.statePath}`,
'- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.',
].join("\n"),
);
}
return notes;
}
async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
const issue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: cfg.plugins?.installs?.matrix,
});
if (!issue) {
return [];
}
return formatPluginInstallPathIssue({
issue,
pluginLabel: "Matrix",
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
formatCommand: formatCliCommand,
}).map((entry) => `- ${entry}`);
}
/**
* Scan all channel configs for dmPolicy="allowlist" without any allowFrom entries.
* This configuration blocks all DMs because no sender can match the empty
* allowlist. Common after upgrades that remove external allowlist
* file support.
*/
function detectEmptyAllowlistPolicy(cfg: OpenClawConfig): string[] {
const channels = cfg.channels;
if (!channels || typeof channels !== "object") {
return [];
}
const warnings: string[] = [];
const checkAccount = (
account: Record<string, unknown>,
prefix: string,
parent?: Record<string, unknown>,
channelName?: string,
) => {
const accountDm = asObjectRecord(account.dm);
const parentDm = asObjectRecord(parent?.dm);
const dmPolicy =
(account.dmPolicy as string | undefined) ??
(accountDm?.policy as string | undefined) ??
(parent?.dmPolicy as string | undefined) ??
(parentDm?.policy as string | undefined) ??
undefined;
const effectiveAllowFrom =
(account.allowFrom as Array<string | number> | undefined) ??
(parent?.allowFrom as Array<string | number> | undefined) ??
(accountDm?.allowFrom as Array<string | number> | undefined) ??
(parentDm?.allowFrom as Array<string | number> | undefined) ??
undefined;
warnings.push(
...collectEmptyAllowlistPolicyWarningsForAccount({
account,
channelName,
doctorFixCommand: formatCliCommand("openclaw doctor --fix"),
parent,
prefix,
}),
);
if (
channelName === "telegram" &&
((account.groupPolicy as string | undefined) ??
(parent?.groupPolicy as string | undefined) ??
undefined) === "allowlist"
) {
warnings.push(
...collectTelegramGroupPolicyWarnings({
account,
dmPolicy,
effectiveAllowFrom,
parent,
prefix,
}),
);
}
};
for (const [channelName, channelConfig] of Object.entries(
channels as Record<string, Record<string, unknown>>,
)) {
if (!channelConfig || typeof channelConfig !== "object") {
continue;
}
checkAccount(channelConfig, `channels.${channelName}`, undefined, channelName);
const accounts = channelConfig.accounts;
if (accounts && typeof accounts === "object") {
for (const [accountId, account] of Object.entries(
accounts as Record<string, Record<string, unknown>>,
)) {
if (!account || typeof account !== "object") {
continue;
}
checkAccount(
account,
`channels.${channelName}.accounts.${accountId}`,
channelConfig,
channelName,
);
}
}
}
return warnings;
}
export async function loadAndMaybeMigrateDoctorConfig(params: {
options: DoctorOptions;
confirm: (p: { message: string; initialValue: boolean }) => Promise<boolean>;
@@ -266,80 +115,16 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
cfg: candidate,
env: process.env,
});
const pendingMatrixMigration = hasPendingMatrixMigration({
cfg: candidate,
env: process.env,
});
const actionableMatrixMigration = hasActionableMatrixMigration({
cfg: candidate,
env: process.env,
});
if (shouldRepair) {
let matrixSnapshotReady = true;
if (actionableMatrixMigration) {
try {
const snapshot = await maybeCreateMatrixMigrationSnapshot({
trigger: "doctor-fix",
env: process.env,
});
note(
`Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`,
"Doctor changes",
);
} catch (err) {
matrixSnapshotReady = false;
note(
`- Failed creating a Matrix migration snapshot before repair: ${String(err)}`,
"Doctor warnings",
);
note(
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
"Doctor warnings",
);
}
} else if (pendingMatrixMigration) {
note(
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
"Doctor warnings",
);
const matrixRepair = await applyMatrixDoctorRepair({
cfg: candidate,
env: process.env,
});
for (const change of matrixRepair.changes) {
note(change, "Doctor changes");
}
if (matrixSnapshotReady) {
const matrixStateRepair = await autoMigrateLegacyMatrixState({
cfg: candidate,
env: process.env,
});
if (matrixStateRepair.changes.length > 0) {
note(
[
"Matrix plugin upgraded in place.",
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
"- No user action required.",
].join("\n"),
"Doctor changes",
);
}
if (matrixStateRepair.warnings.length > 0) {
note(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"), "Doctor warnings");
}
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
cfg: candidate,
env: process.env,
});
if (matrixCryptoRepair.changes.length > 0) {
note(
[
"Matrix encrypted-state migration prepared.",
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
].join("\n"),
"Doctor changes",
);
}
if (matrixCryptoRepair.warnings.length > 0) {
note(
matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"),
"Doctor warnings",
);
}
for (const warning of matrixRepair.warnings) {
note(warning, "Doctor warnings");
}
} else if (matrixLegacyState) {
if ("warning" in matrixLegacyState) {
@@ -408,7 +193,10 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
cfg = allowlistRepair.config;
}
const emptyAllowlistWarnings = detectEmptyAllowlistPolicy(candidate);
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(candidate, {
doctorFixCommand: formatCliCommand("openclaw doctor --fix"),
extraWarningsForAccount: collectTelegramEmptyAllowlistExtraWarnings,
});
if (emptyAllowlistWarnings.length > 0) {
note(
emptyAllowlistWarnings.map((line) => sanitizeForLog(line)).join("\n"),
@@ -471,7 +259,10 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
);
}
const emptyAllowlistWarnings = detectEmptyAllowlistPolicy(candidate);
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(candidate, {
doctorFixCommand: formatCliCommand("openclaw doctor --fix"),
extraWarningsForAccount: collectTelegramEmptyAllowlistExtraWarnings,
});
if (emptyAllowlistWarnings.length > 0) {
note(
emptyAllowlistWarnings.map((line) => sanitizeForLog(line)).join("\n"),

View File

@@ -0,0 +1,140 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
applyMatrixDoctorRepair,
collectMatrixInstallPathWarnings,
formatMatrixLegacyCryptoPreview,
formatMatrixLegacyStatePreview,
} from "./matrix.js";
vi.mock("../../../infra/matrix-migration-snapshot.js", () => ({
hasActionableMatrixMigration: vi.fn(() => false),
hasPendingMatrixMigration: vi.fn(() => false),
maybeCreateMatrixMigrationSnapshot: vi.fn(),
}));
vi.mock("../../../infra/matrix-legacy-state.js", async () => {
const actual = await vi.importActual<typeof import("../../../infra/matrix-legacy-state.js")>(
"../../../infra/matrix-legacy-state.js",
);
return {
...actual,
autoMigrateLegacyMatrixState: vi.fn(async () => ({ changes: [], warnings: [] })),
};
});
vi.mock("../../../infra/matrix-legacy-crypto.js", async () => {
const actual = await vi.importActual<typeof import("../../../infra/matrix-legacy-crypto.js")>(
"../../../infra/matrix-legacy-crypto.js",
);
return {
...actual,
autoPrepareLegacyMatrixCrypto: vi.fn(async () => ({ changes: [], warnings: [] })),
};
});
describe("doctor matrix provider helpers", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("formats the legacy state preview", () => {
const preview = formatMatrixLegacyStatePreview({
accountId: "default",
legacyStoragePath: "/tmp/legacy-sync.json",
targetStoragePath: "/tmp/new-sync.json",
legacyCryptoPath: "/tmp/legacy-crypto.json",
targetCryptoPath: "/tmp/new-crypto.json",
selectionNote: "Picked the newest account.",
targetRootDir: "/tmp/account-root",
});
expect(preview).toContain("Matrix plugin upgraded in place.");
expect(preview).toContain("/tmp/legacy-sync.json -> /tmp/new-sync.json");
expect(preview).toContain("Picked the newest account.");
});
it("formats encrypted-state migration previews", () => {
const previews = formatMatrixLegacyCryptoPreview({
warnings: ["matrix warning"],
plans: [
{
accountId: "default",
rootDir: "/tmp/account-root",
homeserver: "https://matrix.example.org",
userId: "@bot:example.org",
accessToken: "tok-123",
deviceId: "DEVICE123",
legacyCryptoPath: "/tmp/legacy-crypto.json",
recoveryKeyPath: "/tmp/recovery-key.txt",
statePath: "/tmp/state.json",
},
],
});
expect(previews[0]).toBe("- matrix warning");
expect(previews[1]).toContain(
'Matrix encrypted-state migration is pending for account "default".',
);
expect(previews[1]).toContain("/tmp/recovery-key.txt");
});
it("warns on stale custom Matrix plugin paths", async () => {
const missingPath = path.join(tmpdir(), "openclaw-matrix-missing-provider-test");
await fs.rm(missingPath, { recursive: true, force: true });
const warnings = await collectMatrixInstallPathWarnings({
plugins: {
installs: {
matrix: {
source: "path",
sourcePath: missingPath,
installPath: missingPath,
},
},
},
});
expect(warnings[0]).toContain("custom path that no longer exists");
expect(warnings[0]).toContain(missingPath);
expect(warnings[1]).toContain("openclaw plugins install @openclaw/matrix");
expect(warnings[2]).toContain("openclaw plugins install ./extensions/matrix");
});
it("summarizes matrix repair messaging", async () => {
const matrixSnapshotModule = await import("../../../infra/matrix-migration-snapshot.js");
const matrixStateModule = await import("../../../infra/matrix-legacy-state.js");
const matrixCryptoModule = await import("../../../infra/matrix-legacy-crypto.js");
vi.mocked(matrixSnapshotModule.hasActionableMatrixMigration).mockReturnValue(true);
vi.mocked(matrixSnapshotModule.maybeCreateMatrixMigrationSnapshot).mockResolvedValue({
archivePath: "/tmp/matrix-backup.tgz",
created: true,
markerPath: "/tmp/marker.json",
});
vi.mocked(matrixStateModule.autoMigrateLegacyMatrixState).mockResolvedValue({
migrated: true,
changes: ["Migrated legacy sync state"],
warnings: [],
});
vi.mocked(matrixCryptoModule.autoPrepareLegacyMatrixCrypto).mockResolvedValue({
migrated: true,
changes: ["Prepared recovery key export"],
warnings: [],
});
const result = await applyMatrixDoctorRepair({
cfg: {},
env: process.env,
});
expect(result.changes).toEqual([
expect.stringContaining("Matrix migration snapshot created"),
expect.stringContaining("Matrix plugin upgraded in place."),
expect.stringContaining("Matrix encrypted-state migration prepared."),
]);
expect(result.warnings).toEqual([]);
});
});

View File

@@ -0,0 +1,147 @@
import { formatCliCommand } from "../../../cli/command-format.js";
import type { OpenClawConfig } from "../../../config/config.js";
import {
autoPrepareLegacyMatrixCrypto,
detectLegacyMatrixCrypto,
} from "../../../infra/matrix-legacy-crypto.js";
import {
autoMigrateLegacyMatrixState,
detectLegacyMatrixState,
} from "../../../infra/matrix-legacy-state.js";
import {
hasActionableMatrixMigration,
hasPendingMatrixMigration,
maybeCreateMatrixMigrationSnapshot,
} from "../../../infra/matrix-migration-snapshot.js";
import {
detectPluginInstallPathIssue,
formatPluginInstallPathIssue,
} from "../../../infra/plugin-install-path-warnings.js";
export function formatMatrixLegacyStatePreview(
detection: Exclude<ReturnType<typeof detectLegacyMatrixState>, null | { warning: string }>,
): string {
return [
"- Matrix plugin upgraded in place.",
`- Legacy sync store: ${detection.legacyStoragePath} -> ${detection.targetStoragePath}`,
`- Legacy crypto store: ${detection.legacyCryptoPath} -> ${detection.targetCryptoPath}`,
...(detection.selectionNote ? [`- ${detection.selectionNote}`] : []),
'- Run "openclaw doctor --fix" to migrate this Matrix state now.',
].join("\n");
}
export function formatMatrixLegacyCryptoPreview(
detection: ReturnType<typeof detectLegacyMatrixCrypto>,
): string[] {
const notes: string[] = [];
for (const warning of detection.warnings) {
notes.push(`- ${warning}`);
}
for (const plan of detection.plans) {
notes.push(
[
`- Matrix encrypted-state migration is pending for account "${plan.accountId}".`,
`- Legacy crypto store: ${plan.legacyCryptoPath}`,
`- New recovery key file: ${plan.recoveryKeyPath}`,
`- Migration state file: ${plan.statePath}`,
'- Run "openclaw doctor --fix" to extract any saved backup key now. Backed-up room keys will restore automatically on next gateway start.',
].join("\n"),
);
}
return notes;
}
export async function collectMatrixInstallPathWarnings(cfg: OpenClawConfig): Promise<string[]> {
const issue = await detectPluginInstallPathIssue({
pluginId: "matrix",
install: cfg.plugins?.installs?.matrix,
});
if (!issue) {
return [];
}
return formatPluginInstallPathIssue({
issue,
pluginLabel: "Matrix",
defaultInstallCommand: "openclaw plugins install @openclaw/matrix",
repoInstallCommand: "openclaw plugins install ./extensions/matrix",
formatCommand: formatCliCommand,
}).map((entry) => `- ${entry}`);
}
export async function applyMatrixDoctorRepair(params: {
cfg: OpenClawConfig;
env: NodeJS.ProcessEnv;
}): Promise<{ changes: string[]; warnings: string[] }> {
const changes: string[] = [];
const warnings: string[] = [];
const pendingMatrixMigration = hasPendingMatrixMigration({
cfg: params.cfg,
env: params.env,
});
const actionableMatrixMigration = hasActionableMatrixMigration({
cfg: params.cfg,
env: params.env,
});
let matrixSnapshotReady = true;
if (actionableMatrixMigration) {
try {
const snapshot = await maybeCreateMatrixMigrationSnapshot({
trigger: "doctor-fix",
env: params.env,
});
changes.push(
`Matrix migration snapshot ${snapshot.created ? "created" : "reused"} before applying Matrix upgrades.\n- ${snapshot.archivePath}`,
);
} catch (err) {
matrixSnapshotReady = false;
warnings.push(`- Failed creating a Matrix migration snapshot before repair: ${String(err)}`);
warnings.push(
'- Skipping Matrix migration changes for now. Resolve the snapshot failure, then rerun "openclaw doctor --fix".',
);
}
} else if (pendingMatrixMigration) {
warnings.push(
"- Matrix migration warnings are present, but no on-disk Matrix mutation is actionable yet. No pre-migration snapshot was needed.",
);
}
if (!matrixSnapshotReady) {
return { changes, warnings };
}
const matrixStateRepair = await autoMigrateLegacyMatrixState({
cfg: params.cfg,
env: params.env,
});
if (matrixStateRepair.changes.length > 0) {
changes.push(
[
"Matrix plugin upgraded in place.",
...matrixStateRepair.changes.map((entry) => `- ${entry}`),
"- No user action required.",
].join("\n"),
);
}
if (matrixStateRepair.warnings.length > 0) {
warnings.push(matrixStateRepair.warnings.map((entry) => `- ${entry}`).join("\n"));
}
const matrixCryptoRepair = await autoPrepareLegacyMatrixCrypto({
cfg: params.cfg,
env: params.env,
});
if (matrixCryptoRepair.changes.length > 0) {
changes.push(
[
"Matrix encrypted-state migration prepared.",
...matrixCryptoRepair.changes.map((entry) => `- ${entry}`),
].join("\n"),
);
}
if (matrixCryptoRepair.warnings.length > 0) {
warnings.push(matrixCryptoRepair.warnings.map((entry) => `- ${entry}`).join("\n"));
}
return { changes, warnings };
}

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
collectTelegramEmptyAllowlistExtraWarnings,
collectTelegramGroupPolicyWarnings,
scanTelegramAllowFromUsernameEntries,
} from "./telegram.js";
@@ -57,6 +58,33 @@ describe("doctor telegram provider warnings", () => {
expect(warnings).toEqual([]);
});
it("returns extra empty-allowlist warnings only for telegram allowlist groups", () => {
const warnings = collectTelegramEmptyAllowlistExtraWarnings({
account: {
botToken: "123:abc",
groupPolicy: "allowlist",
groups: {
ops: { allow: true },
},
},
channelName: "telegram",
prefix: "channels.telegram",
});
expect(warnings).toEqual([
expect.stringContaining(
'channels.telegram.groupPolicy is "allowlist" but groupAllowFrom (and allowFrom) is empty',
),
]);
expect(
collectTelegramEmptyAllowlistExtraWarnings({
account: { groupPolicy: "allowlist" },
channelName: "signal",
prefix: "channels.signal",
}),
).toEqual([]);
});
it("finds non-numeric telegram allowFrom username entries across account scopes", () => {
const hits = scanTelegramAllowFromUsernameEntries({
channels: {

View File

@@ -13,6 +13,7 @@ import { resolveTelegramAccount } from "../../../plugin-sdk/account-resolution.j
import { describeUnknownError } from "../../../secrets/shared.js";
import { sanitizeForLog } from "../../../terminal/ansi.js";
import { hasAllowFromEntries } from "../shared/allowlist.js";
import type { EmptyAllowlistAccountScanParams } from "../shared/empty-allowlist-scan.js";
import { asObjectRecord } from "../shared/object.js";
import type { DoctorAccountRecord, DoctorAllowFromList } from "../types.js";
@@ -338,3 +339,20 @@ export function collectTelegramGroupPolicyWarnings(
`- ${params.prefix}.groupPolicy is "allowlist" but groupAllowFrom (and allowFrom) is empty — all group messages will be silently dropped. Add sender IDs to ${params.prefix}.groupAllowFrom or ${params.prefix}.allowFrom, or set ${params.prefix}.groupPolicy to "open".`,
];
}
export function collectTelegramEmptyAllowlistExtraWarnings(
params: EmptyAllowlistAccountScanParams,
): string[] {
return params.channelName === "telegram" &&
((params.account.groupPolicy as string | undefined) ??
(params.parent?.groupPolicy as string | undefined) ??
undefined) === "allowlist"
? collectTelegramGroupPolicyWarnings({
account: params.account,
dmPolicy: params.dmPolicy,
effectiveAllowFrom: params.effectiveAllowFrom,
parent: params.parent,
prefix: params.prefix,
})
: [];
}

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { scanEmptyAllowlistPolicyWarnings } from "./empty-allowlist-scan.js";
describe("doctor empty allowlist policy scan", () => {
it("scans top-level and account-scoped channel warnings", () => {
const warnings = scanEmptyAllowlistPolicyWarnings(
{
channels: {
signal: {
dmPolicy: "allowlist",
accounts: {
work: { dmPolicy: "allowlist" },
},
},
},
},
{ doctorFixCommand: "openclaw doctor --fix" },
);
expect(warnings).toEqual([
expect.stringContaining('channels.signal.dmPolicy is "allowlist" but allowFrom is empty'),
expect.stringContaining(
'channels.signal.accounts.work.dmPolicy is "allowlist" but allowFrom is empty',
),
]);
});
it("allows provider-specific extra warnings without importing providers", () => {
const warnings = scanEmptyAllowlistPolicyWarnings(
{
channels: {
telegram: {
groupPolicy: "allowlist",
},
},
},
{
doctorFixCommand: "openclaw doctor --fix",
extraWarningsForAccount: ({ channelName, prefix }) =>
channelName === "telegram" ? [`extra:${prefix}`] : [],
},
);
expect(warnings).toContain("extra:channels.telegram");
});
});

View File

@@ -0,0 +1,101 @@
import type { OpenClawConfig } from "../../../config/config.js";
import type { DoctorAccountRecord, DoctorAllowFromList } from "../types.js";
import { collectEmptyAllowlistPolicyWarningsForAccount } from "./empty-allowlist-policy.js";
import { asObjectRecord } from "./object.js";
export type EmptyAllowlistAccountScanParams = {
account: DoctorAccountRecord;
channelName: string;
dmPolicy?: string;
effectiveAllowFrom?: DoctorAllowFromList;
parent?: DoctorAccountRecord;
prefix: string;
};
type ScanEmptyAllowlistPolicyWarningsParams = {
doctorFixCommand: string;
extraWarningsForAccount?: (params: EmptyAllowlistAccountScanParams) => string[];
};
export function scanEmptyAllowlistPolicyWarnings(
cfg: OpenClawConfig,
params: ScanEmptyAllowlistPolicyWarningsParams,
): string[] {
const channels = cfg.channels;
if (!channels || typeof channels !== "object") {
return [];
}
const warnings: string[] = [];
const checkAccount = (
account: DoctorAccountRecord,
prefix: string,
channelName: string,
parent?: DoctorAccountRecord,
) => {
const accountDm = asObjectRecord(account.dm);
const parentDm = asObjectRecord(parent?.dm);
const dmPolicy =
(account.dmPolicy as string | undefined) ??
(accountDm?.policy as string | undefined) ??
(parent?.dmPolicy as string | undefined) ??
(parentDm?.policy as string | undefined) ??
undefined;
const effectiveAllowFrom =
(account.allowFrom as DoctorAllowFromList | undefined) ??
(parent?.allowFrom as DoctorAllowFromList | undefined) ??
(accountDm?.allowFrom as DoctorAllowFromList | undefined) ??
(parentDm?.allowFrom as DoctorAllowFromList | undefined) ??
undefined;
warnings.push(
...collectEmptyAllowlistPolicyWarningsForAccount({
account,
channelName,
doctorFixCommand: params.doctorFixCommand,
parent,
prefix,
}),
);
if (params.extraWarningsForAccount) {
warnings.push(
...params.extraWarningsForAccount({
account,
channelName,
dmPolicy,
effectiveAllowFrom,
parent,
prefix,
}),
);
}
};
for (const [channelName, channelConfig] of Object.entries(
channels as Record<string, DoctorAccountRecord>,
)) {
if (!channelConfig || typeof channelConfig !== "object") {
continue;
}
checkAccount(channelConfig, `channels.${channelName}`, channelName);
const accounts = asObjectRecord(channelConfig.accounts);
if (!accounts) {
continue;
}
for (const [accountId, account] of Object.entries(accounts)) {
if (!account || typeof account !== "object") {
continue;
}
checkAccount(
account as DoctorAccountRecord,
`channels.${channelName}.accounts.${accountId}`,
channelName,
channelConfig,
);
}
}
return warnings;
}