diff --git a/CHANGELOG.md b/CHANGELOG.md index d9d413029a5..3e969c71000 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 6e1fae3a5bb..266ef0cfdc9 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -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); diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 535b00d5f6d..0e298a59b34 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -1686,6 +1686,23 @@ function createUpdatedChannelSnapshot( }; } +async function maybeRepairLegacyConfigForUpdateChannel(params: { + configSnapshot: Awaited>; + jsonMode: boolean; +}): Promise>> { + 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 { 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")); diff --git a/src/commands/doctor/legacy-config-repair.ts b/src/commands/doctor/legacy-config-repair.ts new file mode 100644 index 00000000000..3f92ccef911 --- /dev/null +++ b/src/commands/doctor/legacy-config-repair.ts @@ -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>; + +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 }; +}