Merged via squash. Prepared head SHA: 1714ffe6fc1e3ed6a8a120d01d074f1be83c62d3 Co-authored-by: xiwan <931632+xiwan@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
This commit is contained in:
@@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/security default response headers: add `Permissions-Policy: camera=(), microphone=(), geolocation=()` to baseline gateway HTTP security headers for all responses. (#30186) thanks @habakan.
|
||||
- Plugins/startup loading: lazily initialize plugin runtime, split startup-critical plugin SDK imports into `openclaw/plugin-sdk/core` and `openclaw/plugin-sdk/telegram`, and preserve `api.runtime` reflection semantics for plugin compatibility. (#28620) thanks @hmemcpy.
|
||||
- Build/lazy runtime boundaries: replace ineffective dynamic import sites with dedicated lazy runtime boundaries across Slack slash handling, Telegram audit, CLI send deps, memory fallback, and outbound delivery paths while preserving behavior. (#33690) thanks @gumadeiras.
|
||||
- Config/heartbeat legacy-path handling: auto-migrate top-level `heartbeat` into `agents.defaults.heartbeat` (with merge semantics that preserve explicit defaults), and keep startup failures on non-migratable legacy entries in the detailed invalid-config path instead of generic migration-failed errors. (#32706) thanks @xiwan.
|
||||
- Security/auth labels: remove token and API-key snippets from user-facing auth status labels so `/status` and `/models` do not expose credential fragments. (#33262) thanks @cu1ch3n.
|
||||
- Security/audit denyCommands guidance: suggest likely exact node command IDs for unknown `gateway.nodes.denyCommands` entries so ineffective denylist entries are easier to correct. (#29713) thanks @liquidhorizon88-bot.
|
||||
- Docs/security hardening guidance: document Docker `DOCKER-USER` + UFW policy and add cross-linking from Docker install docs for VPS/public-host setups. (#27613) thanks @dorukardahan.
|
||||
|
||||
@@ -600,6 +600,67 @@ describe("doctor config flow", () => {
|
||||
expectGoogleChatDmAllowFromRepaired(result.cfg);
|
||||
});
|
||||
|
||||
it("migrates top-level heartbeat into agents.defaults.heartbeat on repair", async () => {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
config: {
|
||||
heartbeat: {
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
every: "30m",
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
|
||||
const cfg = result.cfg as {
|
||||
heartbeat?: unknown;
|
||||
agents?: {
|
||||
defaults?: {
|
||||
heartbeat?: {
|
||||
model?: string;
|
||||
every?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(cfg.heartbeat).toBeUndefined();
|
||||
expect(cfg.agents?.defaults?.heartbeat).toMatchObject({
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
every: "30m",
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates top-level heartbeat visibility into channels.defaults.heartbeat on repair", async () => {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
config: {
|
||||
heartbeat: {
|
||||
showOk: true,
|
||||
showAlerts: false,
|
||||
},
|
||||
},
|
||||
run: loadAndMaybeMigrateDoctorConfig,
|
||||
});
|
||||
|
||||
const cfg = result.cfg as {
|
||||
heartbeat?: unknown;
|
||||
channels?: {
|
||||
defaults?: {
|
||||
heartbeat?: {
|
||||
showOk?: boolean;
|
||||
showAlerts?: boolean;
|
||||
useIndicator?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
expect(cfg.heartbeat).toBeUndefined();
|
||||
expect(cfg.channels?.defaults?.heartbeat).toMatchObject({
|
||||
showOk: true,
|
||||
showAlerts: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("repairs googlechat account dm.policy open by setting dm.allowFrom on repair", async () => {
|
||||
const result = await runDoctorConfigWithInput({
|
||||
repair: true,
|
||||
|
||||
@@ -274,6 +274,15 @@ describe("legacy config detection", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
it("flags top-level heartbeat as legacy in snapshot", async () => {
|
||||
await withSnapshotForConfig(
|
||||
{ heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m" } },
|
||||
async (ctx) => {
|
||||
expect(ctx.snapshot.valid).toBe(false);
|
||||
expect(ctx.snapshot.legacyIssues.some((issue) => issue.path === "heartbeat")).toBe(true);
|
||||
},
|
||||
);
|
||||
});
|
||||
it("flags legacy provider sections in snapshot", async () => {
|
||||
await withSnapshotForConfig({ whatsapp: { allowFrom: ["+1555"] } }, async (ctx) => {
|
||||
expect(ctx.snapshot.valid).toBe(false);
|
||||
|
||||
@@ -96,6 +96,130 @@ describe("legacy migrate mention routing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy migrate heartbeat config", () => {
|
||||
it("moves top-level heartbeat into agents.defaults.heartbeat", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
heartbeat: {
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
every: "30m",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.changes).toContain("Moved heartbeat → agents.defaults.heartbeat.");
|
||||
expect(res.config?.agents?.defaults?.heartbeat).toEqual({
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
every: "30m",
|
||||
});
|
||||
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves top-level heartbeat visibility into channels.defaults.heartbeat", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
heartbeat: {
|
||||
showOk: true,
|
||||
showAlerts: false,
|
||||
useIndicator: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.changes).toContain("Moved heartbeat visibility → channels.defaults.heartbeat.");
|
||||
expect(res.config?.channels?.defaults?.heartbeat).toEqual({
|
||||
showOk: true,
|
||||
showAlerts: false,
|
||||
useIndicator: false,
|
||||
});
|
||||
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps explicit agents.defaults.heartbeat values when merging top-level heartbeat", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
heartbeat: {
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
every: "30m",
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
every: "1h",
|
||||
target: "telegram",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.changes).toContain(
|
||||
"Merged heartbeat → agents.defaults.heartbeat (filled missing fields from legacy; kept explicit agents.defaults values).",
|
||||
);
|
||||
expect(res.config?.agents?.defaults?.heartbeat).toEqual({
|
||||
every: "1h",
|
||||
target: "telegram",
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
});
|
||||
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps explicit channels.defaults.heartbeat values when merging top-level heartbeat visibility", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
heartbeat: {
|
||||
showOk: true,
|
||||
showAlerts: true,
|
||||
},
|
||||
channels: {
|
||||
defaults: {
|
||||
heartbeat: {
|
||||
showOk: false,
|
||||
useIndicator: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.changes).toContain(
|
||||
"Merged heartbeat visibility → channels.defaults.heartbeat (filled missing fields from legacy; kept explicit channels.defaults values).",
|
||||
);
|
||||
expect(res.config?.channels?.defaults?.heartbeat).toEqual({
|
||||
showOk: false,
|
||||
showAlerts: true,
|
||||
useIndicator: false,
|
||||
});
|
||||
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves agent.heartbeat precedence over top-level heartbeat legacy key", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
agent: {
|
||||
heartbeat: {
|
||||
every: "1h",
|
||||
target: "telegram",
|
||||
},
|
||||
},
|
||||
heartbeat: {
|
||||
every: "30m",
|
||||
target: "discord",
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.config?.agents?.defaults?.heartbeat).toEqual({
|
||||
every: "1h",
|
||||
target: "telegram",
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
});
|
||||
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
||||
expect((res.config as { agent?: unknown } | null)?.agent).toBeUndefined();
|
||||
});
|
||||
|
||||
it("records a migration change when removing empty top-level heartbeat", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
heartbeat: {},
|
||||
});
|
||||
|
||||
expect(res.changes).toContain("Removed empty top-level heartbeat.");
|
||||
expect(res.config).not.toBeNull();
|
||||
expect((res.config as { heartbeat?: unknown } | null)?.heartbeat).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("legacy migrate controlUi.allowedOrigins seed (issue #29385)", () => {
|
||||
it("seeds allowedOrigins for bind=lan with no existing controlUi config", () => {
|
||||
const res = migrateLegacyConfig({
|
||||
|
||||
@@ -16,6 +16,51 @@ import {
|
||||
} 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).
|
||||
@@ -245,6 +290,65 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
||||
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",
|
||||
|
||||
@@ -204,4 +204,9 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
|
||||
match: (value) => isLegacyGatewayBindHostAlias(value),
|
||||
requireSourceLiteral: true,
|
||||
},
|
||||
{
|
||||
path: ["heartbeat"],
|
||||
message:
|
||||
"top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
|
||||
},
|
||||
];
|
||||
|
||||
@@ -256,17 +256,18 @@ export async function startGatewayServer(
|
||||
}
|
||||
const { config: migrated, changes } = migrateLegacyConfig(configSnapshot.parsed);
|
||||
if (!migrated) {
|
||||
throw new Error(
|
||||
`Legacy config entries detected but auto-migration failed. Run "${formatCliCommand("openclaw doctor")}" to migrate.`,
|
||||
);
|
||||
}
|
||||
await writeConfigFile(migrated);
|
||||
if (changes.length > 0) {
|
||||
log.info(
|
||||
`gateway: migrated legacy config entries:\n${changes
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}`,
|
||||
log.warn(
|
||||
"gateway: legacy config entries detected but no auto-migration changes were produced; continuing with validation.",
|
||||
);
|
||||
} else {
|
||||
await writeConfigFile(migrated);
|
||||
if (changes.length > 0) {
|
||||
log.info(
|
||||
`gateway: migrated legacy config entries:\n${changes
|
||||
.map((entry) => `- ${entry}`)
|
||||
.join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
83
src/gateway/server.legacy-migration.test.ts
Normal file
83
src/gateway/server.legacy-migration.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import {
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
startGatewayServer,
|
||||
testState,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
describe("gateway startup legacy migration fallback", () => {
|
||||
test("surfaces detailed validation errors when legacy entries have no migration output", async () => {
|
||||
testState.legacyIssues = [
|
||||
{
|
||||
path: "heartbeat",
|
||||
message:
|
||||
"top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
|
||||
},
|
||||
];
|
||||
testState.legacyParsed = {
|
||||
heartbeat: { model: "anthropic/claude-3-5-haiku-20241022", every: "30m" },
|
||||
};
|
||||
testState.migrationConfig = null;
|
||||
testState.migrationChanges = [];
|
||||
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
let thrown: unknown;
|
||||
try {
|
||||
server = await startGatewayServer(await getFreePort());
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
if (server) {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
const message = String((thrown as Error).message);
|
||||
expect(message).toContain("Invalid config at");
|
||||
expect(message).toContain(
|
||||
"heartbeat: top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
|
||||
);
|
||||
expect(message).not.toContain("Legacy config entries detected but auto-migration failed.");
|
||||
});
|
||||
|
||||
test("keeps detailed validation errors when heartbeat comes from include-resolved config", async () => {
|
||||
testState.legacyIssues = [
|
||||
{
|
||||
path: "heartbeat",
|
||||
message:
|
||||
"top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
|
||||
},
|
||||
];
|
||||
// Simulate a parsed source that only contains include directives, while
|
||||
// legacy heartbeat is surfaced from the resolved config.
|
||||
testState.legacyParsed = {
|
||||
$include: ["heartbeat.defaults.json"],
|
||||
};
|
||||
testState.migrationConfig = null;
|
||||
testState.migrationChanges = [];
|
||||
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
let thrown: unknown;
|
||||
try {
|
||||
server = await startGatewayServer(await getFreePort());
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
|
||||
if (server) {
|
||||
await server.close();
|
||||
}
|
||||
|
||||
expect(thrown).toBeInstanceOf(Error);
|
||||
const message = String((thrown as Error).message);
|
||||
expect(message).toContain("Invalid config at");
|
||||
expect(message).toContain(
|
||||
"heartbeat: top-level heartbeat is not a valid config path; use agents.defaults.heartbeat (cadence/target/model settings) or channels.defaults.heartbeat (showOk/showAlerts/useIndicator).",
|
||||
);
|
||||
expect(message).not.toContain("Legacy config entries detected but auto-migration failed.");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user