mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:20:43 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
|
||||
48
src/commands/doctor/legacy-config-repair.ts
Normal file
48
src/commands/doctor/legacy-config-repair.ts
Normal 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 };
|
||||
}
|
||||
Reference in New Issue
Block a user