fix(doctor): route setup doctor discovery (#69919)

Merged via squash.

Prepared head SHA: 90c7067941
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana
2026-04-21 23:40:22 -04:00
committed by GitHub
parent a8a023779d
commit a197b544fe
17 changed files with 790 additions and 139 deletions

View File

@@ -26,6 +26,7 @@ Docs: https://docs.openclaw.ai
- Control UI/config: preserve intentionally empty raw config snapshots when clearing pending updates so reset restores the original bytes instead of synthesizing JSON for blank config files. (#68178) Thanks @BunsDev. - Control UI/config: preserve intentionally empty raw config snapshots when clearing pending updates so reset restores the original bytes instead of synthesizing JSON for blank config files. (#68178) Thanks @BunsDev.
- memory-core/dreaming: surface a `Dreaming status: blocked` line in `openclaw memory status` when dreaming is enabled but the heartbeat that drives the managed cron is not firing for the default agent, and add a Troubleshooting section to the dreaming docs covering the two common causes (per-agent `heartbeat` blocks excluding `main`, and `heartbeat.every` set to `0`/empty/invalid), so the silent failure described in #69843 becomes legible on the status surface. - memory-core/dreaming: surface a `Dreaming status: blocked` line in `openclaw memory status` when dreaming is enabled but the heartbeat that drives the managed cron is not firing for the default agent, and add a Troubleshooting section to the dreaming docs covering the two common causes (per-agent `heartbeat` blocks excluding `main`, and `heartbeat.every` set to `0`/empty/invalid), so the silent failure described in #69843 becomes legible on the status surface.
- Cron/run-log: report generic `message` tool sends under the resolved delivery channel when they match the cron target, while preserving account-specific mismatch checks for delivery traces. (#69940) Thanks @davehappyminion. - Cron/run-log: report generic `message` tool sends under the resolved delivery channel when they match the cron target, while preserving account-specific mismatch checks for delivery traces. (#69940) Thanks @davehappyminion.
- Doctor/channels: merge configured-channel doctor hooks across read-only, loaded, setup, and runtime plugin discovery so partial adapters no longer hide runtime-only compatibility repair or allowlist warnings, preserve disabled-channel opt-outs, and ignore malformed hook values before they can mask valid fallbacks. (#69919) Thanks @gumadeiras.
## 2026.4.21 ## 2026.4.21

View File

@@ -188,18 +188,22 @@ vi.mock("../plugins/status.js", () => ({
)) as (typeof import("../plugins/status.js"))["buildPluginCompatibilityNotices"], )) as (typeof import("../plugins/status.js"))["buildPluginCompatibilityNotices"],
})); }));
vi.mock("../plugins/slots.js", () => ({ vi.mock("../plugins/slots.js", async (importOriginal) => {
applyExclusiveSlotSelection: (( const actual = await importOriginal<typeof import("../plugins/slots.js")>();
params: Parameters<(typeof import("../plugins/slots.js"))["applyExclusiveSlotSelection"]>[0], return {
) => ...actual,
invokeMock< applyExclusiveSlotSelection: ((
[Parameters<(typeof import("../plugins/slots.js"))["applyExclusiveSlotSelection"]>[0]], params: Parameters<(typeof import("../plugins/slots.js"))["applyExclusiveSlotSelection"]>[0],
ReturnType<(typeof import("../plugins/slots.js"))["applyExclusiveSlotSelection"]> ) =>
>( invokeMock<
applyExclusiveSlotSelection, [Parameters<(typeof import("../plugins/slots.js"))["applyExclusiveSlotSelection"]>[0]],
params, ReturnType<(typeof import("../plugins/slots.js"))["applyExclusiveSlotSelection"]>
)) as (typeof import("../plugins/slots.js"))["applyExclusiveSlotSelection"], >(
})); applyExclusiveSlotSelection,
params,
)) as (typeof import("../plugins/slots.js"))["applyExclusiveSlotSelection"],
};
});
vi.mock("../plugins/uninstall.js", () => ({ vi.mock("../plugins/uninstall.js", () => ({
uninstallPlugin: (( uninstallPlugin: ((

View File

@@ -255,7 +255,9 @@ async function loadConfigFromSnapshotForInstall(
); );
} }
let nextConfig = snapshot.config; let nextConfig = snapshot.config;
for (const mutation of await collectChannelDoctorStaleConfigMutations(snapshot.config)) { for (const mutation of await collectChannelDoctorStaleConfigMutations(snapshot.config, {
env: process.env,
})) {
nextConfig = mutation.config; nextConfig = mutation.config;
} }
return nextConfig; return nextConfig;

View File

@@ -935,26 +935,26 @@ vi.mock("./doctor/shared/channel-doctor.js", () => {
return !groups && !hasOwnStringArray(groupAllowFrom); return !groups && !hasOwnStringArray(groupAllowFrom);
} }
function collectTelegramFirstTimeExtraWarnings(params: {
account: Record<string, unknown>;
channelName: string;
parent?: Record<string, unknown>;
prefix: string;
}): string[] {
if (
params.channelName !== "telegram" ||
!isTelegramFirstTimeAccount({ account: params.account, parent: params.parent })
) {
return [];
}
return [
`- ${params.prefix}: Telegram is in first-time setup mode. DMs use pairing mode. Group messages stay blocked until you add allowed chats under ${params.prefix}.groups (and optional sender IDs under ${params.prefix}.groupAllowFrom), or set ${params.prefix}.groupPolicy to "open" if you want broad group access.`,
];
}
return { return {
collectChannelDoctorCompatibilityMutations: vi.fn(collectCompatibilityMutations), collectChannelDoctorCompatibilityMutations: vi.fn(collectCompatibilityMutations),
collectChannelDoctorEmptyAllowlistExtraWarnings: vi.fn( collectChannelDoctorEmptyAllowlistExtraWarnings: vi.fn(collectTelegramFirstTimeExtraWarnings),
(params: {
account: Record<string, unknown>;
channelName: string;
parent?: Record<string, unknown>;
prefix: string;
}) => {
if (
params.channelName !== "telegram" ||
!isTelegramFirstTimeAccount({ account: params.account, parent: params.parent })
) {
return [];
}
return [
`- ${params.prefix}: Telegram is in first-time setup mode. DMs use pairing mode. Group messages stay blocked until you add allowed chats under ${params.prefix}.groups (and optional sender IDs under ${params.prefix}.groupAllowFrom), or set ${params.prefix}.groupPolicy to "open" if you want broad group access.`,
];
},
),
collectChannelDoctorMutableAllowlistWarnings: vi.fn( collectChannelDoctorMutableAllowlistWarnings: vi.fn(
({ cfg }: { cfg: { channels?: Record<string, unknown> } }) => { ({ cfg }: { cfg: { channels?: Record<string, unknown> } }) => {
const zalouser = asRecord(cfg.channels?.zalouser); const zalouser = asRecord(cfg.channels?.zalouser);
@@ -997,6 +997,11 @@ vi.mock("./doctor/shared/channel-doctor.js", () => {
}, },
), ),
collectChannelDoctorStaleConfigMutations: vi.fn(async () => []), collectChannelDoctorStaleConfigMutations: vi.fn(async () => []),
createChannelDoctorEmptyAllowlistPolicyHooks: vi.fn(() => ({
extraWarningsForAccount: collectTelegramFirstTimeExtraWarnings,
shouldSkipDefaultEmptyGroupAllowlistWarning: ({ channelName }: { channelName: string }) =>
channelName === "googlechat" || channelName === "telegram",
})),
runChannelDoctorConfigSequences: vi.fn(async () => ({ changeNotes: [], warningNotes: [] })), runChannelDoctorConfigSequences: vi.fn(async () => ({ changeNotes: [], warningNotes: [] })),
shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning: vi.fn( shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning: vi.fn(
({ channelName }: { channelName: string }) => ({ channelName }: { channelName: string }) =>

View File

@@ -8,7 +8,7 @@ import { noteOpencodeProviderOverrides } from "./doctor-config-analysis.js";
import { runDoctorConfigPreflight } from "./doctor-config-preflight.js"; import { runDoctorConfigPreflight } from "./doctor-config-preflight.js";
import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js"; import { normalizeCompatibilityConfigValues } from "./doctor-legacy-config.js";
import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js"; import type { DoctorOptions, DoctorPrompter } from "./doctor-prompter.js";
import { emitDoctorNotes } from "./doctor/emit-notes.js"; import { emitDoctorNotes, sanitizeDoctorNote } from "./doctor/emit-notes.js";
import { finalizeDoctorConfigFlow } from "./doctor/finalize-config-flow.js"; import { finalizeDoctorConfigFlow } from "./doctor/finalize-config-flow.js";
import { import {
applyLegacyCompatibilityStep, applyLegacyCompatibilityStep,
@@ -166,11 +166,12 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
for (const staleCleanup of await channelDoctor.collectChannelDoctorStaleConfigMutations( for (const staleCleanup of await channelDoctor.collectChannelDoctorStaleConfigMutations(
candidate, candidate,
{ env: process.env },
)) { )) {
if (staleCleanup.changes.length === 0) { if (staleCleanup.changes.length === 0) {
continue; continue;
} }
note(staleCleanup.changes.join("\n"), "Doctor changes"); note(sanitizeDoctorNote(staleCleanup.changes.join("\n")), "Doctor changes");
({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({ ({ cfg, candidate, pendingChanges, fixHints } = applyDoctorConfigMutation({
state: { cfg, candidate, pendingChanges, fixHints }, state: { cfg, candidate, pendingChanges, fixHints },
mutation: staleCleanup, mutation: staleCleanup,
@@ -195,6 +196,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
const repairSequence = await runDoctorRepairSequence({ const repairSequence = await runDoctorRepairSequence({
state: { cfg, candidate, pendingChanges, fixHints }, state: { cfg, candidate, pendingChanges, fixHints },
doctorFixCommand, doctorFixCommand,
env: process.env,
}); });
({ cfg, candidate, pendingChanges, fixHints } = repairSequence.state); ({ cfg, candidate, pendingChanges, fixHints } = repairSequence.state);
emitDoctorNotes({ emitDoctorNotes({
@@ -209,6 +211,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
warningNotes: await collectDoctorPreviewWarnings({ warningNotes: await collectDoctorPreviewWarnings({
cfg: candidate, cfg: candidate,
doctorFixCommand, doctorFixCommand,
env: process.env,
}), }),
}); });
} }
@@ -216,10 +219,11 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
const mutableAllowlistWarnings = collectMutableAllowlistWarnings const mutableAllowlistWarnings = collectMutableAllowlistWarnings
? await collectMutableAllowlistWarnings({ ? await collectMutableAllowlistWarnings({
cfg: candidate, cfg: candidate,
env: process.env,
}) })
: []; : [];
if (mutableAllowlistWarnings.length > 0) { if (mutableAllowlistWarnings.length > 0) {
note(mutableAllowlistWarnings.join("\n"), "Doctor warnings"); note(sanitizeDoctorNote(mutableAllowlistWarnings.join("\n")), "Doctor warnings");
} }
const unknownStep = applyUnknownConfigKeyStep({ const unknownStep = applyUnknownConfigKeyStep({

View File

@@ -29,6 +29,25 @@ describe("doctor note emission", () => {
expect(note.mock.calls).toEqual([["warning only", "Doctor warnings"]]); expect(note.mock.calls).toEqual([["warning only", "Doctor warnings"]]);
}); });
it("sanitizes emitted notes from plugin-provided doctor output", () => {
const note = vi.fn();
emitDoctorNotes({
note,
changeNotes: ["change \u001B[31mred\u001B[0m\nnext line"],
warningNotes: [
`warning \u001B]8;;https://example.test\u001B\\link\u001B]8;;\u001B\\${String.fromCharCode(
0x9b,
)}\r`,
],
});
expect(note.mock.calls).toEqual([
["change red\nnext line", "Doctor changes"],
["warning link", "Doctor warnings"],
]);
});
it("emits nothing when note groups are omitted or empty", () => { it("emits nothing when note groups are omitted or empty", () => {
const note = vi.fn(); const note = vi.fn();

View File

@@ -1,12 +1,21 @@
import { sanitizeForLog } from "../../terminal/ansi.js";
export function sanitizeDoctorNote(note: string): string {
return note
.split("\n")
.map((line) => sanitizeForLog(line))
.join("\n");
}
export function emitDoctorNotes(params: { export function emitDoctorNotes(params: {
note: (message: string, title?: string) => void; note: (message: string, title?: string) => void;
changeNotes?: string[]; changeNotes?: string[];
warningNotes?: string[]; warningNotes?: string[];
}): void { }): void {
for (const change of params.changeNotes ?? []) { for (const change of params.changeNotes ?? []) {
params.note(change, "Doctor changes"); params.note(sanitizeDoctorNote(change), "Doctor changes");
} }
for (const warning of params.warningNotes ?? []) { for (const warning of params.warningNotes ?? []) {
params.note(warning, "Doctor warnings"); params.note(sanitizeDoctorNote(warning), "Doctor warnings");
} }
} }

View File

@@ -35,7 +35,10 @@ vi.mock("./shared/channel-doctor.js", () => ({
} }
return []; return [];
}, },
collectChannelDoctorEmptyAllowlistExtraWarnings: () => [], createChannelDoctorEmptyAllowlistPolicyHooks: () => ({
extraWarningsForAccount: () => [],
shouldSkipDefaultEmptyGroupAllowlistWarning: () => false,
}),
})); }));
vi.mock("./shared/empty-allowlist-scan.js", () => ({ vi.mock("./shared/empty-allowlist-scan.js", () => ({

View File

@@ -2,7 +2,7 @@ import { sanitizeForLog } from "../../terminal/ansi.js";
import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js"; import { maybeRepairAllowlistPolicyAllowFrom } from "./shared/allowlist-policy-repair.js";
import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js"; import { maybeRepairBundledPluginLoadPaths } from "./shared/bundled-plugin-load-paths.js";
import { import {
collectChannelDoctorEmptyAllowlistExtraWarnings, createChannelDoctorEmptyAllowlistPolicyHooks,
collectChannelDoctorRepairMutations, collectChannelDoctorRepairMutations,
} from "./shared/channel-doctor.js"; } from "./shared/channel-doctor.js";
import { import {
@@ -18,6 +18,7 @@ import { maybeRepairStalePluginConfig } from "./shared/stale-plugin-config.js";
export async function runDoctorRepairSequence(params: { export async function runDoctorRepairSequence(params: {
state: DoctorConfigMutationState; state: DoctorConfigMutationState;
doctorFixCommand: string; doctorFixCommand: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ }): Promise<{
state: DoctorConfigMutationState; state: DoctorConfigMutationState;
changeNotes: string[]; changeNotes: string[];
@@ -26,6 +27,7 @@ export async function runDoctorRepairSequence(params: {
let state = params.state; let state = params.state;
const changeNotes: string[] = []; const changeNotes: string[] = [];
const warningNotes: string[] = []; const warningNotes: string[] = [];
const env = params.env ?? process.env;
const sanitizeLines = (lines: string[]) => lines.map((line) => sanitizeForLog(line)).join("\n"); const sanitizeLines = (lines: string[]) => lines.map((line) => sanitizeForLog(line)).join("\n");
const applyMutation = (mutation: { const applyMutation = (mutation: {
@@ -49,17 +51,18 @@ export async function runDoctorRepairSequence(params: {
for (const mutation of await collectChannelDoctorRepairMutations({ for (const mutation of await collectChannelDoctorRepairMutations({
cfg: state.candidate, cfg: state.candidate,
doctorFixCommand: params.doctorFixCommand, doctorFixCommand: params.doctorFixCommand,
env,
})) { })) {
applyMutation(mutation); applyMutation(mutation);
} }
applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate)); applyMutation(maybeRepairOpenPolicyAllowFrom(state.candidate));
applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, process.env)); applyMutation(maybeRepairBundledPluginLoadPaths(state.candidate, env));
applyMutation(maybeRepairStalePluginConfig(state.candidate, process.env)); applyMutation(maybeRepairStalePluginConfig(state.candidate, env));
applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate)); applyMutation(await maybeRepairAllowlistPolicyAllowFrom(state.candidate));
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(state.candidate, { const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(state.candidate, {
doctorFixCommand: params.doctorFixCommand, doctorFixCommand: params.doctorFixCommand,
extraWarningsForAccount: collectChannelDoctorEmptyAllowlistExtraWarnings, ...createChannelDoctorEmptyAllowlistPolicyHooks({ cfg: state.candidate, env }),
}); });
if (emptyAllowlistWarnings.length > 0) { if (emptyAllowlistWarnings.length > 0) {
warningNotes.push(sanitizeLines(emptyAllowlistWarnings)); warningNotes.push(sanitizeLines(emptyAllowlistWarnings));

View File

@@ -1,52 +1,273 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { collectChannelDoctorCompatibilityMutations } from "./channel-doctor.js"; import {
collectChannelDoctorCompatibilityMutations,
collectChannelDoctorEmptyAllowlistExtraWarnings,
collectChannelDoctorMutableAllowlistWarnings,
collectChannelDoctorStaleConfigMutations,
createChannelDoctorEmptyAllowlistPolicyHooks,
} from "./channel-doctor.js";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
getChannelPlugin: vi.fn(), getLoadedChannelPlugin: vi.fn(),
getBundledChannelPlugin: vi.fn(), getBundledChannelPlugin: vi.fn(),
listChannelPlugins: vi.fn(), getBundledChannelSetupPlugin: vi.fn(),
listBundledChannelPlugins: vi.fn(), resolveReadOnlyChannelPluginsForConfig: vi.fn(),
})); }));
vi.mock("../../../channels/plugins/registry.js", () => ({ vi.mock("../../../channels/plugins/registry.js", () => ({
getChannelPlugin: (...args: Parameters<typeof mocks.getChannelPlugin>) => getLoadedChannelPlugin: (...args: Parameters<typeof mocks.getLoadedChannelPlugin>) =>
mocks.getChannelPlugin(...args), mocks.getLoadedChannelPlugin(...args),
listChannelPlugins: (...args: Parameters<typeof mocks.listChannelPlugins>) =>
mocks.listChannelPlugins(...args),
})); }));
vi.mock("../../../channels/plugins/bundled.js", () => ({ vi.mock("../../../channels/plugins/bundled.js", () => ({
getBundledChannelPlugin: (...args: Parameters<typeof mocks.getBundledChannelPlugin>) => getBundledChannelPlugin: (...args: Parameters<typeof mocks.getBundledChannelPlugin>) =>
mocks.getBundledChannelPlugin(...args), mocks.getBundledChannelPlugin(...args),
listBundledChannelPlugins: (...args: Parameters<typeof mocks.listBundledChannelPlugins>) => getBundledChannelSetupPlugin: (...args: Parameters<typeof mocks.getBundledChannelSetupPlugin>) =>
mocks.listBundledChannelPlugins(...args), mocks.getBundledChannelSetupPlugin(...args),
}));
vi.mock("../../../channels/plugins/read-only.js", () => ({
resolveReadOnlyChannelPluginsForConfig: (
...args: Parameters<typeof mocks.resolveReadOnlyChannelPluginsForConfig>
) => mocks.resolveReadOnlyChannelPluginsForConfig(...args),
})); }));
describe("channel doctor compatibility mutations", () => { describe("channel doctor compatibility mutations", () => {
beforeEach(() => { beforeEach(() => {
mocks.getChannelPlugin.mockReset(); mocks.getLoadedChannelPlugin.mockReset();
mocks.getBundledChannelPlugin.mockReset(); mocks.getBundledChannelPlugin.mockReset();
mocks.listChannelPlugins.mockReset(); mocks.getBundledChannelSetupPlugin.mockReset();
mocks.listBundledChannelPlugins.mockReset(); mocks.resolveReadOnlyChannelPluginsForConfig.mockReset();
mocks.getChannelPlugin.mockReturnValue(undefined); mocks.getLoadedChannelPlugin.mockReturnValue(undefined);
mocks.getBundledChannelPlugin.mockReturnValue(undefined); mocks.getBundledChannelPlugin.mockReturnValue(undefined);
mocks.listChannelPlugins.mockReturnValue([]); mocks.getBundledChannelSetupPlugin.mockReturnValue(undefined);
mocks.listBundledChannelPlugins.mockReturnValue([]); mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({ plugins: [] });
}); });
it("skips plugin discovery when no channels are configured", () => { it("skips plugin discovery when no channels are configured", () => {
const result = collectChannelDoctorCompatibilityMutations({} as never); const result = collectChannelDoctorCompatibilityMutations({} as never);
expect(result).toEqual([]); expect(result).toEqual([]);
expect(mocks.listChannelPlugins).not.toHaveBeenCalled(); expect(mocks.resolveReadOnlyChannelPluginsForConfig).not.toHaveBeenCalled();
expect(mocks.listBundledChannelPlugins).not.toHaveBeenCalled();
}); });
it("only evaluates configured channel ids", () => { it("skips plugin discovery when only channel defaults are configured", async () => {
const result = await collectChannelDoctorStaleConfigMutations({
channels: {
defaults: {
enabled: true,
},
},
} as never);
expect(result).toEqual([]);
expect(mocks.resolveReadOnlyChannelPluginsForConfig).not.toHaveBeenCalled();
expect(mocks.getLoadedChannelPlugin).not.toHaveBeenCalled();
expect(mocks.getBundledChannelSetupPlugin).not.toHaveBeenCalled();
expect(mocks.getBundledChannelPlugin).not.toHaveBeenCalled();
});
it("skips plugin discovery for explicitly disabled channels", () => {
const result = collectChannelDoctorCompatibilityMutations({
channels: {
mattermost: {
enabled: false,
},
},
} as never);
expect(result).toEqual([]);
expect(mocks.resolveReadOnlyChannelPluginsForConfig).not.toHaveBeenCalled();
expect(mocks.getLoadedChannelPlugin).not.toHaveBeenCalled();
expect(mocks.getBundledChannelSetupPlugin).not.toHaveBeenCalled();
expect(mocks.getBundledChannelPlugin).not.toHaveBeenCalled();
});
it("uses read-only doctor adapters for configured channel ids", () => {
const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({ const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({
config: cfg, config: cfg,
changes: ["matrix"], changes: ["matrix"],
})); }));
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
plugins: [
{
id: "matrix",
doctor: { normalizeCompatibilityConfig },
},
],
});
const cfg = {
channels: {
matrix: {
enabled: true,
},
},
};
const result = collectChannelDoctorCompatibilityMutations(cfg as never);
expect(result).toHaveLength(1);
expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1);
expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(cfg, {
includePersistedAuthState: false,
});
expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelSetupPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelSetupPlugin).not.toHaveBeenCalledWith("discord");
});
it("merges partial doctor adapters instead of masking runtime-only hooks", async () => {
const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({
config: cfg,
changes: ["matrix"],
}));
const collectMutableAllowlistWarnings = vi.fn(() => ["runtime warning"]);
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
plugins: [
{
id: "matrix",
doctor: { normalizeCompatibilityConfig },
},
],
});
mocks.getBundledChannelPlugin.mockImplementation((id: string) =>
id === "matrix"
? {
id: "matrix",
doctor: { collectMutableAllowlistWarnings },
}
: undefined,
);
const cfg = {
channels: {
matrix: {
enabled: true,
},
},
};
expect(collectChannelDoctorCompatibilityMutations(cfg as never)).toHaveLength(1);
await expect(
collectChannelDoctorMutableAllowlistWarnings({ cfg: cfg as never }),
).resolves.toEqual(["runtime warning"]);
expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1);
expect(collectMutableAllowlistWarnings).toHaveBeenCalledTimes(1);
});
it("ignores malformed doctor adapter values so valid fallbacks still run", async () => {
const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({
config: cfg,
changes: ["setup"],
}));
const collectMutableAllowlistWarnings = vi.fn(() => ["runtime warning"]);
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
plugins: [
{
id: "matrix",
doctor: {
normalizeCompatibilityConfig: null,
collectMutableAllowlistWarnings: "not-a-function",
warnOnEmptyGroupSenderAllowlist: "yes",
},
},
],
});
mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) =>
id === "matrix"
? {
id: "matrix",
doctor: { normalizeCompatibilityConfig },
}
: undefined,
);
mocks.getBundledChannelPlugin.mockImplementation((id: string) =>
id === "matrix"
? {
id: "matrix",
doctor: { collectMutableAllowlistWarnings },
}
: undefined,
);
const cfg = {
channels: {
matrix: {
enabled: true,
},
},
};
expect(collectChannelDoctorCompatibilityMutations(cfg as never)).toHaveLength(1);
await expect(
collectChannelDoctorMutableAllowlistWarnings({ cfg: cfg as never }),
).resolves.toEqual(["runtime warning"]);
expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1);
expect(collectMutableAllowlistWarnings).toHaveBeenCalledTimes(1);
});
it("falls back to setup doctor adapters when read-only plugins lack doctor hooks", () => {
const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({
config: cfg,
changes: ["matrix"],
}));
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
plugins: [
{
id: "matrix",
},
],
});
mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) =>
id === "matrix"
? {
id: "matrix",
doctor: { normalizeCompatibilityConfig },
}
: undefined,
);
const cfg = {
channels: {
matrix: {
enabled: true,
},
},
};
const result = collectChannelDoctorCompatibilityMutations(cfg as never);
expect(result).toHaveLength(1);
expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1);
expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(cfg, {
includePersistedAuthState: false,
});
expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelSetupPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelPlugin).toHaveBeenCalledWith("matrix");
});
it("falls back to bundled runtime doctor adapters when setup adapters lack doctor hooks", () => {
const normalizeCompatibilityConfig = vi.fn(({ cfg }: { cfg: unknown }) => ({
config: cfg,
changes: ["matrix"],
}));
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
plugins: [
{
id: "matrix",
},
],
});
mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) =>
id === "matrix"
? {
id: "matrix",
}
: undefined,
);
mocks.getBundledChannelPlugin.mockImplementation((id: string) => mocks.getBundledChannelPlugin.mockImplementation((id: string) =>
id === "matrix" id === "matrix"
? { ? {
@@ -68,9 +289,164 @@ describe("channel doctor compatibility mutations", () => {
expect(result).toHaveLength(1); expect(result).toHaveLength(1);
expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1); expect(normalizeCompatibilityConfig).toHaveBeenCalledTimes(1);
expect(mocks.getChannelPlugin).toHaveBeenCalledWith("matrix"); expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelSetupPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelPlugin).toHaveBeenCalledWith("matrix"); expect(mocks.getBundledChannelPlugin).toHaveBeenCalledWith("matrix");
expect(mocks.getBundledChannelPlugin).not.toHaveBeenCalledWith("discord"); });
expect(mocks.listBundledChannelPlugins).not.toHaveBeenCalled();
it("passes explicit env into read-only channel plugin discovery", () => {
const cfg = {
channels: {
matrix: {
enabled: true,
},
},
};
const env = { OPENCLAW_HOME: "/tmp/openclaw-test-home" };
collectChannelDoctorCompatibilityMutations(cfg as never, { env });
expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(cfg, {
env,
includePersistedAuthState: false,
});
});
it("keeps configured channel doctor lookup non-fatal when setup loading fails", () => {
mocks.resolveReadOnlyChannelPluginsForConfig.mockImplementation(() => {
throw new Error("missing runtime dep");
});
mocks.getBundledChannelSetupPlugin.mockImplementation((id: string) => {
if (id === "discord") {
throw new Error("missing runtime dep");
}
return undefined;
});
const result = collectChannelDoctorCompatibilityMutations({
channels: {
discord: {
enabled: true,
},
},
} as never);
expect(result).toEqual([]);
expect(mocks.getLoadedChannelPlugin).toHaveBeenCalledWith("discord");
expect(mocks.getBundledChannelSetupPlugin).toHaveBeenCalledWith("discord");
expect(mocks.getBundledChannelPlugin).toHaveBeenCalledWith("discord");
});
it("uses config for empty allowlist lookup without exposing it to plugin hooks", () => {
const collectEmptyAllowlistExtraWarnings = vi.fn(({ prefix }: { prefix: string }) => [
`${prefix} extra`,
]);
const cfg = {
channels: {
matrix: {
groupPolicy: "allowlist",
},
},
};
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
plugins: [
{
id: "matrix",
doctor: { collectEmptyAllowlistExtraWarnings },
},
],
});
const result = collectChannelDoctorEmptyAllowlistExtraWarnings({
account: {},
channelName: "matrix",
cfg: cfg as never,
prefix: "channels.matrix",
});
expect(result).toEqual(["channels.matrix extra"]);
expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(cfg, {
includePersistedAuthState: false,
});
expect(collectEmptyAllowlistExtraWarnings.mock.calls[0]?.[0]).not.toHaveProperty("cfg");
});
it("reuses empty allowlist doctor entries across per-account hooks", () => {
const collectEmptyAllowlistExtraWarnings = vi.fn(({ prefix }: { prefix: string }) => [
`${prefix} extra`,
]);
const shouldSkipDefaultEmptyGroupAllowlistWarning = vi.fn(() => true);
const cfg = {
channels: {
matrix: {
accounts: {
work: {},
personal: {},
},
},
slack: {
accounts: {
team: {},
},
},
},
};
const env = { OPENCLAW_HOME: "/tmp/openclaw-test-home" };
mocks.resolveReadOnlyChannelPluginsForConfig.mockReturnValue({
plugins: [
{
id: "matrix",
doctor: {
collectEmptyAllowlistExtraWarnings,
shouldSkipDefaultEmptyGroupAllowlistWarning,
},
},
{
id: "slack",
doctor: {
collectEmptyAllowlistExtraWarnings,
},
},
],
});
const hooks = createChannelDoctorEmptyAllowlistPolicyHooks({ cfg: cfg as never, env });
expect(
hooks.extraWarningsForAccount({
account: {},
channelName: "matrix",
prefix: "channels.matrix.accounts.work",
}),
).toEqual(["channels.matrix.accounts.work extra"]);
expect(
hooks.shouldSkipDefaultEmptyGroupAllowlistWarning({
account: {},
channelName: "matrix",
prefix: "channels.matrix.accounts.work",
}),
).toBe(true);
expect(
hooks.extraWarningsForAccount({
account: {},
channelName: "matrix",
prefix: "channels.matrix.accounts.personal",
}),
).toEqual(["channels.matrix.accounts.personal extra"]);
expect(
hooks.extraWarningsForAccount({
account: {},
channelName: "slack",
prefix: "channels.slack.accounts.team",
}),
).toEqual(["channels.slack.accounts.team extra"]);
expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledTimes(1);
expect(mocks.resolveReadOnlyChannelPluginsForConfig).toHaveBeenCalledWith(cfg, {
env,
includePersistedAuthState: false,
});
expect(collectEmptyAllowlistExtraWarnings).toHaveBeenCalledTimes(3);
expect(shouldSkipDefaultEmptyGroupAllowlistWarning).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -1,8 +1,9 @@
import { import {
getBundledChannelPlugin, getBundledChannelPlugin,
listBundledChannelPlugins, getBundledChannelSetupPlugin,
} from "../../../channels/plugins/bundled.js"; } from "../../../channels/plugins/bundled.js";
import { getChannelPlugin, listChannelPlugins } from "../../../channels/plugins/registry.js"; import { resolveReadOnlyChannelPluginsForConfig } from "../../../channels/plugins/read-only.js";
import { getLoadedChannelPlugin } from "../../../channels/plugins/registry.js";
import type { import type {
ChannelDoctorAdapter, ChannelDoctorAdapter,
ChannelDoctorConfigMutation, ChannelDoctorConfigMutation,
@@ -12,10 +13,51 @@ import type {
import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js";
type ChannelDoctorEntry = { type ChannelDoctorEntry = {
channelId: string;
doctor: ChannelDoctorAdapter; doctor: ChannelDoctorAdapter;
}; };
type ChannelDoctorPluginCandidate = {
id: string;
doctor?: ChannelDoctorAdapter;
};
type ChannelDoctorLookupContext = {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
};
type ChannelDoctorEmptyAllowlistLookupParams = ChannelDoctorEmptyAllowlistAccountContext & {
cfg?: OpenClawConfig;
};
const channelDoctorFunctionKeys = new Set<keyof ChannelDoctorAdapter>([
"normalizeCompatibilityConfig",
"collectPreviewWarnings",
"collectMutableAllowlistWarnings",
"repairConfig",
"runConfigSequence",
"cleanStaleConfig",
"collectEmptyAllowlistExtraWarnings",
"shouldSkipDefaultEmptyGroupAllowlistWarning",
]);
const channelDoctorBooleanKeys = new Set<keyof ChannelDoctorAdapter>([
"groupAllowFromFallbackToAllowFrom",
"warnOnEmptyGroupSenderAllowlist",
]);
const channelDoctorEnumValues: Partial<Record<keyof ChannelDoctorAdapter, ReadonlySet<string>>> = {
dmAllowFromMode: new Set(["topOnly", "topOrNested", "nestedOnly"]),
groupModel: new Set(["sender", "route", "hybrid"]),
};
export type ChannelDoctorEmptyAllowlistPolicyHooks = {
extraWarningsForAccount: (params: ChannelDoctorEmptyAllowlistAccountContext) => string[];
shouldSkipDefaultEmptyGroupAllowlistWarning: (
params: ChannelDoctorEmptyAllowlistAccountContext,
) => boolean;
};
function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] { function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] {
const channels = const channels =
cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels) cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels)
@@ -24,55 +66,195 @@ function collectConfiguredChannelIds(cfg: OpenClawConfig): string[] {
if (!channels) { if (!channels) {
return []; return [];
} }
const channelEntries = channels as Record<string, unknown>;
return Object.keys(channels) return Object.keys(channels)
.filter((channelId) => channelId !== "defaults") .filter((channelId) => {
if (channelId === "defaults") {
return false;
}
const entry = channelEntries[channelId];
return (
!entry ||
typeof entry !== "object" ||
Array.isArray(entry) ||
(entry as { enabled?: unknown }).enabled !== false
);
})
.toSorted(); .toSorted();
} }
function safeListActiveChannelPlugins() { function safeGetLoadedChannelPlugin(id: string) {
try { try {
return listChannelPlugins(); return getLoadedChannelPlugin(id);
} catch {
return undefined;
}
}
function safeGetBundledChannelSetupPlugin(id: string) {
try {
return getBundledChannelSetupPlugin(id);
} catch {
return undefined;
}
}
function safeGetBundledChannelPlugin(id: string) {
try {
return getBundledChannelPlugin(id);
} catch {
return undefined;
}
}
function safeListReadOnlyChannelPlugins(context: ChannelDoctorLookupContext) {
try {
return resolveReadOnlyChannelPluginsForConfig(context.cfg, {
...(context.env ? { env: context.env } : {}),
includePersistedAuthState: false,
}).plugins;
} catch { } catch {
return []; return [];
} }
} }
function safeListBundledChannelPlugins() { function listReadOnlyChannelPluginsById(
try { context: ChannelDoctorLookupContext,
return listBundledChannelPlugins(); ): Map<string, ChannelDoctorPluginCandidate> {
} catch { return new Map(safeListReadOnlyChannelPlugins(context).map((plugin) => [plugin.id, plugin]));
return [];
}
} }
function listChannelDoctorEntries(channelIds?: readonly string[]): ChannelDoctorEntry[] { function mergeDoctorAdapters(
const byId = new Map<string, ChannelDoctorEntry>(); adapters: Array<ChannelDoctorAdapter | undefined>,
const selectedIds = channelIds ? new Set(channelIds) : null; ): ChannelDoctorAdapter | undefined {
const plugins = selectedIds const merged: Partial<Record<keyof ChannelDoctorAdapter, unknown>> = {};
? [...selectedIds].flatMap((id) => { for (const adapter of adapters) {
let activeOrBundledPlugin; if (!adapter) {
try {
activeOrBundledPlugin = getChannelPlugin(id);
} catch {
activeOrBundledPlugin = undefined;
}
if (activeOrBundledPlugin?.doctor) {
return [activeOrBundledPlugin];
}
const bundledPlugin = getBundledChannelPlugin(id);
return bundledPlugin ? [bundledPlugin] : [];
})
: [...safeListActiveChannelPlugins(), ...safeListBundledChannelPlugins()];
for (const plugin of plugins) {
if (!plugin.doctor) {
continue; continue;
} }
const existing = byId.get(plugin.id); for (const [key, value] of Object.entries(adapter) as Array<
if (!existing) { [keyof ChannelDoctorAdapter, unknown]
byId.set(plugin.id, { channelId: plugin.id, doctor: plugin.doctor }); >) {
if (merged[key] !== undefined) {
continue;
}
if (!isValidChannelDoctorAdapterValue(key, value)) {
continue;
}
merged[key] = value;
} }
} }
return [...byId.values()]; return Object.keys(merged).length > 0 ? (merged as ChannelDoctorAdapter) : undefined;
}
function isValidChannelDoctorAdapterValue(
key: keyof ChannelDoctorAdapter,
value: unknown,
): boolean {
if (value == null) {
return false;
}
if (channelDoctorFunctionKeys.has(key)) {
return typeof value === "function";
}
if (channelDoctorBooleanKeys.has(key)) {
return typeof value === "boolean";
}
const enumValues = channelDoctorEnumValues[key];
if (enumValues) {
return typeof value === "string" && enumValues.has(value);
}
if (key === "legacyConfigRules") {
return Array.isArray(value);
}
return false;
}
function listChannelDoctorEntries(
channelIds: readonly string[],
context: ChannelDoctorLookupContext,
options: {
readOnlyPluginsById?: ReadonlyMap<string, ChannelDoctorPluginCandidate>;
} = {},
): ChannelDoctorEntry[] {
if (channelIds.length === 0) {
return [];
}
const selectedIds = new Set(channelIds);
const readOnlyPluginsById =
options.readOnlyPluginsById ?? listReadOnlyChannelPluginsById(context);
const entries: ChannelDoctorEntry[] = [];
for (const id of selectedIds) {
const doctor = mergeDoctorAdapters([
readOnlyPluginsById.get(id)?.doctor,
safeGetLoadedChannelPlugin(id)?.doctor,
safeGetBundledChannelSetupPlugin(id)?.doctor,
safeGetBundledChannelPlugin(id)?.doctor,
]);
if (!doctor) {
continue;
}
entries.push({ doctor });
}
return entries;
}
function toPluginEmptyAllowlistContext({
cfg: _cfg,
...params
}: ChannelDoctorEmptyAllowlistLookupParams): ChannelDoctorEmptyAllowlistAccountContext {
return params;
}
function collectEmptyAllowlistExtraWarningsForEntries(
entries: readonly ChannelDoctorEntry[],
params: ChannelDoctorEmptyAllowlistLookupParams,
): string[] {
const warnings: string[] = [];
const pluginParams = toPluginEmptyAllowlistContext(params);
for (const entry of entries) {
const lines = entry.doctor.collectEmptyAllowlistExtraWarnings?.(pluginParams);
if (lines?.length) {
warnings.push(...lines);
}
}
return warnings;
}
function shouldSkipDefaultEmptyGroupAllowlistWarningForEntries(
entries: readonly ChannelDoctorEntry[],
params: ChannelDoctorEmptyAllowlistLookupParams,
): boolean {
const pluginParams = toPluginEmptyAllowlistContext(params);
return entries.some(
(entry) => entry.doctor.shouldSkipDefaultEmptyGroupAllowlistWarning?.(pluginParams) === true,
);
}
export function createChannelDoctorEmptyAllowlistPolicyHooks(
context: ChannelDoctorLookupContext,
): ChannelDoctorEmptyAllowlistPolicyHooks {
const readOnlyPluginsById = listReadOnlyChannelPluginsById(context);
const entriesByChannel = new Map<string, ChannelDoctorEntry[]>();
const entriesForChannel = (channelName: string) => {
const existing = entriesByChannel.get(channelName);
if (existing) {
return existing;
}
const entries = listChannelDoctorEntries([channelName], context, { readOnlyPluginsById });
entriesByChannel.set(channelName, entries);
return entries;
};
return {
extraWarningsForAccount: (params) =>
collectEmptyAllowlistExtraWarningsForEntries(entriesForChannel(params.channelName), params),
shouldSkipDefaultEmptyGroupAllowlistWarning: (params) =>
shouldSkipDefaultEmptyGroupAllowlistWarningForEntries(
entriesForChannel(params.channelName),
params,
),
};
} }
export async function runChannelDoctorConfigSequences(params: { export async function runChannelDoctorConfigSequences(params: {
@@ -82,7 +264,10 @@ export async function runChannelDoctorConfigSequences(params: {
}): Promise<ChannelDoctorSequenceResult> { }): Promise<ChannelDoctorSequenceResult> {
const changeNotes: string[] = []; const changeNotes: string[] = [];
const warningNotes: string[] = []; const warningNotes: string[] = [];
for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) { for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg), {
cfg: params.cfg,
env: params.env,
})) {
const result = await entry.doctor.runConfigSequence?.(params); const result = await entry.doctor.runConfigSequence?.(params);
if (!result) { if (!result) {
continue; continue;
@@ -95,6 +280,7 @@ export async function runChannelDoctorConfigSequences(params: {
export function collectChannelDoctorCompatibilityMutations( export function collectChannelDoctorCompatibilityMutations(
cfg: OpenClawConfig, cfg: OpenClawConfig,
options: { env?: NodeJS.ProcessEnv } = {},
): ChannelDoctorConfigMutation[] { ): ChannelDoctorConfigMutation[] {
const channelIds = collectConfiguredChannelIds(cfg); const channelIds = collectConfiguredChannelIds(cfg);
if (channelIds.length === 0) { if (channelIds.length === 0) {
@@ -102,7 +288,7 @@ export function collectChannelDoctorCompatibilityMutations(
} }
const mutations: ChannelDoctorConfigMutation[] = []; const mutations: ChannelDoctorConfigMutation[] = [];
let nextCfg = cfg; let nextCfg = cfg;
for (const entry of listChannelDoctorEntries(channelIds)) { for (const entry of listChannelDoctorEntries(channelIds, { cfg, env: options.env })) {
const mutation = entry.doctor.normalizeCompatibilityConfig?.({ cfg: nextCfg }); const mutation = entry.doctor.normalizeCompatibilityConfig?.({ cfg: nextCfg });
if (!mutation || mutation.changes.length === 0) { if (!mutation || mutation.changes.length === 0) {
continue; continue;
@@ -115,10 +301,14 @@ export function collectChannelDoctorCompatibilityMutations(
export async function collectChannelDoctorStaleConfigMutations( export async function collectChannelDoctorStaleConfigMutations(
cfg: OpenClawConfig, cfg: OpenClawConfig,
options: { env?: NodeJS.ProcessEnv } = {},
): Promise<ChannelDoctorConfigMutation[]> { ): Promise<ChannelDoctorConfigMutation[]> {
const mutations: ChannelDoctorConfigMutation[] = []; const mutations: ChannelDoctorConfigMutation[] = [];
let nextCfg = cfg; let nextCfg = cfg;
for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(cfg))) { for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(cfg), {
cfg,
env: options.env,
})) {
const mutation = await entry.doctor.cleanStaleConfig?.({ cfg: nextCfg }); const mutation = await entry.doctor.cleanStaleConfig?.({ cfg: nextCfg });
if (!mutation || mutation.changes.length === 0) { if (!mutation || mutation.changes.length === 0) {
continue; continue;
@@ -132,9 +322,13 @@ export async function collectChannelDoctorStaleConfigMutations(
export async function collectChannelDoctorPreviewWarnings(params: { export async function collectChannelDoctorPreviewWarnings(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
doctorFixCommand: string; doctorFixCommand: string;
env?: NodeJS.ProcessEnv;
}): Promise<string[]> { }): Promise<string[]> {
const warnings: string[] = []; const warnings: string[] = [];
for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) { for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg), {
cfg: params.cfg,
env: params.env,
})) {
const lines = await entry.doctor.collectPreviewWarnings?.(params); const lines = await entry.doctor.collectPreviewWarnings?.(params);
if (lines?.length) { if (lines?.length) {
warnings.push(...lines); warnings.push(...lines);
@@ -145,9 +339,13 @@ export async function collectChannelDoctorPreviewWarnings(params: {
export async function collectChannelDoctorMutableAllowlistWarnings(params: { export async function collectChannelDoctorMutableAllowlistWarnings(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
}): Promise<string[]> { }): Promise<string[]> {
const warnings: string[] = []; const warnings: string[] = [];
for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) { for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg), {
cfg: params.cfg,
env: params.env,
})) {
const lines = await entry.doctor.collectMutableAllowlistWarnings?.(params); const lines = await entry.doctor.collectMutableAllowlistWarnings?.(params);
if (lines?.length) { if (lines?.length) {
warnings.push(...lines); warnings.push(...lines);
@@ -159,10 +357,14 @@ export async function collectChannelDoctorMutableAllowlistWarnings(params: {
export async function collectChannelDoctorRepairMutations(params: { export async function collectChannelDoctorRepairMutations(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
doctorFixCommand: string; doctorFixCommand: string;
env?: NodeJS.ProcessEnv;
}): Promise<ChannelDoctorConfigMutation[]> { }): Promise<ChannelDoctorConfigMutation[]> {
const mutations: ChannelDoctorConfigMutation[] = []; const mutations: ChannelDoctorConfigMutation[] = [];
let nextCfg = params.cfg; let nextCfg = params.cfg;
for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg))) { for (const entry of listChannelDoctorEntries(collectConfiguredChannelIds(params.cfg), {
cfg: params.cfg,
env: params.env,
})) {
const mutation = await entry.doctor.repairConfig?.({ const mutation = await entry.doctor.repairConfig?.({
cfg: nextCfg, cfg: nextCfg,
doctorFixCommand: params.doctorFixCommand, doctorFixCommand: params.doctorFixCommand,
@@ -180,22 +382,23 @@ export async function collectChannelDoctorRepairMutations(params: {
} }
export function collectChannelDoctorEmptyAllowlistExtraWarnings( export function collectChannelDoctorEmptyAllowlistExtraWarnings(
params: ChannelDoctorEmptyAllowlistAccountContext, params: ChannelDoctorEmptyAllowlistLookupParams,
): string[] { ): string[] {
const warnings: string[] = []; return collectEmptyAllowlistExtraWarningsForEntries(
for (const entry of listChannelDoctorEntries([params.channelName])) { listChannelDoctorEntries([params.channelName], {
const lines = entry.doctor.collectEmptyAllowlistExtraWarnings?.(params); cfg: params.cfg ?? {},
if (lines?.length) { }),
warnings.push(...lines); params,
} );
}
return warnings;
} }
export function shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning( export function shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning(
params: ChannelDoctorEmptyAllowlistAccountContext, params: ChannelDoctorEmptyAllowlistLookupParams,
): boolean { ): boolean {
return listChannelDoctorEntries([params.channelName]).some( return shouldSkipDefaultEmptyGroupAllowlistWarningForEntries(
(entry) => entry.doctor.shouldSkipDefaultEmptyGroupAllowlistWarning?.(params) === true, listChannelDoctorEntries([params.channelName], {
cfg: params.cfg ?? {},
}),
params,
); );
} }

View File

@@ -1,3 +1,4 @@
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { getDoctorChannelCapabilities } from "../channel-capabilities.js"; import { getDoctorChannelCapabilities } from "../channel-capabilities.js";
import type { DoctorAccountRecord, DoctorAllowFromList } from "../types.js"; import type { DoctorAccountRecord, DoctorAllowFromList } from "../types.js";
import { hasAllowFromEntries } from "./allowlist.js"; import { hasAllowFromEntries } from "./allowlist.js";
@@ -6,9 +7,11 @@ import { shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning } from "./chan
type CollectEmptyAllowlistPolicyWarningsParams = { type CollectEmptyAllowlistPolicyWarningsParams = {
account: DoctorAccountRecord; account: DoctorAccountRecord;
channelName?: string; channelName?: string;
cfg?: OpenClawConfig;
doctorFixCommand: string; doctorFixCommand: string;
parent?: DoctorAccountRecord; parent?: DoctorAccountRecord;
prefix: string; prefix: string;
shouldSkipDefaultEmptyGroupAllowlistWarning?: typeof shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning;
}; };
function usesSenderBasedGroupAllowlist(channelName?: string): boolean { function usesSenderBasedGroupAllowlist(channelName?: string): boolean {
@@ -64,9 +67,13 @@ export function collectEmptyAllowlistPolicyWarningsForAccount(
if ( if (
params.channelName && params.channelName &&
shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning({ (
params.shouldSkipDefaultEmptyGroupAllowlistWarning ??
shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning
)({
account: params.account, account: params.account,
channelName: params.channelName, channelName: params.channelName,
cfg: params.cfg,
dmPolicy, dmPolicy,
effectiveAllowFrom, effectiveAllowFrom,
parent: params.parent, parent: params.parent,

View File

@@ -1,20 +1,15 @@
import type { ChannelDoctorEmptyAllowlistAccountContext } from "../../../channels/plugins/types.adapters.js";
import type { OpenClawConfig } from "../../../config/types.openclaw.js"; import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import type { DoctorAccountRecord, DoctorAllowFromList } from "../types.js"; import type { DoctorAccountRecord, DoctorAllowFromList } from "../types.js";
import { collectEmptyAllowlistPolicyWarningsForAccount } from "./empty-allowlist-policy.js"; import { collectEmptyAllowlistPolicyWarningsForAccount } from "./empty-allowlist-policy.js";
import { asObjectRecord } from "./object.js"; import { asObjectRecord } from "./object.js";
export type EmptyAllowlistAccountScanParams = {
account: DoctorAccountRecord;
channelName: string;
dmPolicy?: string;
effectiveAllowFrom?: DoctorAllowFromList;
parent?: DoctorAccountRecord;
prefix: string;
};
type ScanEmptyAllowlistPolicyWarningsParams = { type ScanEmptyAllowlistPolicyWarningsParams = {
doctorFixCommand: string; doctorFixCommand: string;
extraWarningsForAccount?: (params: EmptyAllowlistAccountScanParams) => string[]; extraWarningsForAccount?: (params: ChannelDoctorEmptyAllowlistAccountContext) => string[];
shouldSkipDefaultEmptyGroupAllowlistWarning?: (
params: ChannelDoctorEmptyAllowlistAccountContext,
) => boolean;
}; };
export function scanEmptyAllowlistPolicyWarnings( export function scanEmptyAllowlistPolicyWarnings(
@@ -53,9 +48,12 @@ export function scanEmptyAllowlistPolicyWarnings(
...collectEmptyAllowlistPolicyWarningsForAccount({ ...collectEmptyAllowlistPolicyWarningsForAccount({
account, account,
channelName, channelName,
cfg,
doctorFixCommand: params.doctorFixCommand, doctorFixCommand: params.doctorFixCommand,
parent, parent,
prefix, prefix,
shouldSkipDefaultEmptyGroupAllowlistWarning:
params.shouldSkipDefaultEmptyGroupAllowlistWarning,
}), }),
); );
if (params.extraWarningsForAccount) { if (params.extraWarningsForAccount) {

View File

@@ -50,6 +50,10 @@ vi.mock("./channel-doctor.js", () => ({
]; ];
}, },
), ),
createChannelDoctorEmptyAllowlistPolicyHooks: vi.fn(() => ({
extraWarningsForAccount: () => [],
shouldSkipDefaultEmptyGroupAllowlistWarning: () => false,
})),
shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning: vi.fn(() => false), shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning: vi.fn(() => false),
})); }));

View File

@@ -78,8 +78,10 @@ function hasConfiguredSafeBins(cfg: OpenClawConfig): boolean {
export async function collectDoctorPreviewWarnings(params: { export async function collectDoctorPreviewWarnings(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
doctorFixCommand: string; doctorFixCommand: string;
env?: NodeJS.ProcessEnv;
}): Promise<string[]> { }): Promise<string[]> {
const warnings: string[] = []; const warnings: string[] = [];
const env = params.env ?? process.env;
const hasChannelConfig = hasChannels(params.cfg); const hasChannelConfig = hasChannels(params.cfg);
const hasPluginConfig = hasPlugins(params.cfg); const hasPluginConfig = hasPlugins(params.cfg);
@@ -88,7 +90,7 @@ export async function collectDoctorPreviewWarnings(params: {
? await import("./channel-plugin-blockers.js") ? await import("./channel-plugin-blockers.js")
: undefined; : undefined;
const channelPluginBlockerHits = const channelPluginBlockerHits =
channelPluginRuntime?.scanConfiguredChannelPluginBlockers(params.cfg, process.env) ?? []; channelPluginRuntime?.scanConfiguredChannelPluginBlockers(params.cfg, env) ?? [];
if (channelPluginRuntime && channelPluginBlockerHits.length > 0) { if (channelPluginRuntime && channelPluginBlockerHits.length > 0) {
warnings.push( warnings.push(
channelPluginRuntime channelPluginRuntime
@@ -102,6 +104,7 @@ export async function collectDoctorPreviewWarnings(params: {
const channelDoctorWarnings = await collectChannelDoctorPreviewWarnings({ const channelDoctorWarnings = await collectChannelDoctorPreviewWarnings({
cfg: params.cfg, cfg: params.cfg,
doctorFixCommand: params.doctorFixCommand, doctorFixCommand: params.doctorFixCommand,
env,
}); });
if (channelDoctorWarnings.length > 0) { if (channelDoctorWarnings.length > 0) {
warnings.push(...channelDoctorWarnings); warnings.push(...channelDoctorWarnings);
@@ -126,13 +129,13 @@ export async function collectDoctorPreviewWarnings(params: {
isStalePluginAutoRepairBlocked, isStalePluginAutoRepairBlocked,
scanStalePluginConfig, scanStalePluginConfig,
} = await import("./stale-plugin-config.js"); } = await import("./stale-plugin-config.js");
const stalePluginHits = scanStalePluginConfig(params.cfg, process.env); const stalePluginHits = scanStalePluginConfig(params.cfg, env);
if (stalePluginHits.length > 0) { if (stalePluginHits.length > 0) {
warnings.push( warnings.push(
collectStalePluginConfigWarnings({ collectStalePluginConfigWarnings({
hits: stalePluginHits, hits: stalePluginHits,
doctorFixCommand: params.doctorFixCommand, doctorFixCommand: params.doctorFixCommand,
autoRepairBlocked: isStalePluginAutoRepairBlocked(params.cfg, process.env), autoRepairBlocked: isStalePluginAutoRepairBlocked(params.cfg, env),
}).join("\n"), }).join("\n"),
); );
} }
@@ -141,7 +144,7 @@ export async function collectDoctorPreviewWarnings(params: {
if (hasPluginLoadPaths(params.cfg)) { if (hasPluginLoadPaths(params.cfg)) {
const { collectBundledPluginLoadPathWarnings, scanBundledPluginLoadPathMigrations } = const { collectBundledPluginLoadPathWarnings, scanBundledPluginLoadPathMigrations } =
await import("./bundled-plugin-load-paths.js"); await import("./bundled-plugin-load-paths.js");
const bundledPluginLoadPathHits = scanBundledPluginLoadPathMigrations(params.cfg, process.env); const bundledPluginLoadPathHits = scanBundledPluginLoadPathMigrations(params.cfg, env);
if (bundledPluginLoadPathHits.length > 0) { if (bundledPluginLoadPathHits.length > 0) {
warnings.push( warnings.push(
collectBundledPluginLoadPathWarnings({ collectBundledPluginLoadPathWarnings({
@@ -153,11 +156,17 @@ export async function collectDoctorPreviewWarnings(params: {
} }
if (hasChannelConfig) { if (hasChannelConfig) {
const { collectChannelDoctorEmptyAllowlistExtraWarnings } = await loadChannelDoctorModule(); const { createChannelDoctorEmptyAllowlistPolicyHooks } = await loadChannelDoctorModule();
const { scanEmptyAllowlistPolicyWarnings } = await import("./empty-allowlist-scan.js"); const { scanEmptyAllowlistPolicyWarnings } = await import("./empty-allowlist-scan.js");
const emptyAllowlistHooks = createChannelDoctorEmptyAllowlistPolicyHooks({
cfg: params.cfg,
env,
});
const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(params.cfg, { const emptyAllowlistWarnings = scanEmptyAllowlistPolicyWarnings(params.cfg, {
doctorFixCommand: params.doctorFixCommand, doctorFixCommand: params.doctorFixCommand,
extraWarningsForAccount: collectChannelDoctorEmptyAllowlistExtraWarnings, extraWarningsForAccount: emptyAllowlistHooks.extraWarningsForAccount,
shouldSkipDefaultEmptyGroupAllowlistWarning:
emptyAllowlistHooks.shouldSkipDefaultEmptyGroupAllowlistWarning,
}).filter( }).filter(
(warning) => (warning) =>
!( !(

View File

@@ -15,8 +15,10 @@ describe("terminal ansi helpers", () => {
"next" + "next" +
String.fromCharCode(0) + String.fromCharCode(0) +
"line" + "line" +
String.fromCharCode(127); String.fromCharCode(127) +
expect(sanitizeForLog(input)).toBe("warnnextline"); String.fromCharCode(0x9b) +
"done";
expect(sanitizeForLog(input)).toBe("warnnextlinedone");
}); });
it("measures wide graphemes by terminal cell width", () => { it("measures wide graphemes by terminal cell width", () => {

View File

@@ -30,8 +30,8 @@ export function splitGraphemes(input: string): string[] {
/** /**
* Sanitize a value for safe interpolation into log messages. * Sanitize a value for safe interpolation into log messages.
* Strips ANSI escape sequences, C0 control characters (U+0000U+001F), * Strips ANSI escape sequences, C0/C1 control characters, and DEL to
* and DEL (U+007F) to prevent log forging / terminal escape injection (CWE-117). * prevent log forging / terminal escape injection (CWE-117).
*/ */
export function sanitizeForLog(v: string): string { export function sanitizeForLog(v: string): string {
// Pattern built at runtime so the source file stays free of literal control // Pattern built at runtime so the source file stays free of literal control
@@ -39,7 +39,9 @@ export function sanitizeForLog(v: string): string {
const c0Start = String.fromCharCode(0x00); const c0Start = String.fromCharCode(0x00);
const c0End = String.fromCharCode(0x1f); const c0End = String.fromCharCode(0x1f);
const del = String.fromCharCode(0x7f); const del = String.fromCharCode(0x7f);
const controlCharsRegex = new RegExp(`[${c0Start}-${c0End}${del}]`, "g"); const c1Start = String.fromCharCode(0x80);
const c1End = String.fromCharCode(0x9f);
const controlCharsRegex = new RegExp(`[${c0Start}-${c0End}${del}${c1Start}-${c1End}]`, "g");
return stripAnsi(v).replace(controlCharsRegex, ""); return stripAnsi(v).replace(controlCharsRegex, "");
} }