mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-13 15:47:28 +00:00
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:
committed by
GitHub
parent
a8a023779d
commit
a197b544fe
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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: ((
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 }) =>
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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", () => ({
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ vi.mock("./channel-doctor.js", () => ({
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
createChannelDoctorEmptyAllowlistPolicyHooks: vi.fn(() => ({
|
||||||
|
extraWarningsForAccount: () => [],
|
||||||
|
shouldSkipDefaultEmptyGroupAllowlistWarning: () => false,
|
||||||
|
})),
|
||||||
shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning: vi.fn(() => false),
|
shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning: vi.fn(() => false),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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) =>
|
||||||
!(
|
!(
|
||||||
|
|||||||
@@ -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", () => {
|
||||||
|
|||||||
@@ -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+0000–U+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, "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user