fix(cli): repair legacy config before update channel switch (#77069)

* fix(cli): repair legacy config before update channel switch

* docs(changelog): note update channel legacy config repair

* fix(update): keep legacy config repair doctor-owned

* fix(update): keep dry runs read-only

* fix(update): avoid include-flattening legacy repair
This commit is contained in:
Vincent Koc
2026-05-05 17:54:53 -07:00
committed by GitHub
parent d12c4d832d
commit fcf0561da0
4 changed files with 265 additions and 5 deletions

View File

@@ -433,6 +433,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Update: repair doctor-migratable legacy config before persisting `openclaw update --channel ...`, so old Slack/Telegram streaming keys do not block switching to beta after a package update. Thanks @vincentkoc.
- Web fetch: late-bind `web_fetch` config and provider fallback metadata from the active runtime snapshot, matching `web_search` so long-lived tools do not use stale fetch provider settings. Thanks @vincentkoc.
- Plugins/discovery: demote the source-only TypeScript runtime check on already-installed `origin: "global"` plugin packages from a config-blocking error to a warning and let the runtime fall through to the TypeScript source via jiti, so a single broken installed package no longer blocks `plugins install` for unrelated plugins; install-time rejection of newly-installed source-only packages is unchanged. Thanks @romneyda.
- Providers/OpenAI Codex: stop the OAuth progress spinner before showing the manual redirect paste prompt, so callback timeouts do not spam `Browser callback did not finish` across terminals.

View File

@@ -2330,6 +2330,193 @@ describe("update-cli", () => {
);
});
it("repairs legacy config before persisting a requested update channel", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
const legacyConfig = {
channels: {
slack: {
streaming: "partial",
nativeStreaming: false,
},
telegram: {
streaming: "block",
},
},
} as OpenClawConfig;
const migratedConfig = {
channels: {
slack: {
streaming: {
mode: "partial",
nativeTransport: false,
},
},
telegram: {
streaming: {
mode: "block",
},
},
},
} as OpenClawConfig;
vi.mocked(readConfigFileSnapshot)
.mockResolvedValueOnce({
...baseSnapshot,
parsed: legacyConfig,
resolved: legacyConfig,
sourceConfig: legacyConfig,
config: legacyConfig,
runtimeConfig: legacyConfig,
valid: false,
hash: "legacy-hash",
issues: [
{
path: "channels.slack.streaming",
message: "Invalid input: expected object, received string",
},
],
legacyIssues: [
{
path: "channels.slack",
message: "legacy slack streaming keys",
},
{
path: "channels.telegram",
message: "legacy telegram streaming keys",
},
],
})
.mockResolvedValueOnce({
...baseSnapshot,
parsed: migratedConfig,
resolved: migratedConfig,
sourceConfig: migratedConfig,
config: migratedConfig,
runtimeConfig: migratedConfig,
valid: true,
hash: "migrated-hash",
});
await updateCommand({ channel: "beta", yes: true });
expect(replaceConfigFile).toHaveBeenCalledTimes(2);
expect(replaceConfigFile).toHaveBeenNthCalledWith(1, {
nextConfig: expect.objectContaining({
channels: expect.objectContaining({
slack: expect.objectContaining({
streaming: expect.objectContaining({
mode: "partial",
nativeTransport: false,
}),
}),
telegram: expect.objectContaining({
streaming: expect.objectContaining({
mode: "block",
}),
}),
}),
}),
baseHash: "legacy-hash",
writeOptions: {
allowConfigSizeDrop: true,
skipOutputLogs: false,
},
});
expect(replaceConfigFile).toHaveBeenNthCalledWith(2, {
nextConfig: {
...migratedConfig,
update: {
channel: "beta",
},
},
baseHash: "migrated-hash",
});
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
});
it("does not auto-repair legacy config when authored includes are present", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
const legacyConfigWithInclude = {
$include: "./channels.json5",
channels: {
slack: {
streaming: "partial",
nativeStreaming: false,
},
},
} as unknown as OpenClawConfig;
vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({
...baseSnapshot,
parsed: legacyConfigWithInclude,
resolved: legacyConfigWithInclude,
sourceConfig: legacyConfigWithInclude,
config: legacyConfigWithInclude,
runtimeConfig: legacyConfigWithInclude,
valid: false,
hash: "legacy-include-hash",
issues: [
{
path: "channels.slack.streaming",
message: "Invalid input: expected object, received string",
},
],
legacyIssues: [
{
path: "channels.slack",
message: "legacy slack streaming keys",
},
],
});
await updateCommand({ channel: "beta", yes: true });
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(runCommandWithTimeout).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("does not repair legacy config during a dry run", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);
const legacyConfig = {
channels: {
slack: {
streaming: "partial",
nativeStreaming: false,
},
},
} as OpenClawConfig;
vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({
...baseSnapshot,
parsed: legacyConfig,
resolved: legacyConfig,
sourceConfig: legacyConfig,
config: legacyConfig,
runtimeConfig: legacyConfig,
valid: false,
hash: "legacy-hash",
issues: [
{
path: "channels.slack.streaming",
message: "Invalid input: expected object, received string",
},
],
legacyIssues: [
{
path: "channels.slack",
message: "legacy slack streaming keys",
},
],
});
await updateCommand({ dryRun: true, channel: "beta", yes: true });
expect(replaceConfigFile).not.toHaveBeenCalled();
expect(runCommandWithTimeout).not.toHaveBeenCalled();
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
});
it("does not persist the requested channel when the package update fails", async () => {
const tempDir = createCaseDir("openclaw-update");
mockPackageInstallStatus(tempDir);

View File

@@ -1686,6 +1686,23 @@ function createUpdatedChannelSnapshot(
};
}
async function maybeRepairLegacyConfigForUpdateChannel(params: {
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
jsonMode: boolean;
}): Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> {
if (params.configSnapshot.valid || params.configSnapshot.legacyIssues.length === 0) {
return params.configSnapshot;
}
const { repairLegacyConfigForUpdateChannel } =
await import("../../commands/doctor/legacy-config-repair.js");
const { snapshot, repaired } = await repairLegacyConfigForUpdateChannel(params);
if (!params.jsonMode && repaired) {
defaultRuntime.log(theme.muted("Migrated legacy config before changing update channel."));
}
return snapshot;
}
async function writePostCorePluginUpdateResultFile(
filePath: string | undefined,
result: PostCorePluginUpdateResult,
@@ -1947,17 +1964,24 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
includeRegistry: false,
});
const configSnapshot = await readConfigFileSnapshot();
const storedChannel = configSnapshot.valid
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
: null;
const requestedChannel = normalizeUpdateChannel(opts.channel);
if (opts.channel && !requestedChannel) {
defaultRuntime.error(`--channel must be "stable", "beta", or "dev" (got "${opts.channel}")`);
defaultRuntime.exit(1);
return;
}
let configSnapshot = await readConfigFileSnapshot();
if (opts.channel && !opts.dryRun && !configSnapshot.valid) {
configSnapshot = await maybeRepairLegacyConfigForUpdateChannel({
configSnapshot,
jsonMode: Boolean(opts.json),
});
}
const storedChannel = configSnapshot.valid
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
: null;
if (opts.channel && !configSnapshot.valid) {
const issues = formatConfigIssueLines(configSnapshot.issues, "-");
defaultRuntime.error(["Config is invalid; cannot set update channel.", ...issues].join("\n"));

View File

@@ -0,0 +1,48 @@
import { readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js";
import { INCLUDE_KEY } from "../../config/includes.js";
import { validateConfigObjectWithPlugins } from "../../config/validation.js";
import { isRecord } from "../../utils.js";
import { migrateLegacyConfig } from "./shared/legacy-config-migrate.js";
type ConfigSnapshot = Awaited<ReturnType<typeof readConfigFileSnapshot>>;
function containsAuthoredInclude(value: unknown): boolean {
if (!isRecord(value)) {
return false;
}
if (Object.prototype.hasOwnProperty.call(value, INCLUDE_KEY)) {
return true;
}
return Object.values(value).some((entry) => containsAuthoredInclude(entry));
}
export async function repairLegacyConfigForUpdateChannel(params: {
configSnapshot: ConfigSnapshot;
jsonMode: boolean;
}): Promise<{ snapshot: ConfigSnapshot; repaired: boolean }> {
if (containsAuthoredInclude(params.configSnapshot.parsed)) {
return { snapshot: params.configSnapshot, repaired: false };
}
const migrated = migrateLegacyConfig(params.configSnapshot.parsed);
if (!migrated.config) {
return { snapshot: params.configSnapshot, repaired: false };
}
const validated = validateConfigObjectWithPlugins(migrated.config);
if (!validated.ok) {
return { snapshot: params.configSnapshot, repaired: false };
}
await replaceConfigFile({
nextConfig: validated.config,
baseHash: params.configSnapshot.hash,
writeOptions: {
allowConfigSizeDrop: true,
skipOutputLogs: params.jsonMode,
},
});
const snapshot = await readConfigFileSnapshot();
return { snapshot, repaired: snapshot.valid };
}