Skip to content

Commit fcf0561

Browse files
authored
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
1 parent d12c4d8 commit fcf0561

4 files changed

Lines changed: 265 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -433,6 +433,7 @@ Docs: https://docs.openclaw.ai
433433

434434
### Fixes
435435

436+
- 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.
436437
- 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.
437438
- 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.
438439
- 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.

src/cli/update-cli.test.ts

Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2330,6 +2330,193 @@ describe("update-cli", () => {
23302330
);
23312331
});
23322332

2333+
it("repairs legacy config before persisting a requested update channel", async () => {
2334+
const tempDir = createCaseDir("openclaw-update");
2335+
mockPackageInstallStatus(tempDir);
2336+
const legacyConfig = {
2337+
channels: {
2338+
slack: {
2339+
streaming: "partial",
2340+
nativeStreaming: false,
2341+
},
2342+
telegram: {
2343+
streaming: "block",
2344+
},
2345+
},
2346+
} as OpenClawConfig;
2347+
const migratedConfig = {
2348+
channels: {
2349+
slack: {
2350+
streaming: {
2351+
mode: "partial",
2352+
nativeTransport: false,
2353+
},
2354+
},
2355+
telegram: {
2356+
streaming: {
2357+
mode: "block",
2358+
},
2359+
},
2360+
},
2361+
} as OpenClawConfig;
2362+
vi.mocked(readConfigFileSnapshot)
2363+
.mockResolvedValueOnce({
2364+
...baseSnapshot,
2365+
parsed: legacyConfig,
2366+
resolved: legacyConfig,
2367+
sourceConfig: legacyConfig,
2368+
config: legacyConfig,
2369+
runtimeConfig: legacyConfig,
2370+
valid: false,
2371+
hash: "legacy-hash",
2372+
issues: [
2373+
{
2374+
path: "channels.slack.streaming",
2375+
message: "Invalid input: expected object, received string",
2376+
},
2377+
],
2378+
legacyIssues: [
2379+
{
2380+
path: "channels.slack",
2381+
message: "legacy slack streaming keys",
2382+
},
2383+
{
2384+
path: "channels.telegram",
2385+
message: "legacy telegram streaming keys",
2386+
},
2387+
],
2388+
})
2389+
.mockResolvedValueOnce({
2390+
...baseSnapshot,
2391+
parsed: migratedConfig,
2392+
resolved: migratedConfig,
2393+
sourceConfig: migratedConfig,
2394+
config: migratedConfig,
2395+
runtimeConfig: migratedConfig,
2396+
valid: true,
2397+
hash: "migrated-hash",
2398+
});
2399+
2400+
await updateCommand({ channel: "beta", yes: true });
2401+
2402+
expect(replaceConfigFile).toHaveBeenCalledTimes(2);
2403+
expect(replaceConfigFile).toHaveBeenNthCalledWith(1, {
2404+
nextConfig: expect.objectContaining({
2405+
channels: expect.objectContaining({
2406+
slack: expect.objectContaining({
2407+
streaming: expect.objectContaining({
2408+
mode: "partial",
2409+
nativeTransport: false,
2410+
}),
2411+
}),
2412+
telegram: expect.objectContaining({
2413+
streaming: expect.objectContaining({
2414+
mode: "block",
2415+
}),
2416+
}),
2417+
}),
2418+
}),
2419+
baseHash: "legacy-hash",
2420+
writeOptions: {
2421+
allowConfigSizeDrop: true,
2422+
skipOutputLogs: false,
2423+
},
2424+
});
2425+
expect(replaceConfigFile).toHaveBeenNthCalledWith(2, {
2426+
nextConfig: {
2427+
...migratedConfig,
2428+
update: {
2429+
channel: "beta",
2430+
},
2431+
},
2432+
baseHash: "migrated-hash",
2433+
});
2434+
expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1);
2435+
});
2436+
2437+
it("does not auto-repair legacy config when authored includes are present", async () => {
2438+
const tempDir = createCaseDir("openclaw-update");
2439+
mockPackageInstallStatus(tempDir);
2440+
const legacyConfigWithInclude = {
2441+
$include: "./channels.json5",
2442+
channels: {
2443+
slack: {
2444+
streaming: "partial",
2445+
nativeStreaming: false,
2446+
},
2447+
},
2448+
} as unknown as OpenClawConfig;
2449+
vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({
2450+
...baseSnapshot,
2451+
parsed: legacyConfigWithInclude,
2452+
resolved: legacyConfigWithInclude,
2453+
sourceConfig: legacyConfigWithInclude,
2454+
config: legacyConfigWithInclude,
2455+
runtimeConfig: legacyConfigWithInclude,
2456+
valid: false,
2457+
hash: "legacy-include-hash",
2458+
issues: [
2459+
{
2460+
path: "channels.slack.streaming",
2461+
message: "Invalid input: expected object, received string",
2462+
},
2463+
],
2464+
legacyIssues: [
2465+
{
2466+
path: "channels.slack",
2467+
message: "legacy slack streaming keys",
2468+
},
2469+
],
2470+
});
2471+
2472+
await updateCommand({ channel: "beta", yes: true });
2473+
2474+
expect(replaceConfigFile).not.toHaveBeenCalled();
2475+
expect(runCommandWithTimeout).not.toHaveBeenCalled();
2476+
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
2477+
});
2478+
2479+
it("does not repair legacy config during a dry run", async () => {
2480+
const tempDir = createCaseDir("openclaw-update");
2481+
mockPackageInstallStatus(tempDir);
2482+
const legacyConfig = {
2483+
channels: {
2484+
slack: {
2485+
streaming: "partial",
2486+
nativeStreaming: false,
2487+
},
2488+
},
2489+
} as OpenClawConfig;
2490+
vi.mocked(readConfigFileSnapshot).mockResolvedValueOnce({
2491+
...baseSnapshot,
2492+
parsed: legacyConfig,
2493+
resolved: legacyConfig,
2494+
sourceConfig: legacyConfig,
2495+
config: legacyConfig,
2496+
runtimeConfig: legacyConfig,
2497+
valid: false,
2498+
hash: "legacy-hash",
2499+
issues: [
2500+
{
2501+
path: "channels.slack.streaming",
2502+
message: "Invalid input: expected object, received string",
2503+
},
2504+
],
2505+
legacyIssues: [
2506+
{
2507+
path: "channels.slack",
2508+
message: "legacy slack streaming keys",
2509+
},
2510+
],
2511+
});
2512+
2513+
await updateCommand({ dryRun: true, channel: "beta", yes: true });
2514+
2515+
expect(replaceConfigFile).not.toHaveBeenCalled();
2516+
expect(runCommandWithTimeout).not.toHaveBeenCalled();
2517+
expect(defaultRuntime.exit).toHaveBeenCalledWith(1);
2518+
});
2519+
23332520
it("does not persist the requested channel when the package update fails", async () => {
23342521
const tempDir = createCaseDir("openclaw-update");
23352522
mockPackageInstallStatus(tempDir);

src/cli/update-cli/update-command.ts

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,6 +1686,23 @@ function createUpdatedChannelSnapshot(
16861686
};
16871687
}
16881688

1689+
async function maybeRepairLegacyConfigForUpdateChannel(params: {
1690+
configSnapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>;
1691+
jsonMode: boolean;
1692+
}): Promise<Awaited<ReturnType<typeof readConfigFileSnapshot>>> {
1693+
if (params.configSnapshot.valid || params.configSnapshot.legacyIssues.length === 0) {
1694+
return params.configSnapshot;
1695+
}
1696+
1697+
const { repairLegacyConfigForUpdateChannel } =
1698+
await import("../../commands/doctor/legacy-config-repair.js");
1699+
const { snapshot, repaired } = await repairLegacyConfigForUpdateChannel(params);
1700+
if (!params.jsonMode && repaired) {
1701+
defaultRuntime.log(theme.muted("Migrated legacy config before changing update channel."));
1702+
}
1703+
return snapshot;
1704+
}
1705+
16891706
async function writePostCorePluginUpdateResultFile(
16901707
filePath: string | undefined,
16911708
result: PostCorePluginUpdateResult,
@@ -1947,17 +1964,24 @@ export async function updateCommand(opts: UpdateCommandOptions): Promise<void> {
19471964
includeRegistry: false,
19481965
});
19491966

1950-
const configSnapshot = await readConfigFileSnapshot();
1951-
const storedChannel = configSnapshot.valid
1952-
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
1953-
: null;
1954-
19551967
const requestedChannel = normalizeUpdateChannel(opts.channel);
19561968
if (opts.channel && !requestedChannel) {
19571969
defaultRuntime.error(`--channel must be "stable", "beta", or "dev" (got "${opts.channel}")`);
19581970
defaultRuntime.exit(1);
19591971
return;
19601972
}
1973+
1974+
let configSnapshot = await readConfigFileSnapshot();
1975+
if (opts.channel && !opts.dryRun && !configSnapshot.valid) {
1976+
configSnapshot = await maybeRepairLegacyConfigForUpdateChannel({
1977+
configSnapshot,
1978+
jsonMode: Boolean(opts.json),
1979+
});
1980+
}
1981+
const storedChannel = configSnapshot.valid
1982+
? normalizeUpdateChannel(configSnapshot.config.update?.channel)
1983+
: null;
1984+
19611985
if (opts.channel && !configSnapshot.valid) {
19621986
const issues = formatConfigIssueLines(configSnapshot.issues, "-");
19631987
defaultRuntime.error(["Config is invalid; cannot set update channel.", ...issues].join("\n"));
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { readConfigFileSnapshot, replaceConfigFile } from "../../config/config.js";
2+
import { INCLUDE_KEY } from "../../config/includes.js";
3+
import { validateConfigObjectWithPlugins } from "../../config/validation.js";
4+
import { isRecord } from "../../utils.js";
5+
import { migrateLegacyConfig } from "./shared/legacy-config-migrate.js";
6+
7+
type ConfigSnapshot = Awaited<ReturnType<typeof readConfigFileSnapshot>>;
8+
9+
function containsAuthoredInclude(value: unknown): boolean {
10+
if (!isRecord(value)) {
11+
return false;
12+
}
13+
if (Object.prototype.hasOwnProperty.call(value, INCLUDE_KEY)) {
14+
return true;
15+
}
16+
return Object.values(value).some((entry) => containsAuthoredInclude(entry));
17+
}
18+
19+
export async function repairLegacyConfigForUpdateChannel(params: {
20+
configSnapshot: ConfigSnapshot;
21+
jsonMode: boolean;
22+
}): Promise<{ snapshot: ConfigSnapshot; repaired: boolean }> {
23+
if (containsAuthoredInclude(params.configSnapshot.parsed)) {
24+
return { snapshot: params.configSnapshot, repaired: false };
25+
}
26+
27+
const migrated = migrateLegacyConfig(params.configSnapshot.parsed);
28+
if (!migrated.config) {
29+
return { snapshot: params.configSnapshot, repaired: false };
30+
}
31+
32+
const validated = validateConfigObjectWithPlugins(migrated.config);
33+
if (!validated.ok) {
34+
return { snapshot: params.configSnapshot, repaired: false };
35+
}
36+
37+
await replaceConfigFile({
38+
nextConfig: validated.config,
39+
baseHash: params.configSnapshot.hash,
40+
writeOptions: {
41+
allowConfigSizeDrop: true,
42+
skipOutputLogs: params.jsonMode,
43+
},
44+
});
45+
46+
const snapshot = await readConfigFileSnapshot();
47+
return { snapshot, repaired: snapshot.valid };
48+
}

0 commit comments

Comments
 (0)