Spracherkennung für: .ts vermutete Sprache: Unknown {[0] [0] [0]} [Methode: Schwerpunktbildung, einfache Gewichte, sechs Dimensionen]
import fs from "node:fs/promises";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome } from "../../test/helpers/temp-home.js";
import { loadAndMaybeMigrateDoctorConfig } from "./doctor-config-flow.js";
import {
getDoctorConfigInputForTest,
runDoctorConfigWithInput,
} from "./doctor-config-flow.test-utils.js";
type TerminalNote = (message: string, title?: string) => void;
const terminalNoteMock = vi.hoisted(() => vi.fn<TerminalNote>());
const legacyConfigMigrationForTest = vi.hoisted(() => {
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function ensureRecord(parent: Record<string, unknown>, key: string): Record<string, unknown> {
const current = asRecord(parent[key]);
if (current) {
return current;
}
const next: Record<string, unknown> = {};
parent[key] = next;
return next;
}
function migrateThreadBinding(value: unknown, changes: string[], pathLabel: string): void {
const record = asRecord(value);
const bindings = asRecord(record?.threadBindings);
if (!bindings || !("ttlHours" in bindings)) {
return;
}
if (!("idleHours" in bindings)) {
bindings.idleHours = bindings.ttlHours;
}
delete bindings.ttlHours;
changes.push(`Moved ${pathLabel}.threadBindings.ttlHours to idleHours.`);
}
function migrateStreamingAlias(channel: Record<string, unknown>, channelId: string): boolean {
if (
!("streamMode" in channel) &&
typeof channel.streaming !== "boolean" &&
typeof channel.streaming !== "string"
) {
return false;
}
if (channelId === "googlechat") {
delete channel.streamMode;
return true;
}
const streaming = asRecord(channel.streaming) ?? {};
if (!("mode" in streaming)) {
streaming.mode =
channel.streamMode === "block"
? "partial"
: channel.streaming === false
? "off"
: "partial";
}
delete channel.streamMode;
channel.streaming = streaming;
return true;
}
function migrateNestedAllowAliases(channel: Record<string, unknown>, channelId: string): boolean {
let changed = false;
if (channelId === "slack") {
for (const room of Object.values(asRecord(channel.channels) ?? {})) {
const roomRecord = asRecord(room);
if (roomRecord && "allow" in roomRecord) {
roomRecord.enabled = roomRecord.allow;
delete roomRecord.allow;
changed = true;
}
}
}
if (channelId === "googlechat") {
for (const group of Object.values(asRecord(channel.groups) ?? {})) {
const groupRecord = asRecord(group);
if (groupRecord && "allow" in groupRecord) {
groupRecord.enabled = groupRecord.allow;
delete groupRecord.allow;
changed = true;
}
}
}
if (channelId === "discord") {
for (const guild of Object.values(asRecord(channel.guilds) ?? {})) {
for (const room of Object.values(asRecord(asRecord(guild)?.channels) ?? {})) {
const roomRecord = asRecord(room);
if (roomRecord && "allow" in roomRecord) {
roomRecord.enabled = roomRecord.allow;
delete roomRecord.allow;
changed = true;
}
}
}
}
return changed;
}
function migrate(raw: unknown): { next: Record<string, unknown> | null; changes: string[] } {
const root = asRecord(raw);
if (!root) {
return { next: null, changes: [] };
}
const next = structuredClone(root);
const changes: string[] = [];
const heartbeat = asRecord(next.heartbeat);
if (heartbeat) {
const agents = ensureRecord(next, "agents");
const agentDefaults = ensureRecord(agents, "defaults");
const channels = ensureRecord(next, "channels");
const channelDefaults = ensureRecord(channels, "defaults");
const agentHeartbeat: Record<string, unknown> = {};
const channelHeartbeat: Record<string, unknown> = {};
for (const key of ["model", "every"]) {
if (key in heartbeat) {
agentHeartbeat[key] = heartbeat[key];
}
}
for (const key of ["showOk", "showAlerts", "useIndicator"]) {
if (key in heartbeat) {
channelHeartbeat[key] = heartbeat[key];
}
}
if (Object.keys(agentHeartbeat).length > 0) {
agentDefaults.heartbeat = {
...asRecord(agentDefaults.heartbeat),
...agentHeartbeat,
};
}
if (Object.keys(channelHeartbeat).length > 0) {
channelDefaults.heartbeat = {
...asRecord(channelDefaults.heartbeat),
...channelHeartbeat,
};
}
delete next.heartbeat;
changes.push("Moved heartbeat to agents.defaults.heartbeat and channels.defaults.heartbeat.");
}
const gateway = asRecord(next.gateway);
if (gateway?.bind === "0.0.0.0") {
gateway.bind = "lan";
changes.push("Normalized gateway.bind host alias.");
} else if (gateway?.bind === "localhost" || gateway?.bind === "127.0.0.1") {
gateway.bind = "loopback";
changes.push("Normalized gateway.bind host alias.");
}
migrateThreadBinding(next.session, changes, "session");
const channels = asRecord(next.channels);
for (const [channelId, channelRaw] of Object.entries(channels ?? {})) {
if (channelId === "defaults") {
continue;
}
const channel = asRecord(channelRaw);
if (!channel) {
continue;
}
migrateThreadBinding(channel, changes, `channels.${channelId}`);
if (migrateStreamingAlias(channel, channelId)) {
changes.push(`Normalized channels.${channelId} streaming aliases.`);
}
if (migrateNestedAllowAliases(channel, channelId)) {
changes.push(`Normalized channels.${channelId} nested allow aliases.`);
}
for (const [accountId, accountRaw] of Object.entries(asRecord(channel.accounts) ?? {})) {
const account = asRecord(accountRaw);
migrateThreadBinding(account, changes, `channels.${channelId}.accounts.${accountId}`);
if (account && migrateStreamingAlias(account, channelId)) {
changes.push(`Normalized channels.${channelId}.accounts.${accountId} streaming aliases.`);
}
}
}
const sandbox = asRecord(asRecord(asRecord(next.agents)?.defaults)?.sandbox);
if (sandbox && "perSession" in sandbox) {
sandbox.scope = sandbox.perSession === true ? "session" : "workspace";
delete sandbox.perSession;
changes.push("Moved agents.defaults.sandbox.perSession to scope.");
}
return changes.length > 0 ? { next, changes } : { next: null, changes: [] };
}
return {
migrate,
migrateLegacyConfig: (raw: unknown) => {
const { next, changes } = migrate(raw);
return { config: next, changes };
},
};
});
vi.mock("../terminal/note.js", () => ({
note: terminalNoteMock,
}));
vi.mock("../config/plugin-auto-enable.js", () => ({
applyPluginAutoEnable: vi.fn(
({
config,
}: {
config: {
plugins?: { allow?: string[]; entries?: Record<string, unknown> };
tools?: { alsoAllow?: string[] };
};
}) => {
if (!config.tools?.alsoAllow?.includes("browser")) {
return { config, changes: [], autoEnabledReasons: {} };
}
const allow = config.plugins?.allow ?? [];
if (allow.includes("browser")) {
return { config, changes: [], autoEnabledReasons: {} };
}
return {
config: {
...config,
plugins: {
...config.plugins,
allow: [...allow, "browser"],
entries: {
...config.plugins?.entries,
browser: {
...(config.plugins?.entries?.browser as Record<string, unknown> | undefined),
enabled: true,
},
},
},
},
changes: ["browser referenced by tools.alsoAllow, enabled automatically."],
autoEnabledReasons: { browser: ["tools.alsoAllow"] },
};
},
),
}));
vi.mock("../config/validation.js", () => ({
validateConfigObjectWithPlugins: vi.fn((config: unknown) => ({ ok: true, config })),
}));
vi.mock("../config/legacy.js", () => {
type LegacyRule = {
path: string[];
message: string;
match?: (value: unknown, root: Record<string, unknown>) => boolean;
requireSourceLiteral?: boolean;
};
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function getPathValue(root: Record<string, unknown>, pathParts: readonly string[]): unknown {
let cursor: unknown = root;
for (const part of pathParts) {
const record = asRecord(cursor);
if (!record) {
return undefined;
}
cursor = record[part];
}
return cursor;
}
function addIssue(
issues: Array<{ path: string; message: string }>,
pathParts: readonly string[],
message: string,
) {
issues.push({ path: pathParts.join("."), message });
}
function hasLegacyStreamingAlias(channel: Record<string, unknown>): boolean {
return (
"streamMode" in channel ||
"chunkMode" in channel ||
"blockStreaming" in channel ||
"draftChunk" in channel ||
"blockStreamingCoalesce" in channel ||
"nativeStreaming" in channel ||
typeof channel.streaming === "boolean" ||
typeof channel.streaming === "string"
);
}
return {
findLegacyConfigIssues: (raw: unknown, sourceRaw?: unknown, extraRules: LegacyRule[] = []) => {
const root = asRecord(raw);
if (!root) {
return [];
}
const sourceRoot = asRecord(sourceRaw) ?? root;
const issues: Array<{ path: string; message: string }> = [];
if ("heartbeat" in root) {
addIssue(
issues,
["heartbeat"],
'heartbeat is legacy; use agents.defaults.heartbeat and channels.defaults.heartbeat. Run "openclaw doctor --fix".',
);
}
if ("memorySearch" in root) {
addIssue(
issues,
["memorySearch"],
'memorySearch is legacy; use agents.defaults.memorySearch. Run "openclaw doctor --fix".',
);
}
const gateway = asRecord(root.gateway);
if (gateway && "bind" in gateway) {
addIssue(
issues,
["gateway", "bind"],
'gateway.bind host aliases are legacy; use the canonical bind mode. Run "openclaw doctor --fix".',
);
}
const sessionThreadBindings = asRecord(asRecord(root.session)?.threadBindings);
if (sessionThreadBindings && "ttlHours" in sessionThreadBindings) {
addIssue(
issues,
["session", "threadBindings", "ttlHours"],
'session.threadBindings.ttlHours is legacy; use session.threadBindings.idleHours. Run "openclaw doctor --fix".',
);
}
const xSearch = asRecord(asRecord(asRecord(root.tools)?.web)?.x_search);
if (xSearch && "apiKey" in xSearch) {
addIssue(
issues,
["tools", "web", "x_search", "apiKey"],
'tools.web.x_search.apiKey is legacy; use plugins.entries.xai.config.webSearch.apiKey. Run "openclaw doctor --fix".',
);
}
const sandbox = asRecord(asRecord(asRecord(root.agents)?.defaults)?.sandbox);
if (sandbox && "perSession" in sandbox) {
addIssue(
issues,
["agents", "defaults", "sandbox"],
'agents.defaults.sandbox.perSession is legacy; use agents.defaults.sandbox.scope. Run "openclaw doctor --fix".',
);
}
const channels = asRecord(root.channels);
for (const [channelId, channelRaw] of Object.entries(channels ?? {})) {
if (channelId === "defaults") {
continue;
}
const channel = asRecord(channelRaw);
if (!channel) {
continue;
}
if (hasLegacyStreamingAlias(channel)) {
addIssue(
issues,
["channels", channelId],
channelId === "googlechat"
? `channels.${channelId}.streamMode is legacy and no longer used. Run "openclaw doctor --fix".`
: `channels.${channelId}.streamMode, channels.${channelId}.streaming aliases are legacy. Run "openclaw doctor --fix".`,
);
}
const threadBindings = asRecord(channel.threadBindings);
if (threadBindings && "ttlHours" in threadBindings) {
addIssue(
issues,
["channels", channelId, "threadBindings", "ttlHours"],
'channels.<id>.threadBindings.ttlHours is legacy; use channels.<id>.threadBindings.idleHours. Run "openclaw doctor --fix".',
);
}
if (channelId === "slack") {
for (const roomRaw of Object.values(asRecord(channel.channels) ?? {})) {
if ("allow" in (asRecord(roomRaw) ?? {})) {
addIssue(
issues,
["channels", "slack"],
'channels.slack.channels.<id>.allow is legacy; use enabled. Run "openclaw doctor --fix".',
);
}
}
}
if (channelId === "googlechat") {
for (const spaceRaw of Object.values(asRecord(channel.groups) ?? {})) {
if ("allow" in (asRecord(spaceRaw) ?? {})) {
addIssue(
issues,
["channels", "googlechat"],
'channels.googlechat.groups.<id>.allow is legacy; use enabled. Run "openclaw doctor --fix".',
);
}
}
}
if (channelId === "discord") {
for (const guildRaw of Object.values(asRecord(channel.guilds) ?? {})) {
const guild = asRecord(guildRaw);
for (const roomRaw of Object.values(asRecord(guild?.channels) ?? {})) {
if ("allow" in (asRecord(roomRaw) ?? {})) {
addIssue(
issues,
["channels", "discord"],
'channels.discord.guilds.<id>.channels.<id>.allow is legacy; use enabled. Run "openclaw doctor --fix".',
);
}
}
}
}
for (const [accountId, accountRaw] of Object.entries(asRecord(channel.accounts) ?? {})) {
const account = asRecord(accountRaw);
const accountThreadBindings = asRecord(account?.threadBindings);
if (accountThreadBindings && "ttlHours" in accountThreadBindings) {
addIssue(
issues,
["channels", channelId, "accounts", accountId, "threadBindings", "ttlHours"],
'channels.<id>.threadBindings.ttlHours is legacy; use channels.<id>.threadBindings.idleHours. Run "openclaw doctor --fix".',
);
}
}
}
for (const rule of extraRules) {
const value = getPathValue(root, rule.path);
if (value === undefined || (rule.match && !rule.match(value, root))) {
continue;
}
if (rule.requireSourceLiteral) {
const sourceValue = getPathValue(sourceRoot, rule.path);
if (sourceValue === undefined || (rule.match && !rule.match(sourceValue, sourceRoot))) {
continue;
}
}
addIssue(issues, rule.path, rule.message);
}
return issues;
},
};
});
vi.mock("../channels/plugins/bootstrap-registry.js", () => ({
getBootstrapChannelPlugin: vi.fn((channelId: string) => {
if (channelId !== "discord") {
return undefined;
}
return {
doctor: {
normalizeCompatibilityConfig: ({
cfg,
}: {
cfg: { channels?: { discord?: Record<string, unknown> } };
}) => {
const discord = cfg.channels?.discord;
if (!discord) {
return { config: cfg, changes: [] };
}
if (
!("streamMode" in discord) &&
typeof discord.streaming !== "boolean" &&
typeof discord.streaming !== "string"
) {
return { config: cfg, changes: [] };
}
const next = structuredClone(cfg);
const nextDiscord = next.channels?.discord;
if (!nextDiscord) {
return { config: cfg, changes: [] };
}
const nextStreaming =
nextDiscord.streaming && typeof nextDiscord.streaming === "object"
? { ...(nextDiscord.streaming as Record<string, unknown>) }
: {};
if (!("mode" in nextStreaming)) {
nextStreaming.mode =
nextDiscord.streamMode === "block"
? "partial"
: nextDiscord.streaming === false
? "off"
: "partial";
}
delete nextDiscord.streamMode;
nextDiscord.streaming = nextStreaming;
return {
config: next,
changes: ["Discord allowlist ids normalized to strings."],
};
},
},
};
}),
}));
vi.mock("../channels/plugins/doctor-contract-api.js", () => ({
loadBundledChannelDoctorContractApi: vi.fn(() => undefined),
}));
vi.mock("../channels/plugins/setup-promotion-helpers.js", () => {
const commonSingleAccountKeys = new Set([
"name",
"token",
"tokenFile",
"botToken",
"appToken",
"account",
"signalNumber",
"authDir",
"cliPath",
"dbPath",
"httpUrl",
"httpHost",
"httpPort",
"webhookPath",
"webhookUrl",
"webhookSecret",
"service",
"region",
"homeserver",
"userId",
"accessToken",
"password",
"deviceName",
"url",
"code",
"dmPolicy",
"allowFrom",
"groupPolicy",
"groupAllowFrom",
"defaultTo",
]);
const fallbackSingleAccountKeys: Record<string, readonly string[]> = {
telegram: ["streaming"],
};
const namedAccountPromotionKeys: Record<string, readonly string[]> = {
telegram: ["botToken", "tokenFile"],
};
return {
resolveSingleAccountKeysToMove: ({
channelKey,
channel,
}: {
channelKey: string;
channel: Record<string, unknown>;
}) => {
const accounts =
channel.accounts && typeof channel.accounts === "object" && !Array.isArray(channel.accounts)
? (channel.accounts as Record<string, unknown>)
: {};
const hasNamedAccounts = Object.keys(accounts).some(Boolean);
const allowedNamedKeys = namedAccountPromotionKeys[channelKey];
return Object.entries(channel)
.filter(([key, value]) => {
if (key === "accounts" || key === "enabled" || value === undefined) {
return false;
}
const isKnownKey =
commonSingleAccountKeys.has(key) ||
(fallbackSingleAccountKeys[channelKey]?.includes(key) ?? false);
if (!isKnownKey) {
return false;
}
if (hasNamedAccounts && allowedNamedKeys && !allowedNamedKeys.includes(key)) {
return false;
}
return true;
})
.map(([key]) => key);
},
};
});
vi.mock("./doctor/shared/channel-legacy-config-migrate.js", () => ({
applyChannelDoctorCompatibilityMigrations: (cfg: Record<string, unknown>) => ({
next: cfg,
changes: [],
}),
}));
vi.mock("./doctor/shared/legacy-config-migrate.js", () => ({
migrateLegacyConfig: legacyConfigMigrationForTest.migrateLegacyConfig,
}));
vi.mock("./doctor/shared/bundled-plugin-load-paths.js", () => ({
maybeRepairBundledPluginLoadPaths: vi.fn((cfg: Record<string, unknown>) => ({
config: cfg,
changes: [],
})),
}));
vi.mock("./doctor/shared/exec-safe-bins.js", () => ({
maybeRepairExecSafeBinProfiles: vi.fn((cfg: Record<string, unknown>) => ({
config: cfg,
changes: [],
warnings: [],
})),
}));
vi.mock("./doctor/shared/stale-plugin-config.js", () => ({
maybeRepairStalePluginConfig: vi.fn((cfg: Record<string, unknown>) => ({
config: cfg,
changes: [],
})),
}));
vi.mock("./doctor/channel-capabilities.js", () => {
const byChannel = {
googlechat: {
dmAllowFromMode: "nestedOnly",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
},
matrix: {
dmAllowFromMode: "nestedOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
},
msteams: {
dmAllowFromMode: "topOnly",
groupModel: "hybrid",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: true,
},
zalouser: {
dmAllowFromMode: "topOnly",
groupModel: "hybrid",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
},
} as const;
const fallback = {
dmAllowFromMode: "topOnly",
groupModel: "sender",
groupAllowFromFallbackToAllowFrom: true,
warnOnEmptyGroupSenderAllowlist: true,
};
return {
getDoctorChannelCapabilities: (channelName?: string) =>
channelName && channelName in byChannel
? byChannel[channelName as keyof typeof byChannel]
: fallback,
};
});
vi.mock("../plugins/doctor-contract-registry.js", () => {
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hasLegacyTalkFields(value: unknown): boolean {
const talk = asRecord(value);
return Boolean(
talk &&
["voiceId", "voiceAliases", "modelId", "outputFormat", "apiKey"].some((key) =>
Object.prototype.hasOwnProperty.call(talk, key),
),
);
}
function resolveDiscordStreamMode(entry: Record<string, unknown>): string {
if (
entry.streamMode === "block" ||
entry.streamMode === "partial" ||
entry.streamMode === "off"
) {
return entry.streamMode;
}
if (entry.streaming === true) {
return "partial";
}
if (entry.streaming === false) {
return "off";
}
return "off";
}
function normalizeDiscordStreamingEntry(
entry: Record<string, unknown>,
pathPrefix: string,
changes: string[],
): boolean {
const hasLegacyStreaming =
"streamMode" in entry ||
typeof entry.streaming === "boolean" ||
typeof entry.streaming === "string" ||
"chunkMode" in entry ||
"blockStreaming" in entry ||
"draftChunk" in entry ||
"blockStreamingCoalesce" in entry;
if (!hasLegacyStreaming) {
return false;
}
let changed = false;
const streaming = asRecord(entry.streaming) ?? {};
if (!("mode" in streaming) && ("streamMode" in entry || typeof entry.streaming !== "object")) {
const mode = resolveDiscordStreamMode(entry);
streaming.mode = mode;
changes.push(
"streamMode" in entry
? `Moved ${pathPrefix}.streamMode → ${pathPrefix}.streaming.mode (${mode}).`
: `Moved ${pathPrefix}.streaming (boolean) → ${pathPrefix}.streaming.mode (${mode}).`,
);
changed = true;
}
if ("streamMode" in entry) {
delete entry.streamMode;
changed = true;
}
if ("chunkMode" in entry && !("chunkMode" in streaming)) {
streaming.chunkMode = entry.chunkMode;
delete entry.chunkMode;
changes.push(`Moved ${pathPrefix}.chunkMode → ${pathPrefix}.streaming.chunkMode.`);
changed = true;
}
const block = asRecord(streaming.block) ?? {};
if ("blockStreaming" in entry && !("enabled" in block)) {
block.enabled = entry.blockStreaming;
delete entry.blockStreaming;
changes.push(`Moved ${pathPrefix}.blockStreaming → ${pathPrefix}.streaming.block.enabled.`);
changed = true;
}
if ("blockStreamingCoalesce" in entry && !("coalesce" in block)) {
block.coalesce = entry.blockStreamingCoalesce;
delete entry.blockStreamingCoalesce;
changes.push(
`Moved ${pathPrefix}.blockStreamingCoalesce → ${pathPrefix}.streaming.block.coalesce.`,
);
changed = true;
}
if (Object.keys(block).length > 0) {
streaming.block = block;
}
const preview = asRecord(streaming.preview) ?? {};
if ("draftChunk" in entry && !("chunk" in preview)) {
preview.chunk = entry.draftChunk;
delete entry.draftChunk;
changes.push(`Moved ${pathPrefix}.draftChunk → ${pathPrefix}.streaming.preview.chunk.`);
changed = true;
}
if (Object.keys(preview).length > 0) {
streaming.preview = preview;
}
entry.streaming = streaming;
return changed;
}
function normalizeDiscordStreamingAliasesForTest(cfg: unknown): {
config: unknown;
changes: string[];
} {
const root = asRecord(cfg);
const discord = asRecord(asRecord(root?.channels)?.discord);
if (!root || !discord) {
return { config: cfg, changes: [] };
}
const next = structuredClone(root);
const nextDiscord = asRecord(asRecord(next.channels)?.discord);
if (!nextDiscord) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
normalizeDiscordStreamingEntry(nextDiscord, "channels.discord", changes);
const accounts = asRecord(nextDiscord.accounts);
for (const [accountId, accountRaw] of Object.entries(accounts ?? {})) {
const account = asRecord(accountRaw);
if (account) {
normalizeDiscordStreamingEntry(account, `channels.discord.accounts.${accountId}`, changes);
}
}
return changes.length > 0 ? { config: next, changes } : { config: cfg, changes: [] };
}
return {
collectRelevantDoctorPluginIds: (raw: unknown): string[] => {
const ids = new Set<string>();
const root = asRecord(raw);
const channels = asRecord(root?.channels);
for (const channelId of Object.keys(channels ?? {})) {
if (channelId !== "defaults") {
ids.add(channelId);
}
}
if (hasLegacyTalkFields(root?.talk)) {
ids.add("elevenlabs");
}
return [...ids].toSorted();
},
applyPluginDoctorCompatibilityMigrations: normalizeDiscordStreamingAliasesForTest,
listPluginDoctorLegacyConfigRules: () => [
{
path: ["channels", "telegram", "groupMentionsOnly"],
message:
'channels.telegram.groupMentionsOnly was removed; use channels.telegram.groups."*".requireMention instead. Run "openclaw doctor --fix".',
},
{
path: ["talk"],
message:
"talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey are legacy; use talk.providers.<provider> and run openclaw doctor --fix.",
match: hasLegacyTalkFields,
},
],
};
});
vi.mock("../plugins/setup-registry.js", () => ({
resolvePluginSetupAutoEnableReasons: vi.fn(() => []),
runPluginSetupConfigMigrations: vi.fn(({ config }: { config: unknown }) => ({
config,
changes: [],
})),
}));
vi.mock("./doctor/shared/channel-doctor.js", () => {
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hasOwnStringArray(value: unknown): boolean {
return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry);
}
function stringifySelectedArrays(root: Record<string, unknown>): boolean {
let changed = false;
const keysToNormalize = new Set([
"allowFrom",
"groupAllowFrom",
"groupChannels",
"approvers",
"users",
"roles",
]);
const visit = (value: unknown) => {
const record = asRecord(value);
if (!record) {
return;
}
for (const [key, entry] of Object.entries(record)) {
if (keysToNormalize.has(key) && Array.isArray(entry)) {
const next = entry.map((item) =>
typeof item === "number" || typeof item === "string" ? String(item) : item,
);
if (next.some((item, index) => item !== entry[index])) {
record[key] = next;
changed = true;
}
continue;
}
if (entry && typeof entry === "object") {
visit(entry);
}
}
};
visit(root);
return changed;
}
function collectCompatibilityMutations(cfg: { channels?: Record<string, unknown> }) {
const next = structuredClone(cfg);
const changes: string[] = [];
const telegram = asRecord(next.channels?.telegram);
if (telegram && "groupMentionsOnly" in telegram) {
const groups = asRecord(telegram.groups) ?? {};
const defaultGroup = asRecord(groups["*"]) ?? {};
if (defaultGroup.requireMention === undefined) {
defaultGroup.requireMention = telegram.groupMentionsOnly;
}
groups["*"] = defaultGroup;
telegram.groups = groups;
delete telegram.groupMentionsOnly;
changes.push(
'Moved channels.telegram.groupMentionsOnly → channels.telegram.groups."*".requireMention.',
);
}
return changes.length > 0 ? [{ config: next, changes }] : [];
}
function collectInactiveTelegramWarnings(cfg: { channels?: Record<string, unknown> }): string[] {
const telegram = asRecord(cfg.channels?.telegram);
if (!telegram) {
return [];
}
const accounts = asRecord(telegram.accounts);
if (!accounts) {
return [];
}
return Object.entries(accounts).flatMap(([accountId, accountRaw]) => {
const account = asRecord(accountRaw);
if (
!account ||
account.enabled !== false ||
!asRecord(account.botToken) ||
!hasOwnStringArray(account.allowFrom)
) {
return [];
}
return [
`- Telegram account ${accountId}: failed to inspect bot token because the account is disabled.`,
"- Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path.",
];
});
}
function isTelegramFirstTimeAccount(params: {
account: Record<string, unknown>;
parent?: Record<string, unknown>;
}): boolean {
const groupPolicy =
typeof params.account.groupPolicy === "string"
? params.account.groupPolicy
: typeof params.parent?.groupPolicy === "string"
? params.parent.groupPolicy
: undefined;
if (groupPolicy !== "allowlist") {
return false;
}
const botToken = params.account.botToken ?? params.parent?.botToken;
if (!botToken) {
return false;
}
const groups = asRecord(params.account.groups) ?? asRecord(params.parent?.groups);
const groupAllowFrom = params.account.groupAllowFrom ?? params.parent?.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 {
collectChannelDoctorCompatibilityMutations: vi.fn(collectCompatibilityMutations),
collectChannelDoctorEmptyAllowlistExtraWarnings: vi.fn(collectTelegramFirstTimeExtraWarnings),
collectChannelDoctorMutableAllowlistWarnings: vi.fn(
({ cfg }: { cfg: { channels?: Record<string, unknown> } }) => {
const zalouser = asRecord(cfg.channels?.zalouser);
if (!zalouser || zalouser.dangerouslyAllowNameMatching === true) {
return [];
}
const groups = asRecord(zalouser.groups);
if (!groups) {
return [];
}
return Object.entries(groups).flatMap(([name, group]) =>
asRecord(group)?.allow === true
? [
`- Found mutable allowlist entry across zalouser while name matching is disabled by default: channels.zalouser.groups: ${name}.`,
]
: [],
);
},
),
collectChannelDoctorPreviewWarnings: vi.fn(async () => []),
collectChannelDoctorRepairMutations: vi.fn(
async ({ cfg }: { cfg: { channels?: Record<string, unknown> } }) => {
const mutations: Array<{ config: unknown; changes: string[]; warnings?: string[] }> = [];
const discord = asRecord(cfg.channels?.discord);
if (discord) {
const next = structuredClone(cfg);
const nextDiscord = asRecord(next.channels?.discord);
if (nextDiscord && stringifySelectedArrays(nextDiscord)) {
mutations.push({
config: next,
changes: ["Discord allowlist ids normalized to strings."],
});
}
}
const telegramWarnings = collectInactiveTelegramWarnings(cfg);
if (telegramWarnings.length > 0) {
mutations.push({ config: cfg, changes: [], warnings: telegramWarnings });
}
return mutations;
},
),
collectChannelDoctorStaleConfigMutations: vi.fn(async () => []),
createChannelDoctorEmptyAllowlistPolicyHooks: vi.fn(() => ({
extraWarningsForAccount: collectTelegramFirstTimeExtraWarnings,
shouldSkipDefaultEmptyGroupAllowlistWarning: ({ channelName }: { channelName: string }) =>
channelName === "googlechat" || channelName === "telegram",
})),
runChannelDoctorConfigSequences: vi.fn(async () => ({ changeNotes: [], warningNotes: [] })),
shouldSkipChannelDoctorDefaultEmptyGroupAllowlistWarning: vi.fn(
({ channelName }: { channelName: string }) =>
channelName === "googlechat" || channelName === "telegram",
),
};
});
vi.mock("./doctor/shared/preview-warnings.js", () => {
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function hasStringEntries(value: unknown): boolean {
return Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry);
}
function telegramFirstTimeWarnings(params: {
account: Record<string, unknown>;
parent?: Record<string, unknown>;
prefix: string;
}): string[] {
const groupPolicy =
typeof params.account.groupPolicy === "string"
? params.account.groupPolicy
: typeof params.parent?.groupPolicy === "string"
? params.parent.groupPolicy
: undefined;
if (groupPolicy !== "allowlist") {
return [];
}
const botToken = params.account.botToken ?? params.parent?.botToken;
if (!botToken || asRecord(params.account.groups) || asRecord(params.parent?.groups)) {
return [];
}
if (hasStringEntries(params.account.groupAllowFrom ?? params.parent?.groupAllowFrom)) {
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 {
collectDoctorPreviewWarnings: vi.fn(
async ({
cfg,
}: {
cfg: {
channels?: Record<string, unknown>;
plugins?: { enabled?: boolean; entries?: Record<string, { enabled?: boolean }> };
};
doctorFixCommand: string;
}) => {
const warnings: string[] = [];
const telegram = asRecord(cfg.channels?.telegram);
if (telegram) {
const telegramBlocked =
cfg.plugins?.enabled === false || cfg.plugins?.entries?.telegram?.enabled === false;
if (telegramBlocked) {
warnings.push(
cfg.plugins?.enabled === false
? "- channels.telegram: channel is configured, but plugins.enabled=false blocks channel plugins globally. Fix plugin enablement before relying on setup guidance for this channel."
: '- channels.telegram: channel is configured, but plugin "telegram" is disabled by plugins.entries.telegram.enabled=false. Fix plugin enablement before relying on setup guidance for this channel.',
);
} else {
warnings.push(
...telegramFirstTimeWarnings({
account: telegram,
prefix: "channels.telegram",
}),
);
const accounts = asRecord(telegram.accounts);
for (const [accountId, accountRaw] of Object.entries(accounts ?? {})) {
const account = asRecord(accountRaw);
if (account) {
warnings.push(
...telegramFirstTimeWarnings({
account,
parent: telegram,
prefix: `channels.telegram.accounts.${accountId}`,
}),
);
}
}
}
}
const imessage = asRecord(cfg.channels?.imessage);
if (imessage?.groupPolicy === "allowlist" && !hasStringEntries(imessage.groupAllowFrom)) {
warnings.push(
'- channels.imessage.groupPolicy is "allowlist" but groupAllowFrom is empty — this channel does not fall back to allowFrom, so all group messages will be silently dropped.',
);
}
return warnings;
},
),
};
});
vi.mock("./doctor-config-preflight.js", async () => {
const fs = await import("node:fs/promises");
const path = await import("node:path");
const {
collectRelevantDoctorPluginIds,
listPluginDoctorLegacyConfigRules,
}: typeof import("../plugins/doctor-contract-registry.js") =
await import("../plugins/doctor-contract-registry.js");
const { findLegacyConfigIssues }: typeof import("../config/legacy.js") =
await import("../config/legacy.js");
function resolveConfigPath() {
const stateDir =
process.env.OPENCLAW_STATE_DIR ||
(process.env.HOME ? path.join(process.env.HOME, ".openclaw") : "");
return process.env.OPENCLAW_CONFIG_PATH || path.join(stateDir, "openclaw.json");
}
function normalizeDiscordStreamingCompat(cfg: Record<string, unknown>): Record<string, unknown> {
const channels =
cfg.channels && typeof cfg.channels === "object" && !Array.isArray(cfg.channels)
? (cfg.channels as Record<string, unknown>)
: null;
const discord =
channels?.discord && typeof channels.discord === "object" && !Array.isArray(channels.discord)
? (channels.discord as Record<string, unknown>)
: null;
if (
!discord ||
(!("streamMode" in discord) &&
typeof discord.streaming !== "boolean" &&
typeof discord.streaming !== "string")
) {
return cfg;
}
const next = structuredClone(cfg);
const nextDiscord = ((next.channels as Record<string, unknown> | undefined)?.discord ??
{}) as Record<string, unknown>;
const nextStreaming =
nextDiscord.streaming && typeof nextDiscord.streaming === "object"
? { ...(nextDiscord.streaming as Record<string, unknown>) }
: {};
if (!("mode" in nextStreaming)) {
nextStreaming.mode =
nextDiscord.streamMode === "block"
? "partial"
: nextDiscord.streaming === false
? "off"
: "partial";
}
delete nextDiscord.streamMode;
nextDiscord.streaming = nextStreaming;
return next;
}
return {
runDoctorConfigPreflight: vi.fn(async () => {
const injected = getDoctorConfigInputForTest();
const configPath = injected?.path ?? resolveConfigPath();
let parsed: Record<string, unknown> = injected?.config
? structuredClone(injected.config)
: {};
let exists = injected?.exists ?? false;
if (!injected) {
try {
parsed = JSON.parse(await fs.readFile(configPath, "utf-8")) as Record<string, unknown>;
exists = true;
} catch {
parsed = {};
}
}
if (injected?.preflightMode === "fast") {
return {
snapshot: {
exists,
path: configPath,
parsed,
config: parsed,
sourceConfig: parsed,
valid: true,
warnings: [],
legacyIssues: [],
},
baseConfig: parsed,
};
}
if (injected?.preflightMode === "issues") {
const legacyIssues = findLegacyConfigIssues(
parsed,
parsed,
listPluginDoctorLegacyConfigRules({
pluginIds: collectRelevantDoctorPluginIds(parsed),
}),
);
return {
snapshot: {
exists,
path: configPath,
parsed,
config: parsed,
sourceConfig: parsed,
valid: legacyIssues.length === 0,
warnings: [],
legacyIssues,
},
baseConfig: parsed,
};
}
const legacyIssues = findLegacyConfigIssues(
parsed,
parsed,
listPluginDoctorLegacyConfigRules({
pluginIds: collectRelevantDoctorPluginIds(parsed),
}),
);
const compat = legacyConfigMigrationForTest.migrate(parsed);
const effectiveConfig = normalizeDiscordStreamingCompat(compat.next ?? parsed);
return {
snapshot: {
exists,
path: configPath,
parsed,
config: effectiveConfig,
sourceConfig: effectiveConfig,
valid: legacyIssues.length === 0,
warnings: [],
legacyIssues,
},
baseConfig: effectiveConfig,
};
}),
};
});
vi.mock("./doctor-config-analysis.js", () => {
function formatConfigPath(parts: Array<string | number>): string {
if (parts.length === 0) {
return "<root>";
}
let out = "";
for (const part of parts) {
if (typeof part === "number") {
out += `[${part}]`;
} else {
out = out ? `${out}.${part}` : part;
}
}
return out || "<root>";
}
function resolveConfigPathTarget(root: unknown, pathParts: Array<string | number>): unknown {
let current: unknown = root;
for (const part of pathParts) {
if (typeof part === "number") {
if (!Array.isArray(current)) {
return null;
}
current = current[part];
continue;
}
if (!current || typeof current !== "object" || Array.isArray(current)) {
return null;
}
current = (current as Record<string, unknown>)[part];
}
return current;
}
return {
formatConfigPath,
noteIncludeConfinementWarning: vi.fn(),
noteOpencodeProviderOverrides: vi.fn(),
resolveConfigPathTarget,
stripUnknownConfigKeys: vi.fn((config: Record<string, unknown>) => {
const next = structuredClone(config);
const removed: string[] = [];
if ("bridge" in next) {
delete next.bridge;
removed.push("bridge");
}
const gatewayAuth = resolveConfigPathTarget(next, ["gateway", "auth"]);
if (
gatewayAuth &&
typeof gatewayAuth === "object" &&
!Array.isArray(gatewayAuth) &&
"extra" in gatewayAuth
) {
delete (gatewayAuth as Record<string, unknown>).extra;
removed.push("gateway.auth.extra");
}
return { config: next, removed };
}),
};
});
vi.mock("./doctor-state-migrations.js", () => ({
autoMigrateLegacyStateDir: vi.fn(async () => ({ changes: [], warnings: [] })),
}));
function resetTerminalNoteMock() {
terminalNoteMock.mockClear();
return terminalNoteMock;
}
async function collectDoctorWarnings(config: Record<string, unknown>): Promise<string[]> {
const noteSpy = resetTerminalNoteMock();
await runDoctorConfigWithInput({
config,
run: loadAndMaybeMigrateDoctorConfig,
});
return noteSpy.mock.calls.filter((call) => call[1] === "Doctor warnings").map((call) => call[0]);
}
type DiscordGuildRule = {
users: string[];
roles: string[];
channels: Record<string, { users: string[]; roles: string[] }>;
};
type DiscordAccountRule = {
allowFrom?: string[];
dm?: { allowFrom: string[]; groupChannels: string[] };
execApprovals?: { approvers: string[] };
guilds?: Record<string, DiscordGuildRule>;
};
type RepairedDiscordPolicy = {
allowFrom?: string[];
dm: { allowFrom: string[]; groupChannels: string[] };
execApprovals: { approvers: string[] };
guilds: Record<string, DiscordGuildRule>;
accounts: Record<string, DiscordAccountRule>;
};
describe("doctor config flow", () => {
beforeEach(() => {
terminalNoteMock.mockClear();
});
it("preserves invalid config for doctor repairs", async () => {
const result = await runDoctorConfigWithInput({
config: {
gateway: { auth: { mode: "token", token: 123 } },
agents: { list: [{ id: "pi" }] },
},
run: loadAndMaybeMigrateDoctorConfig,
});
expect((result.cfg as Record<string, unknown>).gateway).toEqual({
auth: { mode: "token", token: 123 },
});
});
it("does not warn on mutable account allowlists when dangerous name matching is inherited", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
slack: {
dangerouslyAllowNameMatching: true,
accounts: {
work: {
allowFrom: ["alice"],
},
},
},
},
});
expect(doctorWarnings.some((line) => line.includes("mutable allowlist"))).toBe(false);
});
it("does not warn about sender-based group allowlist for googlechat", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
googlechat: {
groupPolicy: "allowlist",
accounts: {
work: {
groupPolicy: "allowlist",
},
},
},
},
});
expect(
doctorWarnings.some(
(line) => line.includes('groupPolicy is "allowlist"') && line.includes("groupAllowFrom"),
),
).toBe(false);
});
it("shows first-time Telegram guidance without the old groupAllowFrom warning", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
telegram: {
botToken: "123:abc",
groupPolicy: "allowlist",
},
},
});
expect(
doctorWarnings.some(
(line) =>
line.includes('channels.telegram.groupPolicy is "allowlist"') &&
line.includes("groupAllowFrom"),
),
).toBe(false);
expect(
doctorWarnings.some(
(line) =>
line.includes("channels.telegram: Telegram is in first-time setup mode.") &&
line.includes("DMs use pairing mode") &&
line.includes("channels.telegram.groups"),
),
).toBe(true);
});
it("shows account-scoped first-time Telegram guidance without the old groupAllowFrom warning", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
telegram: {
accounts: {
default: {
botToken: "123:abc",
groupPolicy: "allowlist",
},
},
},
},
});
expect(
doctorWarnings.some(
(line) =>
line.includes('channels.telegram.accounts.default.groupPolicy is "allowlist"') &&
line.includes("groupAllowFrom"),
),
).toBe(false);
expect(
doctorWarnings.some(
(line) =>
line.includes(
"channels.telegram.accounts.default: Telegram is in first-time setup mode.",
) &&
line.includes("DMs use pairing mode") &&
line.includes("channels.telegram.accounts.default.groups"),
),
).toBe(true);
});
it("shows plugin-blocked guidance instead of first-time Telegram guidance when telegram is explicitly disabled", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
telegram: {
botToken: "123:abc",
groupPolicy: "allowlist",
},
},
plugins: {
entries: {
telegram: {
enabled: false,
},
},
},
});
expect(
doctorWarnings.some((line) =>
line.includes(
'channels.telegram: channel is configured, but plugin "telegram" is disabled by plugins.entries.telegram.enabled=false.',
),
),
).toBe(true);
expect(doctorWarnings.some((line) => line.includes("first-time setup mode"))).toBe(false);
});
it("shows plugin-blocked guidance instead of first-time Telegram guidance when plugins are disabled globally", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
telegram: {
botToken: "123:abc",
groupPolicy: "allowlist",
},
},
plugins: {
enabled: false,
},
});
expect(
doctorWarnings.some((line) =>
line.includes(
"channels.telegram: channel is configured, but plugins.enabled=false blocks channel plugins globally.",
),
),
).toBe(true);
expect(doctorWarnings.some((line) => line.includes("first-time setup mode"))).toBe(false);
});
it("warns on mutable Zalouser group entries when dangerous name matching is disabled", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
zalouser: {
groups: {
"Ops Room": { allow: true },
},
},
},
});
expect(
doctorWarnings.some(
(line) =>
line.includes("mutable allowlist") && line.includes("channels.zalouser.groups: Ops Room"),
),
).toBe(true);
});
it("does not warn on mutable Zalouser group entries when dangerous name matching is enabled", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
zalouser: {
dangerouslyAllowNameMatching: true,
groups: {
"Ops Room": { allow: true },
},
},
},
});
expect(doctorWarnings.some((line) => line.includes("channels.zalouser.groups"))).toBe(false);
});
it("warns when imessage group allowlist is empty even if allowFrom is set", async () => {
const doctorWarnings = await collectDoctorWarnings({
channels: {
imessage: {
groupPolicy: "allowlist",
allowFrom: ["+15551234567"],
},
},
});
expect(
doctorWarnings.some(
(line) =>
line.includes('channels.imessage.groupPolicy is "allowlist"') &&
line.includes("does not fall back to allowFrom"),
),
).toBe(true);
});
it("repairs generic legacy config surfaces in one pass", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
bridge: { bind: "auto" },
gateway: { auth: { mode: "token", token: "ok", extra: true } },
agents: { list: [{ id: "pi" }] },
browser: {
relayBindHost: "0.0.0.0",
profiles: {
chromeLive: {
driver: "extension",
color: "#00AA00",
},
},
},
tools: {
alsoAllow: ["browser"],
},
plugins: {
allow: ["telegram"],
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as Record<string, unknown>;
expect(cfg.bridge).toBeUndefined();
expect((cfg.gateway as Record<string, unknown>)?.auth).toEqual({
mode: "token",
token: "ok",
});
const browser = (result.cfg as { browser?: Record<string, unknown> }).browser ?? {};
expect(browser.relayBindHost).toBeUndefined();
expect(
((browser.profiles as Record<string, { driver?: string }>)?.chromeLive ?? {}).driver,
).toBe("existing-session");
expect(result.cfg.plugins?.allow).toEqual(["telegram", "browser"]);
expect(result.cfg.plugins?.entries?.browser?.enabled).toBe(true);
});
it("preserves discord streaming intent while stripping unsupported keys on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
discord: {
streaming: true,
lifecycle: {
enabled: true,
reactions: {
queued: "⏳",
thinking: "",
tool: "",
done: "✅",
error: "❌",
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
channels: {
discord: {
streamMode?: string;
streaming?:
| {
mode?: string;
}
| boolean;
lifecycle?: unknown;
};
};
};
expect(cfg.channels.discord.streaming).toEqual({ mode: "partial" });
expect(cfg.channels.discord.streamMode).toBeUndefined();
expect(cfg.channels.discord.lifecycle).toEqual({
enabled: true,
reactions: {
queued: "⏳",
thinking: "",
tool: "",
done: "✅",
error: "❌",
},
});
});
it("warns clearly about legacy channel streaming aliases and points to doctor --fix", async () => {
const noteSpy = resetTerminalNoteMock();
try {
await runDoctorConfigWithInput({
config: {
channels: {
telegram: {
streamMode: "block",
},
discord: {
streaming: false,
},
googlechat: {
streamMode: "append",
},
slack: {
streaming: true,
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
message.includes("channels.telegram:") &&
message.includes("channels.telegram.streamMode, channels.telegram.streaming"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
message.includes("channels.googlechat:") &&
message.includes("channels.googlechat.streamMode is legacy and no longer used"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
message.includes("channels.slack:") &&
message.includes("channels.slack.streamMode, channels.slack.streaming"),
),
).toBe(true);
} finally {
noteSpy.mockClear();
}
});
it("keeps discord streaming aliases on disk during repair so downgrades stay recoverable", async () => {
await withTempHome(
async (home) => {
const configDir = path.join(home, ".openclaw");
const configPath = path.join(configDir, "openclaw.json");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify(
{
channels: {
discord: {
streaming: false,
chunkMode: "newline",
blockStreaming: true,
},
},
},
null,
2,
),
"utf-8",
);
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
const persisted = JSON.parse(await fs.readFile(configPath, "utf-8")) as {
channels?: {
discord?: {
streaming?: unknown;
chunkMode?: unknown;
blockStreaming?: unknown;
};
};
};
expect(persisted.channels?.discord).toEqual({
streaming: false,
chunkMode: "newline",
blockStreaming: true,
});
},
{ skipSessionCleanup: true },
);
});
it("repairs legacy googlechat streamMode by removing it", async () => {
const result = await runDoctorConfigWithInput({
config: {
channels: {
googlechat: {
streamMode: "append",
accounts: {
work: {
streamMode: "replace",
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
channels: {
googlechat: {
accounts?: {
work?: Record<string, unknown>;
};
} & Record<string, unknown>;
};
};
expect(cfg.channels.googlechat.streamMode).toBeUndefined();
expect(cfg.channels.googlechat.accounts?.work?.streamMode).toBeUndefined();
});
it("warns clearly about legacy nested channel allow aliases and points to doctor --fix", async () => {
const noteSpy = resetTerminalNoteMock();
try {
await runDoctorConfigWithInput({
config: {
channels: {
slack: {
channels: {
ops: {
allow: false,
},
},
},
googlechat: {
groups: {
"spaces/aaa": {
allow: false,
},
},
},
discord: {
guilds: {
"100": {
channels: {
general: {
allow: false,
},
},
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
message.includes("channels.slack:") &&
message.includes("channels.slack.channels.<id>.allow is legacy"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
message.includes("channels.googlechat:") &&
message.includes("channels.googlechat.groups.<id>.allow is legacy"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
message.includes("channels.discord:") &&
message.includes("channels.discord.guilds.<id>.channels.<id>.allow is legacy"),
),
).toBe(true);
} finally {
noteSpy.mockClear();
}
});
it("repairs legacy nested channel allow aliases on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
slack: {
channels: {
ops: {
allow: false,
},
},
},
googlechat: {
groups: {
"spaces/aaa": {
allow: false,
},
},
},
discord: {
guilds: {
"100": {
channels: {
general: {
allow: false,
},
},
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
expect(result.cfg.channels?.slack?.channels?.ops).toEqual({
enabled: false,
});
expect(result.cfg.channels?.googlechat?.groups?.["spaces/aaa"]).toEqual({
enabled: false,
});
expect(result.cfg.channels?.discord?.guilds?.["100"]?.channels?.general).toEqual({
enabled: false,
});
});
it("sanitizes config-derived doctor warnings and changes before logging", async () => {
const noteSpy = resetTerminalNoteMock();
try {
await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
telegram: {
accounts: {
work: {
botToken: "tok",
allowFrom: ["@\u001b[31mtestuser"],
},
},
},
slack: {
accounts: {
work: {
allowFrom: ["alice\u001b[31m\nforged"],
},
"ops\u001b[31m\nopen": {
dmPolicy: "open",
},
},
},
whatsapp: {
accounts: {
"ops\u001b[31m\nempty": {
groupPolicy: "allowlist",
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const outputs = noteSpy.mock.calls
.filter((call) => call[1] === "Doctor warnings" || call[1] === "Doctor changes")
.map((call) => call[0]);
const joinedOutputs = outputs.join("\n");
expect(outputs.filter((line) => line.includes("\u001b"))).toEqual([]);
expect(outputs.filter((line) => line.includes("\nforged"))).toEqual([]);
expect(joinedOutputs).toContain('channels.slack.accounts.opsopen.allowFrom: set to ["*"]');
expect(joinedOutputs).toContain('required by dmPolicy="open"');
expect(
outputs.some(
(line) =>
line.includes('channels.whatsapp.accounts.opsempty.groupPolicy is "allowlist"') &&
line.includes("groupAllowFrom"),
),
).toBe(true);
} finally {
noteSpy.mockClear();
}
});
it("warns and continues when Telegram account inspection hits inactive SecretRef surfaces", async () => {
const noteSpy = resetTerminalNoteMock();
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
try {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
secrets: {
providers: {
default: { source: "env" },
},
},
channels: {
telegram: {
accounts: {
inactive: {
enabled: false,
botToken: { source: "env", provider: "default", id: "TELEGRAM_BOT_TOKEN" },
allowFrom: ["@testuser"],
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
channels?: {
telegram?: {
accounts?: Record<string, { allowFrom?: string[] }>;
};
};
};
expect(cfg.channels?.telegram?.accounts?.inactive?.allowFrom).toEqual(["@testuser"]);
expect(fetchSpy).not.toHaveBeenCalled();
expect(
noteSpy.mock.calls.some((call) =>
call[0].includes("Telegram account inactive: failed to inspect bot token"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some((call) =>
call[0].includes(
"Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path",
),
),
).toBe(true);
} finally {
noteSpy.mockClear();
vi.unstubAllGlobals();
}
});
it("converts numeric discord ids to strings on repair", async () => {
await withTempHome(
async (home) => {
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
channels: {
discord: {
allowFrom: [123],
dm: { allowFrom: [456], groupChannels: [789] },
execApprovals: { approvers: [321] },
guilds: {
"100": {
users: [111],
roles: [222],
channels: {
general: { users: [333], roles: [444] },
},
},
},
accounts: {
work: {
allowFrom: [555],
dm: { allowFrom: [666], groupChannels: [777] },
execApprovals: { approvers: [888] },
guilds: {
"200": {
users: [999],
roles: [1010],
channels: {
help: { users: [1111], roles: [1212] },
},
},
},
},
},
},
},
},
null,
2,
),
"utf-8",
);
const result = await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
const cfg = result.cfg as unknown as {
channels: {
discord: Omit<RepairedDiscordPolicy, "allowFrom"> & {
allowFrom?: string[];
accounts: Record<string, DiscordAccountRule> & {
default: { allowFrom: string[] };
work: {
allowFrom: string[];
dm: { allowFrom: string[]; groupChannels: string[] };
execApprovals: { approvers: string[] };
guilds: Record<string, DiscordGuildRule>;
};
};
};
};
};
expect(cfg.channels.discord.allowFrom).toBeUndefined();
expect(cfg.channels.discord.dm.allowFrom).toEqual(["456"]);
expect(cfg.channels.discord.dm.groupChannels).toEqual(["789"]);
expect(cfg.channels.discord.execApprovals.approvers).toEqual(["321"]);
expect(cfg.channels.discord.guilds["100"].users).toEqual(["111"]);
expect(cfg.channels.discord.guilds["100"].roles).toEqual(["222"]);
expect(cfg.channels.discord.guilds["100"].channels.general.users).toEqual(["333"]);
expect(cfg.channels.discord.guilds["100"].channels.general.roles).toEqual(["444"]);
expect(cfg.channels.discord.accounts.default.allowFrom).toEqual(["123"]);
expect(cfg.channels.discord.accounts.work.allowFrom).toEqual(["555"]);
expect(cfg.channels.discord.accounts.work.dm.allowFrom).toEqual(["666"]);
expect(cfg.channels.discord.accounts.work.dm.groupChannels).toEqual(["777"]);
expect(cfg.channels.discord.accounts.work.execApprovals.approvers).toEqual(["888"]);
expect(cfg.channels.discord.accounts.work.guilds["200"].users).toEqual(["999"]);
expect(cfg.channels.discord.accounts.work.guilds["200"].roles).toEqual(["1010"]);
expect(cfg.channels.discord.accounts.work.guilds["200"].channels.help.users).toEqual([
"1111",
]);
expect(cfg.channels.discord.accounts.work.guilds["200"].channels.help.roles).toEqual([
"1212",
]);
},
{ skipSessionCleanup: true },
);
});
it("does not restore top-level allowFrom when config is intentionally default-account scoped", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
discord: {
accounts: {
default: { token: "discord-default-token", allowFrom: ["123"] },
work: { token: "discord-work-token" },
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
channels: {
discord: {
allowFrom?: string[];
accounts: Record<string, { allowFrom?: string[] }>;
};
};
};
expect(cfg.channels.discord.allowFrom).toBeUndefined();
expect(cfg.channels.discord.accounts.default.allowFrom).toEqual(["123"]);
});
it('repairs open dmPolicy allowFrom variants with ["*"] in one pass', async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
discord: {
token: "test-token",
dmPolicy: "open",
groupPolicy: "open",
},
googlechat: {
accounts: {
work: {
dm: {
policy: "open",
},
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as unknown as {
channels: {
discord: { allowFrom: string[]; dmPolicy: string };
googlechat: {
accounts: {
work: {
dm: {
policy: string;
allowFrom: string[];
};
allowFrom?: string[];
};
};
};
};
};
expect(cfg.channels.discord.allowFrom).toEqual(["*"]);
expect(cfg.channels.discord.dmPolicy).toBe("open");
expect(cfg.channels.googlechat.accounts.work.dm.allowFrom).toEqual(["*"]);
expect(cfg.channels.googlechat.accounts.work.allowFrom).toBeUndefined();
});
it('repairs dmPolicy="allowlist" by restoring allowFrom from pairing store on repair', async () => {
const result = await withTempHome(
async (home) => {
const configDir = path.join(home, ".openclaw");
const credentialsDir = path.join(configDir, "credentials");
await fs.mkdir(credentialsDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
channels: {
telegram: {
botToken: "fake-token",
dmPolicy: "allowlist",
},
},
},
null,
2,
),
"utf-8",
);
await fs.writeFile(
path.join(credentialsDir, "telegram-allowFrom.json"),
JSON.stringify({ version: 1, allowFrom: ["12345"] }, null, 2),
"utf-8",
);
return await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
},
{ skipSessionCleanup: true },
);
const cfg = result.cfg as {
channels: {
telegram: {
dmPolicy: string;
allowFrom: string[];
};
};
};
expect(cfg.channels.telegram.dmPolicy).toBe("allowlist");
expect(cfg.channels.telegram.allowFrom).toEqual(["12345"]);
});
it("migrates legacy toolsBySender keys to typed id entries on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
whatsapp: {
groups: {
"123@g.us": {
toolsBySender: {
owner: { allow: ["exec"] },
alice: { deny: ["exec"] },
"id:owner": { deny: ["exec"] },
"username:@ops-bot": { allow: ["fs.read"] },
"*": { deny: ["exec"] },
},
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as unknown as {
channels: {
whatsapp: {
groups: {
"123@g.us": {
toolsBySender: Record<string, { allow?: string[]; deny?: string[] }>;
};
};
};
};
};
const toolsBySender = cfg.channels.whatsapp.groups["123@g.us"].toolsBySender;
expect(toolsBySender.owner).toBeUndefined();
expect(toolsBySender.alice).toBeUndefined();
expect(toolsBySender["id:owner"]).toEqual({ deny: ["exec"] });
expect(toolsBySender["id:alice"]).toEqual({ deny: ["exec"] });
expect(toolsBySender["username:@ops-bot"]).toEqual({ allow: ["fs.read"] });
expect(toolsBySender["*"]).toEqual({ deny: ["exec"] });
});
it("repairs legacy root runtime config surfaces in one pass", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
heartbeat: {
model: "anthropic/claude-3-5-haiku-20241022",
every: "30m",
showOk: true,
showAlerts: false,
},
gateway: {
bind: "0.0.0.0",
},
session: {
threadBindings: {
ttlHours: 24,
},
},
channels: {
discord: {
threadBindings: {
ttlHours: 12,
},
accounts: {
alpha: {
threadBindings: {
ttlHours: 6,
},
},
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
heartbeat?: unknown;
gateway?: {
bind?: string;
};
session?: {
threadBindings?: {
idleHours?: number;
ttlHours?: number;
};
};
agents?: {
defaults?: {
heartbeat?: {
model?: string;
every?: string;
};
};
};
channels?: {
defaults?: {
heartbeat?: {
showOk?: boolean;
showAlerts?: boolean;
useIndicator?: boolean;
};
};
discord?: {
threadBindings?: {
idleHours?: number;
ttlHours?: number;
};
accounts?: Record<
string,
{
threadBindings?: {
idleHours?: number;
ttlHours?: number;
};
}
>;
};
};
};
expect(cfg.heartbeat).toBeUndefined();
expect(cfg.agents?.defaults?.heartbeat).toMatchObject({
model: "anthropic/claude-3-5-haiku-20241022",
every: "30m",
});
expect(cfg.gateway?.bind).toBe("lan");
expect(cfg.session?.threadBindings).toMatchObject({
idleHours: 24,
});
expect(cfg.channels?.discord?.threadBindings).toMatchObject({
idleHours: 12,
});
expect(cfg.channels?.discord?.accounts?.alpha?.threadBindings).toMatchObject({
idleHours: 6,
});
expect(cfg.session?.threadBindings?.ttlHours).toBeUndefined();
expect(cfg.channels?.discord?.threadBindings?.ttlHours).toBeUndefined();
expect(cfg.channels?.discord?.accounts?.alpha?.threadBindings?.ttlHours).toBeUndefined();
expect(cfg.channels?.defaults?.heartbeat).toMatchObject({
showOk: true,
showAlerts: false,
});
});
it("warns clearly about legacy config surfaces and points to doctor --fix", async () => {
const noteSpy = resetTerminalNoteMock();
try {
await runDoctorConfigWithInput({
config: {
heartbeat: {
model: "anthropic/claude-3-5-haiku-20241022",
every: "30m",
showOk: true,
showAlerts: false,
},
memorySearch: {
provider: "local",
fallback: "none",
},
gateway: {
bind: "localhost",
},
channels: {
telegram: {
groupMentionsOnly: true,
},
discord: {
threadBindings: {
ttlHours: 12,
},
accounts: {
alpha: {
threadBindings: {
ttlHours: 6,
},
},
},
},
},
tools: {
web: {
x_search: {
apiKey: "test-key",
},
},
},
hooks: {
internal: {
handlers: [{ event: "command:new", module: "hooks/legacy-handler.js" }],
},
},
session: {
threadBindings: {
ttlHours: 24,
},
},
talk: {
voiceId: "voice-1",
modelId: "eleven_v3",
},
agents: {
defaults: {
sandbox: {
perSession: true,
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const legacyMessages = noteSpy.mock.calls
.filter(([, title]) => title === "Legacy config keys detected")
.map(([message]) => message)
.join("\n");
expect(legacyMessages).toContain("heartbeat:");
expect(legacyMessages).toContain("agents.defaults.heartbeat");
expect(legacyMessages).toContain("channels.defaults.heartbeat");
expect(legacyMessages).toContain("memorySearch:");
expect(legacyMessages).toContain("agents.defaults.memorySearch");
expect(legacyMessages).toContain("gateway.bind:");
expect(legacyMessages).toContain("gateway.bind host aliases");
expect(legacyMessages).toContain("channels.telegram.groupMentionsOnly:");
expect(legacyMessages).toContain("channels.telegram.groups");
expect(legacyMessages).toContain("tools.web.x_search.apiKey:");
expect(legacyMessages).toContain("plugins.entries.xai.config.webSearch.apiKey");
expect(legacyMessages).toContain("hooks.internal.handlers:");
expect(legacyMessages).toContain("HOOK.md + handler.js");
expect(legacyMessages).toContain("does not rewrite this shape automatically");
expect(legacyMessages).toContain("session.threadBindings.ttlHours");
expect(legacyMessages).toContain("session.threadBindings.idleHours");
expect(legacyMessages).toContain("channels.<id>.threadBindings.ttlHours");
expect(legacyMessages).toContain("channels.<id>.threadBindings.idleHours");
expect(legacyMessages).toContain("talk:");
expect(legacyMessages).toContain(
"talk.voiceId/talk.voiceAliases/talk.modelId/talk.outputFormat/talk.apiKey",
);
expect(legacyMessages).toContain("agents.defaults.sandbox:");
expect(legacyMessages).toContain("agents.defaults.sandbox.perSession is legacy");
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Doctor" &&
message.includes('Run "openclaw doctor --fix" to migrate legacy config keys.'),
),
).toBe(true);
} finally {
noteSpy.mockClear();
}
});
it("recovers from stale googlechat top-level allowFrom by repairing dm.allowFrom", async () => {
const result = await runDoctorConfigWithInput({
repair: true,
config: {
channels: {
googlechat: {
allowFrom: ["*"],
dm: {
policy: "open",
},
},
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
channels: {
googlechat: {
dm: { allowFrom: string[] };
allowFrom?: string[];
};
};
};
expect(cfg.channels.googlechat.dm.allowFrom).toEqual(["*"]);
expect(cfg.channels.googlechat.allowFrom).toEqual(["*"]);
});
it("does not report repeat talk provider normalization on consecutive repair runs", async () => {
await withTempHome(
async (home) => {
const providerId = "acme-speech";
const configDir = path.join(home, ".openclaw");
await fs.mkdir(configDir, { recursive: true });
await fs.writeFile(
path.join(configDir, "openclaw.json"),
JSON.stringify(
{
talk: {
interruptOnSpeech: true,
silenceTimeoutMs: 1500,
provider: providerId,
providers: {
[providerId]: {
apiKey: "secret-key",
voiceId: "voice-123",
modelId: "eleven_v3",
},
},
},
},
null,
2,
),
"utf-8",
);
const noteSpy = resetTerminalNoteMock();
try {
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
noteSpy.mockClear();
await loadAndMaybeMigrateDoctorConfig({
options: { nonInteractive: true, repair: true },
confirm: async () => false,
});
const secondRunTalkNormalizationLines = noteSpy.mock.calls
.filter((call) => call[1] === "Doctor changes")
.map((call) => call[0])
.filter((line) => line.includes("Normalized talk.provider/providers shape"));
expect(secondRunTalkNormalizationLines).toEqual([]);
} finally {
noteSpy.mockClear();
}
},
{ skipSessionCleanup: true },
);
});
});
¤ Dauer der Verarbeitung: 0.72 Sekunden
(vorverarbeitet am 2026-04-27)
¤
*© Formatika GbR, Deutschland
|
|