mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-19 07:47:28 +00:00
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
377 lines
13 KiB
TypeScript
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;
|
|
},
|
|
},
|
|
];
|