Files
openclaw/src/config/legacy.migrations.part-3.ts
wan.xi caa748b969 fix(config): detect top-level heartbeat as invalid config path (#30894) (#32706)
Merged via squash.

Prepared head SHA: 1714ffe6fc
Co-authored-by: xiwan <931632+xiwan@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
2026-03-03 20:27:04 -05:00

377 lines
13 KiB
TypeScript

import {
buildDefaultControlUiAllowedOrigins,
hasConfiguredControlUiAllowedOrigins,
isGatewayNonLoopbackBindMode,
resolveGatewayPortWithDefault,
} from "./gateway-control-ui-origins.js";
import {
ensureAgentEntry,
ensureRecord,
getAgentsList,
getRecord,
isRecord,
type LegacyConfigMigration,
mergeMissing,
resolveDefaultAgentIdFromRaw,
} from "./legacy.shared.js";
import { DEFAULT_GATEWAY_PORT } from "./paths.js";
const AGENT_HEARTBEAT_KEYS = new Set([
"every",
"activeHours",
"model",
"session",
"includeReasoning",
"target",
"directPolicy",
"to",
"accountId",
"prompt",
"ackMaxChars",
"suppressToolErrorWarnings",
"lightContext",
]);
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);
function splitLegacyHeartbeat(legacyHeartbeat: Record<string, unknown>): {
agentHeartbeat: Record<string, unknown> | null;
channelHeartbeat: Record<string, unknown> | null;
} {
const agentHeartbeat: Record<string, unknown> = {};
const channelHeartbeat: Record<string, unknown> = {};
for (const [key, value] of Object.entries(legacyHeartbeat)) {
if (CHANNEL_HEARTBEAT_KEYS.has(key)) {
channelHeartbeat[key] = value;
continue;
}
if (AGENT_HEARTBEAT_KEYS.has(key)) {
agentHeartbeat[key] = value;
continue;
}
// Preserve unknown fields under the agent heartbeat namespace so validation
// still surfaces unsupported keys instead of silently dropping user input.
agentHeartbeat[key] = value;
}
return {
agentHeartbeat: Object.keys(agentHeartbeat).length > 0 ? agentHeartbeat : null,
channelHeartbeat: Object.keys(channelHeartbeat).length > 0 ? channelHeartbeat : null,
};
}
// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed.
// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
{
// v2026.2.26 added a startup guard requiring gateway.controlUi.allowedOrigins (or the
// host-header fallback flag) for any non-loopback bind. The onboarding wizard was updated
// to seed this for new installs, but existing bind=lan/bind=custom installs that upgrade
// crash-loop immediately on next startup with no recovery path (issue #29385).
//
// This migration runs on every gateway start via migrateLegacyConfig → applyLegacyMigrations
// and writes the seeded origins to disk before the startup guard fires, preventing the loop.
id: "gateway.controlUi.allowedOrigins-seed-for-non-loopback",
describe: "Seed gateway.controlUi.allowedOrigins for existing non-loopback gateway installs",
apply: (raw, changes) => {
const gateway = getRecord(raw.gateway);
if (!gateway) {
return;
}
const bind = gateway.bind;
if (!isGatewayNonLoopbackBindMode(bind)) {
return;
}
const controlUi = getRecord(gateway.controlUi) ?? {};
if (
hasConfiguredControlUiAllowedOrigins({
allowedOrigins: controlUi.allowedOrigins,
dangerouslyAllowHostHeaderOriginFallback:
controlUi.dangerouslyAllowHostHeaderOriginFallback,
})
) {
return;
}
const port = resolveGatewayPortWithDefault(gateway.port, DEFAULT_GATEWAY_PORT);
const origins = buildDefaultControlUiAllowedOrigins({
port,
bind,
customBindHost:
typeof gateway.customBindHost === "string" ? gateway.customBindHost : undefined,
});
gateway.controlUi = { ...controlUi, allowedOrigins: origins };
raw.gateway = gateway;
changes.push(
`Seeded gateway.controlUi.allowedOrigins ${JSON.stringify(origins)} for bind=${String(bind)}. ` +
"Required since v2026.2.26. Add other machine origins to gateway.controlUi.allowedOrigins if needed.",
);
},
},
{
id: "memorySearch->agents.defaults.memorySearch",
describe: "Move top-level memorySearch to agents.defaults.memorySearch",
apply: (raw, changes) => {
const legacyMemorySearch = getRecord(raw.memorySearch);
if (!legacyMemorySearch) {
return;
}
const agents = ensureRecord(raw, "agents");
const defaults = ensureRecord(agents, "defaults");
const existing = getRecord(defaults.memorySearch);
if (!existing) {
defaults.memorySearch = legacyMemorySearch;
changes.push("Moved memorySearch → agents.defaults.memorySearch.");
} else {
// agents.defaults stays authoritative; legacy top-level config only fills gaps.
const merged = structuredClone(existing);
mergeMissing(merged, legacyMemorySearch);
defaults.memorySearch = merged;
changes.push(
"Merged memorySearch → agents.defaults.memorySearch (filled missing fields from legacy; kept explicit agents.defaults values).",
);
}
agents.defaults = defaults;
raw.agents = agents;
delete raw.memorySearch;
},
},
{
id: "auth.anthropic-claude-cli-mode-oauth",
describe: "Switch anthropic:claude-cli auth profile mode to oauth",
apply: (raw, changes) => {
const auth = getRecord(raw.auth);
const profiles = getRecord(auth?.profiles);
if (!profiles) {
return;
}
const claudeCli = getRecord(profiles["anthropic:claude-cli"]);
if (!claudeCli) {
return;
}
if (claudeCli.mode !== "token") {
return;
}
claudeCli.mode = "oauth";
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
},
},
// tools.alsoAllow migration removed (field not shipped in prod; enforce via schema instead).
{
id: "tools.bash->tools.exec",
describe: "Move tools.bash to tools.exec",
apply: (raw, changes) => {
const tools = ensureRecord(raw, "tools");
const bash = getRecord(tools.bash);
if (!bash) {
return;
}
if (tools.exec === undefined) {
tools.exec = bash;
changes.push("Moved tools.bash → tools.exec.");
} else {
changes.push("Removed tools.bash (tools.exec already set).");
}
delete tools.bash;
},
},
{
id: "messages.tts.enabled->auto",
describe: "Move messages.tts.enabled to messages.tts.auto",
apply: (raw, changes) => {
const messages = getRecord(raw.messages);
const tts = getRecord(messages?.tts);
if (!tts) {
return;
}
if (tts.auto !== undefined) {
if ("enabled" in tts) {
delete tts.enabled;
changes.push("Removed messages.tts.enabled (messages.tts.auto already set).");
}
return;
}
if (typeof tts.enabled !== "boolean") {
return;
}
tts.auto = tts.enabled ? "always" : "off";
delete tts.enabled;
changes.push(`Moved messages.tts.enabled → messages.tts.auto (${String(tts.auto)}).`);
},
},
{
id: "agent.defaults-v2",
describe: "Move agent config to agents.defaults and tools",
apply: (raw, changes) => {
const agent = getRecord(raw.agent);
if (!agent) {
return;
}
const agents = ensureRecord(raw, "agents");
const defaults = getRecord(agents.defaults) ?? {};
const tools = ensureRecord(raw, "tools");
const agentTools = getRecord(agent.tools);
if (agentTools) {
if (tools.allow === undefined && agentTools.allow !== undefined) {
tools.allow = agentTools.allow;
changes.push("Moved agent.tools.allow → tools.allow.");
}
if (tools.deny === undefined && agentTools.deny !== undefined) {
tools.deny = agentTools.deny;
changes.push("Moved agent.tools.deny → tools.deny.");
}
}
const elevated = getRecord(agent.elevated);
if (elevated) {
if (tools.elevated === undefined) {
tools.elevated = elevated;
changes.push("Moved agent.elevated → tools.elevated.");
} else {
changes.push("Removed agent.elevated (tools.elevated already set).");
}
}
const bash = getRecord(agent.bash);
if (bash) {
if (tools.exec === undefined) {
tools.exec = bash;
changes.push("Moved agent.bash → tools.exec.");
} else {
changes.push("Removed agent.bash (tools.exec already set).");
}
}
const sandbox = getRecord(agent.sandbox);
if (sandbox) {
const sandboxTools = getRecord(sandbox.tools);
if (sandboxTools) {
const toolsSandbox = ensureRecord(tools, "sandbox");
const toolPolicy = ensureRecord(toolsSandbox, "tools");
mergeMissing(toolPolicy, sandboxTools);
delete sandbox.tools;
changes.push("Moved agent.sandbox.tools → tools.sandbox.tools.");
}
}
const subagents = getRecord(agent.subagents);
if (subagents) {
const subagentTools = getRecord(subagents.tools);
if (subagentTools) {
const toolsSubagents = ensureRecord(tools, "subagents");
const toolPolicy = ensureRecord(toolsSubagents, "tools");
mergeMissing(toolPolicy, subagentTools);
delete subagents.tools;
changes.push("Moved agent.subagents.tools → tools.subagents.tools.");
}
}
const agentCopy: Record<string, unknown> = structuredClone(agent);
delete agentCopy.tools;
delete agentCopy.elevated;
delete agentCopy.bash;
if (isRecord(agentCopy.sandbox)) {
delete agentCopy.sandbox.tools;
}
if (isRecord(agentCopy.subagents)) {
delete agentCopy.subagents.tools;
}
mergeMissing(defaults, agentCopy);
agents.defaults = defaults;
raw.agents = agents;
delete raw.agent;
changes.push("Moved agent → agents.defaults.");
},
},
{
id: "heartbeat->agents.defaults.heartbeat",
describe: "Move top-level heartbeat to agents.defaults.heartbeat/channels.defaults.heartbeat",
apply: (raw, changes) => {
const legacyHeartbeat = getRecord(raw.heartbeat);
if (!legacyHeartbeat) {
return;
}
const { agentHeartbeat, channelHeartbeat } = splitLegacyHeartbeat(legacyHeartbeat);
if (agentHeartbeat) {
const agents = ensureRecord(raw, "agents");
const defaults = ensureRecord(agents, "defaults");
const existing = getRecord(defaults.heartbeat);
if (!existing) {
defaults.heartbeat = agentHeartbeat;
changes.push("Moved heartbeat → agents.defaults.heartbeat.");
} else {
// agents.defaults stays authoritative; legacy top-level config only fills gaps.
const merged = structuredClone(existing);
mergeMissing(merged, agentHeartbeat);
defaults.heartbeat = merged;
changes.push(
"Merged heartbeat → agents.defaults.heartbeat (filled missing fields from legacy; kept explicit agents.defaults values).",
);
}
agents.defaults = defaults;
raw.agents = agents;
}
if (channelHeartbeat) {
const channels = ensureRecord(raw, "channels");
const defaults = ensureRecord(channels, "defaults");
const existing = getRecord(defaults.heartbeat);
if (!existing) {
defaults.heartbeat = channelHeartbeat;
changes.push("Moved heartbeat visibility → channels.defaults.heartbeat.");
} else {
// channels.defaults stays authoritative; legacy top-level config only fills gaps.
const merged = structuredClone(existing);
mergeMissing(merged, channelHeartbeat);
defaults.heartbeat = merged;
changes.push(
"Merged heartbeat visibility → channels.defaults.heartbeat (filled missing fields from legacy; kept explicit channels.defaults values).",
);
}
channels.defaults = defaults;
raw.channels = channels;
}
if (!agentHeartbeat && !channelHeartbeat) {
changes.push("Removed empty top-level heartbeat.");
}
delete raw.heartbeat;
},
},
{
id: "identity->agents.list",
describe: "Move identity to agents.list[].identity",
apply: (raw, changes) => {
const identity = getRecord(raw.identity);
if (!identity) {
return;
}
const agents = ensureRecord(raw, "agents");
const list = getAgentsList(agents);
const defaultId = resolveDefaultAgentIdFromRaw(raw);
const entry = ensureAgentEntry(list, defaultId);
if (entry.identity === undefined) {
entry.identity = identity;
changes.push(`Moved identity → agents.list (id "${defaultId}").identity.`);
} else {
changes.push("Removed identity (agents.list identity already set).");
}
agents.list = list;
raw.agents = agents;
delete raw.identity;
},
},
];