Skip to content

Commit 2ff29a3

Browse files
committed
refactor: split doctor runtime migrations and talk runtime tests
1 parent 0ef9383 commit 2ff29a3

11 files changed

Lines changed: 670 additions & 604 deletions
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import {
2+
defineLegacyConfigMigration,
3+
ensureRecord,
4+
getRecord,
5+
mergeMissing,
6+
type LegacyConfigMigrationSpec,
7+
type LegacyConfigRule,
8+
} from "../../../config/legacy.shared.js";
9+
import { isBlockedObjectKey } from "../../../config/prototype-keys.js";
10+
11+
const AGENT_HEARTBEAT_KEYS = new Set([
12+
"every",
13+
"activeHours",
14+
"model",
15+
"session",
16+
"includeReasoning",
17+
"target",
18+
"directPolicy",
19+
"to",
20+
"accountId",
21+
"prompt",
22+
"ackMaxChars",
23+
"suppressToolErrorWarnings",
24+
"lightContext",
25+
"isolatedSession",
26+
]);
27+
28+
const CHANNEL_HEARTBEAT_KEYS = new Set(["showOk", "showAlerts", "useIndicator"]);
29+
30+
const MEMORY_SEARCH_RULE: LegacyConfigRule = {
31+
path: ["memorySearch"],
32+
message:
33+
'top-level memorySearch was moved; use agents.defaults.memorySearch instead. Run "openclaw doctor --fix".',
34+
};
35+
36+
const HEARTBEAT_RULE: LegacyConfigRule = {
37+
path: ["heartbeat"],
38+
message:
39+
"top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
40+
};
41+
42+
const LEGACY_SANDBOX_SCOPE_RULES: LegacyConfigRule[] = [
43+
{
44+
path: ["agents", "defaults", "sandbox"],
45+
message:
46+
'agents.defaults.sandbox.perSession is legacy; use agents.defaults.sandbox.scope instead. Run "openclaw doctor --fix".',
47+
match: (value) => hasLegacySandboxPerSession(value),
48+
},
49+
{
50+
path: ["agents", "list"],
51+
message:
52+
'agents.list[].sandbox.perSession is legacy; use agents.list[].sandbox.scope instead. Run "openclaw doctor --fix".',
53+
match: (value) => hasLegacyAgentListSandboxPerSession(value),
54+
},
55+
];
56+
57+
function sandboxScopeFromPerSession(perSession: boolean): "session" | "shared" {
58+
return perSession ? "session" : "shared";
59+
}
60+
61+
function splitLegacyHeartbeat(legacyHeartbeat: Record<string, unknown>): {
62+
agentHeartbeat: Record<string, unknown> | null;
63+
channelHeartbeat: Record<string, unknown> | null;
64+
} {
65+
const agentHeartbeat: Record<string, unknown> = {};
66+
const channelHeartbeat: Record<string, unknown> = {};
67+
68+
for (const [key, value] of Object.entries(legacyHeartbeat)) {
69+
if (isBlockedObjectKey(key)) {
70+
continue;
71+
}
72+
if (CHANNEL_HEARTBEAT_KEYS.has(key)) {
73+
channelHeartbeat[key] = value;
74+
continue;
75+
}
76+
if (AGENT_HEARTBEAT_KEYS.has(key)) {
77+
agentHeartbeat[key] = value;
78+
continue;
79+
}
80+
agentHeartbeat[key] = value;
81+
}
82+
83+
return {
84+
agentHeartbeat: Object.keys(agentHeartbeat).length > 0 ? agentHeartbeat : null,
85+
channelHeartbeat: Object.keys(channelHeartbeat).length > 0 ? channelHeartbeat : null,
86+
};
87+
}
88+
89+
function mergeLegacyIntoDefaults(params: {
90+
raw: Record<string, unknown>;
91+
rootKey: "agents" | "channels";
92+
fieldKey: string;
93+
legacyValue: Record<string, unknown>;
94+
changes: string[];
95+
movedMessage: string;
96+
mergedMessage: string;
97+
}) {
98+
const root = ensureRecord(params.raw, params.rootKey);
99+
const defaults = ensureRecord(root, "defaults");
100+
const existing = getRecord(defaults[params.fieldKey]);
101+
if (!existing) {
102+
defaults[params.fieldKey] = params.legacyValue;
103+
params.changes.push(params.movedMessage);
104+
} else {
105+
const merged = structuredClone(existing);
106+
mergeMissing(merged, params.legacyValue);
107+
defaults[params.fieldKey] = merged;
108+
params.changes.push(params.mergedMessage);
109+
}
110+
111+
root.defaults = defaults;
112+
params.raw[params.rootKey] = root;
113+
}
114+
115+
function hasLegacySandboxPerSession(value: unknown): boolean {
116+
const sandbox = getRecord(value);
117+
return Boolean(sandbox && Object.prototype.hasOwnProperty.call(sandbox, "perSession"));
118+
}
119+
120+
function hasLegacyAgentListSandboxPerSession(value: unknown): boolean {
121+
if (!Array.isArray(value)) {
122+
return false;
123+
}
124+
return value.some((agent) => hasLegacySandboxPerSession(getRecord(agent)?.sandbox));
125+
}
126+
127+
function migrateLegacySandboxPerSession(
128+
sandbox: Record<string, unknown>,
129+
pathLabel: string,
130+
changes: string[],
131+
): void {
132+
if (!Object.prototype.hasOwnProperty.call(sandbox, "perSession")) {
133+
return;
134+
}
135+
const rawPerSession = sandbox.perSession;
136+
if (typeof rawPerSession !== "boolean") {
137+
return;
138+
}
139+
if (sandbox.scope === undefined) {
140+
sandbox.scope = sandboxScopeFromPerSession(rawPerSession);
141+
changes.push(`Moved ${pathLabel}.perSession → ${pathLabel}.scope (${String(sandbox.scope)}).`);
142+
} else {
143+
changes.push(`Removed ${pathLabel}.perSession (${pathLabel}.scope already set).`);
144+
}
145+
delete sandbox.perSession;
146+
}
147+
148+
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_AGENTS: LegacyConfigMigrationSpec[] = [
149+
defineLegacyConfigMigration({
150+
id: "agents.sandbox.perSession->scope",
151+
describe: "Move legacy agent sandbox perSession aliases to sandbox.scope",
152+
legacyRules: LEGACY_SANDBOX_SCOPE_RULES,
153+
apply: (raw, changes) => {
154+
const agents = getRecord(raw.agents);
155+
const defaults = getRecord(agents?.defaults);
156+
const defaultSandbox = getRecord(defaults?.sandbox);
157+
if (defaultSandbox) {
158+
migrateLegacySandboxPerSession(defaultSandbox, "agents.defaults.sandbox", changes);
159+
}
160+
161+
if (!Array.isArray(agents?.list)) {
162+
return;
163+
}
164+
for (const [index, agent] of agents.list.entries()) {
165+
const sandbox = getRecord(getRecord(agent)?.sandbox);
166+
if (!sandbox) {
167+
continue;
168+
}
169+
migrateLegacySandboxPerSession(sandbox, `agents.list.${index}.sandbox`, changes);
170+
}
171+
},
172+
}),
173+
defineLegacyConfigMigration({
174+
id: "memorySearch->agents.defaults.memorySearch",
175+
describe: "Move top-level memorySearch to agents.defaults.memorySearch",
176+
legacyRules: [MEMORY_SEARCH_RULE],
177+
apply: (raw, changes) => {
178+
const legacyMemorySearch = getRecord(raw.memorySearch);
179+
if (!legacyMemorySearch) {
180+
return;
181+
}
182+
183+
mergeLegacyIntoDefaults({
184+
raw,
185+
rootKey: "agents",
186+
fieldKey: "memorySearch",
187+
legacyValue: legacyMemorySearch,
188+
changes,
189+
movedMessage: "Moved memorySearch → agents.defaults.memorySearch.",
190+
mergedMessage:
191+
"Merged memorySearch → agents.defaults.memorySearch (filled missing fields from legacy; kept explicit agents.defaults values).",
192+
});
193+
delete raw.memorySearch;
194+
},
195+
}),
196+
defineLegacyConfigMigration({
197+
id: "heartbeat->agents.defaults.heartbeat",
198+
describe: "Move top-level heartbeat to agents.defaults.heartbeat/channels.defaults.heartbeat",
199+
legacyRules: [HEARTBEAT_RULE],
200+
apply: (raw, changes) => {
201+
const legacyHeartbeat = getRecord(raw.heartbeat);
202+
if (!legacyHeartbeat) {
203+
return;
204+
}
205+
206+
const { agentHeartbeat, channelHeartbeat } = splitLegacyHeartbeat(legacyHeartbeat);
207+
208+
if (agentHeartbeat) {
209+
mergeLegacyIntoDefaults({
210+
raw,
211+
rootKey: "agents",
212+
fieldKey: "heartbeat",
213+
legacyValue: agentHeartbeat,
214+
changes,
215+
movedMessage: "Moved heartbeat → agents.defaults.heartbeat.",
216+
mergedMessage:
217+
"Merged heartbeat → agents.defaults.heartbeat (filled missing fields from legacy; kept explicit agents.defaults values).",
218+
});
219+
}
220+
221+
if (channelHeartbeat) {
222+
mergeLegacyIntoDefaults({
223+
raw,
224+
rootKey: "channels",
225+
fieldKey: "heartbeat",
226+
legacyValue: channelHeartbeat,
227+
changes,
228+
movedMessage: "Moved heartbeat visibility → channels.defaults.heartbeat.",
229+
mergedMessage:
230+
"Merged heartbeat visibility → channels.defaults.heartbeat (filled missing fields from legacy; kept explicit channels.defaults values).",
231+
});
232+
}
233+
234+
if (!agentHeartbeat && !channelHeartbeat) {
235+
changes.push("Removed empty top-level heartbeat.");
236+
}
237+
delete raw.heartbeat;
238+
},
239+
}),
240+
];
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
import {
2+
buildDefaultControlUiAllowedOrigins,
3+
hasConfiguredControlUiAllowedOrigins,
4+
isGatewayNonLoopbackBindMode,
5+
resolveGatewayPortWithDefault,
6+
} from "../../../config/gateway-control-ui-origins.js";
7+
import {
8+
defineLegacyConfigMigration,
9+
getRecord,
10+
type LegacyConfigMigrationSpec,
11+
type LegacyConfigRule,
12+
} from "../../../config/legacy.shared.js";
13+
import { DEFAULT_GATEWAY_PORT } from "../../../config/paths.js";
14+
15+
const GATEWAY_BIND_RULE: LegacyConfigRule = {
16+
path: ["gateway", "bind"],
17+
message:
18+
'gateway.bind host aliases (for example 0.0.0.0/localhost) are legacy; use bind modes (lan/loopback/custom/tailnet/auto) instead. Run "openclaw doctor --fix".',
19+
match: (value) => isLegacyGatewayBindHostAlias(value),
20+
requireSourceLiteral: true,
21+
};
22+
23+
function isLegacyGatewayBindHostAlias(value: unknown): boolean {
24+
if (typeof value !== "string") {
25+
return false;
26+
}
27+
const normalized = value.trim().toLowerCase();
28+
if (!normalized) {
29+
return false;
30+
}
31+
if (
32+
normalized === "auto" ||
33+
normalized === "loopback" ||
34+
normalized === "lan" ||
35+
normalized === "tailnet" ||
36+
normalized === "custom"
37+
) {
38+
return false;
39+
}
40+
return (
41+
normalized === "0.0.0.0" ||
42+
normalized === "::" ||
43+
normalized === "[::]" ||
44+
normalized === "*" ||
45+
normalized === "127.0.0.1" ||
46+
normalized === "localhost" ||
47+
normalized === "::1" ||
48+
normalized === "[::1]"
49+
);
50+
}
51+
52+
function escapeControlForLog(value: string): string {
53+
return value.replace(/\r/g, "\\r").replace(/\n/g, "\\n").replace(/\t/g, "\\t");
54+
}
55+
56+
export const LEGACY_CONFIG_MIGRATIONS_RUNTIME_GATEWAY: LegacyConfigMigrationSpec[] = [
57+
defineLegacyConfigMigration({
58+
id: "gateway.controlUi.allowedOrigins-seed-for-non-loopback",
59+
describe: "Seed gateway.controlUi.allowedOrigins for existing non-loopback gateway installs",
60+
apply: (raw, changes) => {
61+
const gateway = getRecord(raw.gateway);
62+
if (!gateway) {
63+
return;
64+
}
65+
const bind = gateway.bind;
66+
if (!isGatewayNonLoopbackBindMode(bind)) {
67+
return;
68+
}
69+
const controlUi = getRecord(gateway.controlUi) ?? {};
70+
if (
71+
hasConfiguredControlUiAllowedOrigins({
72+
allowedOrigins: controlUi.allowedOrigins,
73+
dangerouslyAllowHostHeaderOriginFallback:
74+
controlUi.dangerouslyAllowHostHeaderOriginFallback,
75+
})
76+
) {
77+
return;
78+
}
79+
const port = resolveGatewayPortWithDefault(gateway.port, DEFAULT_GATEWAY_PORT);
80+
const origins = buildDefaultControlUiAllowedOrigins({
81+
port,
82+
bind,
83+
customBindHost:
84+
typeof gateway.customBindHost === "string" ? gateway.customBindHost : undefined,
85+
});
86+
gateway.controlUi = { ...controlUi, allowedOrigins: origins };
87+
raw.gateway = gateway;
88+
changes.push(
89+
`Seeded gateway.controlUi.allowedOrigins ${JSON.stringify(origins)} for bind=${String(bind)}. ` +
90+
"Required since v2026.2.26. Add other machine origins to gateway.controlUi.allowedOrigins if needed.",
91+
);
92+
},
93+
}),
94+
defineLegacyConfigMigration({
95+
id: "gateway.bind.host-alias->bind-mode",
96+
describe: "Normalize gateway.bind host aliases to supported bind modes",
97+
legacyRules: [GATEWAY_BIND_RULE],
98+
apply: (raw, changes) => {
99+
const gateway = getRecord(raw.gateway);
100+
if (!gateway) {
101+
return;
102+
}
103+
const bindRaw = gateway.bind;
104+
if (typeof bindRaw !== "string") {
105+
return;
106+
}
107+
108+
const normalized = bindRaw.trim().toLowerCase();
109+
let mapped: "lan" | "loopback" | undefined;
110+
if (
111+
normalized === "0.0.0.0" ||
112+
normalized === "::" ||
113+
normalized === "[::]" ||
114+
normalized === "*"
115+
) {
116+
mapped = "lan";
117+
} else if (
118+
normalized === "127.0.0.1" ||
119+
normalized === "localhost" ||
120+
normalized === "::1" ||
121+
normalized === "[::1]"
122+
) {
123+
mapped = "loopback";
124+
}
125+
126+
if (!mapped || normalized === mapped) {
127+
return;
128+
}
129+
130+
gateway.bind = mapped;
131+
raw.gateway = gateway;
132+
changes.push(`Normalized gateway.bind "${escapeControlForLog(bindRaw)}" → "${mapped}".`);
133+
},
134+
}),
135+
];

0 commit comments

Comments
 (0)