fix(doctor): clarify legacy config migration guidance (#60326)

This commit is contained in:
Vincent Koc
2026-04-03 23:16:11 +09:00
committed by GitHub
parent a9a057d1eb
commit 52008e2e60
6 changed files with 69 additions and 49 deletions

View File

@@ -35,28 +35,6 @@ async function collectDoctorWarnings(config: Record<string, unknown>): Promise<s
}
}
type DoctorFlowDeps = {
noteModule: typeof import("../terminal/note.js");
loadAndMaybeMigrateDoctorConfig: typeof import("./doctor-config-flow.js").loadAndMaybeMigrateDoctorConfig;
};
let cachedDoctorFlowDeps: Promise<DoctorFlowDeps> | undefined;
async function loadFreshDoctorFlowDeps(): Promise<DoctorFlowDeps> {
if (!cachedDoctorFlowDeps) {
vi.resetModules();
cachedDoctorFlowDeps = (async () => {
const freshNoteModule = await import("../terminal/note.js");
const doctorFlowModule = await import("./doctor-config-flow.js");
return {
noteModule: freshNoteModule,
loadAndMaybeMigrateDoctorConfig: doctorFlowModule.loadAndMaybeMigrateDoctorConfig,
};
})();
}
return await cachedDoctorFlowDeps;
}
type DiscordGuildRule = {
users: string[];
roles: string[];
@@ -675,9 +653,7 @@ describe("doctor config flow", () => {
});
it("sanitizes config-derived doctor warnings and changes before logging", async () => {
const { noteModule: freshNoteModule, loadAndMaybeMigrateDoctorConfig: loadDoctorFlowFresh } =
await loadFreshDoctorFlowDeps();
const noteSpy = vi.spyOn(freshNoteModule, "note").mockImplementation(() => {});
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await runDoctorConfigWithInput({
repair: true,
@@ -710,7 +686,7 @@ describe("doctor config flow", () => {
},
},
},
run: loadDoctorFlowFresh,
run: loadAndMaybeMigrateDoctorConfig,
});
const outputs = noteSpy.mock.calls
@@ -745,9 +721,7 @@ describe("doctor config flow", () => {
});
it("warns and continues when Telegram account inspection hits inactive SecretRef surfaces", async () => {
const { noteModule: freshNoteModule, loadAndMaybeMigrateDoctorConfig: loadDoctorFlowFresh } =
await loadFreshDoctorFlowDeps();
const noteSpy = vi.spyOn(freshNoteModule, "note").mockImplementation(() => {});
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
const fetchSpy = vi.fn();
vi.stubGlobal("fetch", fetchSpy);
try {
@@ -771,7 +745,7 @@ describe("doctor config flow", () => {
},
},
},
run: loadDoctorFlowFresh,
run: loadAndMaybeMigrateDoctorConfig,
});
const cfg = result.cfg as {
@@ -791,7 +765,7 @@ describe("doctor config flow", () => {
expect(
noteSpy.mock.calls.some((call) =>
String(call[0]).includes(
"Telegram allowFrom contains @username entries, but no Telegram bot token is configured",
"Telegram allowFrom contains @username entries, but configured Telegram bot credentials are unavailable in this command path",
),
),
).toBe(true);
@@ -1180,6 +1154,39 @@ describe("doctor config flow", () => {
});
});
it("warns clearly about legacy config keys and points to doctor --fix", async () => {
const noteSpy = vi.spyOn(noteModule, "note").mockImplementation(() => {});
try {
await runDoctorConfigWithInput({
config: {
heartbeat: {
model: "anthropic/claude-3-5-haiku-20241022",
every: "30m",
},
},
run: loadAndMaybeMigrateDoctorConfig,
});
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Legacy config keys detected" &&
String(message).includes("heartbeat:") &&
String(message).includes("agents.defaults.heartbeat"),
),
).toBe(true);
expect(
noteSpy.mock.calls.some(
([message, title]) =>
title === "Doctor" &&
String(message).includes('Run "openclaw doctor --fix" to migrate legacy config keys.'),
),
).toBe(true);
} finally {
noteSpy.mockRestore();
}
});
it("migrates top-level heartbeat visibility into channels.defaults.heartbeat on repair", async () => {
const result = await runDoctorConfigWithInput({
repair: true,

View File

@@ -51,7 +51,7 @@ export async function loadAndMaybeMigrateDoctorConfig(params: {
});
({ cfg, candidate, pendingChanges, fixHints } = legacyStep.state);
if (legacyStep.issueLines.length > 0) {
note(legacyStep.issueLines.join("\n"), "Compatibility config keys detected");
note(legacyStep.issueLines.join("\n"), "Legacy config keys detected");
}
if (legacyStep.changeLines.length > 0) {
note(legacyStep.changeLines.join("\n"), "Doctor changes");

View File

@@ -27,16 +27,19 @@ vi.mock("../../../channels/plugins/registry.js", () => ({
getChannelPlugin: getChannelPluginMock,
}));
import {
collectTelegramAllowFromUsernameWarnings,
collectTelegramEmptyAllowlistExtraWarnings,
collectTelegramGroupPolicyWarnings,
maybeRepairTelegramAllowFromUsernames,
scanTelegramAllowFromUsernameEntries,
} from "./telegram.js";
type TelegramDoctorModule = typeof import("./telegram.js");
let telegramDoctorModule: Promise<TelegramDoctorModule> | undefined;
async function loadTelegramDoctorModule(): Promise<TelegramDoctorModule> {
telegramDoctorModule ??= import("./telegram.js");
return await telegramDoctorModule;
}
describe("doctor telegram provider warnings", () => {
beforeEach(() => {
vi.resetModules();
telegramDoctorModule = undefined;
resolveCommandSecretRefsViaGatewayMock.mockReset().mockImplementation(async ({ config }) => ({
resolvedConfig: config,
diagnostics: [],
@@ -64,7 +67,8 @@ describe("doctor telegram provider warnings", () => {
});
});
it("shows first-run guidance when groups are not configured yet", () => {
it("shows first-run guidance when groups are not configured yet", async () => {
const { collectTelegramGroupPolicyWarnings } = await loadTelegramDoctorModule();
const warnings = collectTelegramGroupPolicyWarnings({
account: {
botToken: "123:abc",
@@ -81,7 +85,8 @@ describe("doctor telegram provider warnings", () => {
expect(warnings[0]).toContain("channels.telegram.groups");
});
it("warns when configured groups still have no usable sender allowlist", () => {
it("warns when configured groups still have no usable sender allowlist", async () => {
const { collectTelegramGroupPolicyWarnings } = await loadTelegramDoctorModule();
const warnings = collectTelegramGroupPolicyWarnings({
account: {
botToken: "123:abc",
@@ -100,7 +105,8 @@ describe("doctor telegram provider warnings", () => {
]);
});
it("stays quiet when allowFrom can satisfy group allowlist mode", () => {
it("stays quiet when allowFrom can satisfy group allowlist mode", async () => {
const { collectTelegramGroupPolicyWarnings } = await loadTelegramDoctorModule();
const warnings = collectTelegramGroupPolicyWarnings({
account: {
botToken: "123:abc",
@@ -116,7 +122,8 @@ describe("doctor telegram provider warnings", () => {
expect(warnings).toEqual([]);
});
it("returns extra empty-allowlist warnings only for telegram allowlist groups", () => {
it("returns extra empty-allowlist warnings only for telegram allowlist groups", async () => {
const { collectTelegramEmptyAllowlistExtraWarnings } = await loadTelegramDoctorModule();
const warnings = collectTelegramEmptyAllowlistExtraWarnings({
account: {
botToken: "123:abc",
@@ -143,7 +150,8 @@ describe("doctor telegram provider warnings", () => {
).toEqual([]);
});
it("finds non-numeric telegram allowFrom username entries across account scopes", () => {
it("finds non-numeric telegram allowFrom username entries across account scopes", async () => {
const { scanTelegramAllowFromUsernameEntries } = await loadTelegramDoctorModule();
const hits = scanTelegramAllowFromUsernameEntries({
channels: {
telegram: {
@@ -179,7 +187,8 @@ describe("doctor telegram provider warnings", () => {
]);
});
it("formats allowFrom username warnings", () => {
it("formats allowFrom username warnings", async () => {
const { collectTelegramAllowFromUsernameWarnings } = await loadTelegramDoctorModule();
const warnings = collectTelegramAllowFromUsernameWarnings({
hits: [{ path: "channels.telegram.allowFrom", entry: "@top" }],
doctorFixCommand: "openclaw doctor --fix",
@@ -192,6 +201,7 @@ describe("doctor telegram provider warnings", () => {
});
it("repairs Telegram @username allowFrom entries to numeric ids", async () => {
const { maybeRepairTelegramAllowFromUsernames } = await loadTelegramDoctorModule();
telegramResolverMock.mockImplementation(async ({ inputs }: { inputs: string[] }) => {
switch (inputs[0]?.toLowerCase()) {
case "@testuser":
@@ -247,6 +257,7 @@ describe("doctor telegram provider warnings", () => {
});
it("sanitizes Telegram allowFrom repair change lines before logging", async () => {
const { maybeRepairTelegramAllowFromUsernames } = await loadTelegramDoctorModule();
telegramResolverMock.mockImplementation(async ({ inputs }: { inputs: string[] }) => {
if (inputs[0] === "@\u001b[31mtestuser") {
return [{ input: inputs[0], resolved: true, id: "12345" }];
@@ -273,6 +284,7 @@ describe("doctor telegram provider warnings", () => {
});
it("keeps Telegram allowFrom entries unchanged when configured credentials are unavailable", async () => {
const { maybeRepairTelegramAllowFromUsernames } = await loadTelegramDoctorModule();
inspectTelegramAccountMock.mockImplementation(() => ({
enabled: true,
token: "",
@@ -311,6 +323,7 @@ describe("doctor telegram provider warnings", () => {
});
it("uses network settings for Telegram allowFrom repair but ignores apiRoot and proxy", async () => {
const { maybeRepairTelegramAllowFromUsernames } = await loadTelegramDoctorModule();
resolveCommandSecretRefsViaGatewayMock.mockResolvedValue({
resolvedConfig: {
channels: {

View File

@@ -152,7 +152,7 @@ export async function maybeRepairTelegramAllowFromUsernames(cfg: OpenClawConfig)
});
const hasConfiguredUnavailableToken = listTelegramAccountIds(cfg).some((accountId) => {
const inspected = inspectTelegramAccount({ cfg, accountId });
return inspected.enabled && inspected.tokenStatus === "configured_unavailable";
return inspected.tokenStatus === "configured_unavailable";
});
const tokenResolutionWarnings: string[] = [];
const resolverAccountIds: string[] = [];

View File

@@ -33,7 +33,7 @@ describe("doctor config flow steps", () => {
expect(result.issueLines).toEqual([expect.stringContaining("- heartbeat:")]);
expect(result.changeLines).not.toEqual([]);
expect(result.state.fixHints).toContain(
'Run "openclaw doctor --fix" to apply compatibility migrations.',
'Run "openclaw doctor --fix" to migrate legacy config keys.',
);
});

View File

@@ -32,7 +32,7 @@ export function applyLegacyCompatibilityStep(params: {
? params.state.fixHints
: [
...params.state.fixHints,
`Run "${params.doctorFixCommand}" to apply compatibility migrations.`,
`Run "${params.doctorFixCommand}" to migrate legacy config keys.`,
],
},
issueLines,
@@ -49,7 +49,7 @@ export function applyLegacyCompatibilityStep(params: {
? params.state.fixHints
: [
...params.state.fixHints,
`Run "${params.doctorFixCommand}" to apply compatibility migrations.`,
`Run "${params.doctorFixCommand}" to migrate legacy config keys.`,
],
},
issueLines,