refactor: auto-migrate legacy config in gateway

This commit is contained in:
Peter Steinberger
2026-01-02 13:07:14 +01:00
parent 55665246bb
commit 16420e5b31
7 changed files with 123 additions and 69 deletions

View File

@@ -79,7 +79,7 @@ export function buildProgram() {
.join("\n");
defaultRuntime.error(
danger(
`Legacy config entries detected. Ask your agent to run \"clawdis doctor\" to migrate.\n${issues}`,
`Legacy config entries detected. Run \"clawdis doctor\" (or ask your agent) to migrate.\n${issues}`,
),
);
process.exit(1);

View File

@@ -2,9 +2,9 @@ import { describe, expect, it, vi } from "vitest";
const readConfigFileSnapshot = vi.fn();
const writeConfigFile = vi.fn().mockResolvedValue(undefined);
const validateConfigObject = vi.fn((raw: unknown) => ({
ok: true as const,
const migrateLegacyConfig = vi.fn((raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
}));
vi.mock("@clack/prompts", () => ({
@@ -22,7 +22,7 @@ vi.mock("../config/config.js", () => ({
CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json",
readConfigFileSnapshot,
writeConfigFile,
validateConfigObject,
migrateLegacyConfig,
}));
vi.mock("../runtime.js", () => ({
@@ -81,6 +81,11 @@ describe("doctor", () => {
exit: vi.fn(),
};
migrateLegacyConfig.mockReturnValue({
config: { whatsapp: { allowFrom: ["+15555550123"] } },
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
});
await doctorCommand(runtime);
expect(writeConfigFile).toHaveBeenCalledTimes(1);

View File

@@ -4,8 +4,8 @@ import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import type { ClawdisConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDIS,
migrateLegacyConfig,
readConfigFileSnapshot,
validateConfigObject,
writeConfigFile,
} from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js";
@@ -20,65 +20,6 @@ import {
printWizardHeader,
} from "./onboard-helpers.js";
type LegacyMigration = {
id: string;
describe: string;
apply: (raw: Record<string, unknown>, changes: string[]) => void;
};
const LEGACY_MIGRATIONS: LegacyMigration[] = [
// Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
{
id: "routing.allowFrom->whatsapp.allowFrom",
describe: "Move routing.allowFrom to whatsapp.allowFrom",
apply: (raw, changes) => {
const routing = raw.routing;
if (!routing || typeof routing !== "object") return;
const allowFrom = (routing as Record<string, unknown>).allowFrom;
if (allowFrom === undefined) return;
const whatsapp =
raw.whatsapp && typeof raw.whatsapp === "object"
? (raw.whatsapp as Record<string, unknown>)
: {};
if (whatsapp.allowFrom === undefined) {
whatsapp.allowFrom = allowFrom;
changes.push("Moved routing.allowFrom → whatsapp.allowFrom.");
} else {
changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set).");
}
delete (routing as Record<string, unknown>).allowFrom;
if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing;
}
raw.whatsapp = whatsapp;
},
},
];
function applyLegacyMigrations(raw: unknown): {
config: ClawdisConfig | null;
changes: string[];
} {
if (!raw || typeof raw !== "object") return { config: null, changes: [] };
const next = structuredClone(raw) as Record<string, unknown>;
const changes: string[] = [];
for (const migration of LEGACY_MIGRATIONS) {
migration.apply(next, changes);
}
if (changes.length === 0) return { config: null, changes: [] };
const validated = validateConfigObject(next);
if (!validated.ok) {
changes.push(
"Migration applied, but config still invalid; fix remaining issues manually.",
);
return { config: null, changes };
}
return { config: validated.config, changes };
}
function resolveMode(cfg: ClawdisConfig): "local" | "remote" {
return cfg.gateway?.mode === "remote" ? "remote" : "local";
}
@@ -108,9 +49,8 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
runtime,
);
if (migrate) {
const { config: migrated, changes } = applyLegacyMigrations(
snapshot.parsed,
);
// Legacy migration (2026-01-02, commit: 0766c5e3) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig(snapshot.parsed);
if (changes.length > 0) {
note(changes.join("\n"), "Doctor changes");
}
@@ -144,7 +84,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
healthOk = true;
} catch (err) {
runtime.error(`Health check failed: ${String(err)}`);
const message = String(err);
if (message.includes("gateway closed")) {
note("Gateway not running.", "Gateway");
} else {
runtime.error(`Health check failed: ${message}`);
}
}
if (!healthOk) {
@@ -166,7 +111,12 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
try {
await healthCommand({ json: false, timeoutMs: 10_000 }, runtime);
} catch (err) {
runtime.error(`Health check failed: ${String(err)}`);
const message = String(err);
if (message.includes("gateway closed")) {
note("Gateway not running.", "Gateway");
} else {
runtime.error(`Health check failed: ${message}`);
}
}
}
}

View File

@@ -502,6 +502,17 @@ describe("legacy config detection", () => {
}
});
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
routing: { allowFrom: ["+15555550123"] },
});
expect(res.changes).toContain("Moved routing.allowFrom → whatsapp.allowFrom.");
expect(res.config?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
expect(res.config?.routing?.allowFrom).toBeUndefined();
});
it("surfaces legacy issues in snapshot", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdis", "clawdis.json");

View File

@@ -1161,6 +1161,12 @@ type LegacyConfigRule = {
message: string;
};
type LegacyConfigMigration = {
id: string;
describe: string;
apply: (raw: Record<string, unknown>, changes: string[]) => void;
};
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
{
path: ["routing", "allowFrom"],
@@ -1169,6 +1175,37 @@ const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
},
];
const LEGACY_CONFIG_MIGRATIONS: LegacyConfigMigration[] = [
{
id: "routing.allowFrom->whatsapp.allowFrom",
describe: "Move routing.allowFrom to whatsapp.allowFrom",
apply: (raw, changes) => {
const routing = raw.routing;
if (!routing || typeof routing !== "object") return;
const allowFrom = (routing as Record<string, unknown>).allowFrom;
if (allowFrom === undefined) return;
const whatsapp =
raw.whatsapp && typeof raw.whatsapp === "object"
? (raw.whatsapp as Record<string, unknown>)
: {};
if (whatsapp.allowFrom === undefined) {
whatsapp.allowFrom = allowFrom;
changes.push("Moved routing.allowFrom → whatsapp.allowFrom.");
} else {
changes.push("Removed routing.allowFrom (whatsapp.allowFrom already set).");
}
delete (routing as Record<string, unknown>).allowFrom;
if (Object.keys(routing as Record<string, unknown>).length === 0) {
delete raw.routing;
}
raw.whatsapp = whatsapp;
},
},
];
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
if (!raw || typeof raw !== "object") return [];
const root = raw as Record<string, unknown>;
@@ -1189,6 +1226,27 @@ function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
return issues;
}
export function migrateLegacyConfig(raw: unknown): {
config: ClawdisConfig | null;
changes: string[];
} {
if (!raw || typeof raw !== "object") return { config: null, changes: [] };
const next = structuredClone(raw) as Record<string, unknown>;
const changes: string[] = [];
for (const migration of LEGACY_CONFIG_MIGRATIONS) {
migration.apply(next, changes);
}
if (changes.length === 0) return { config: null, changes: [] };
const validated = validateConfigObject(next);
if (!validated.ok) {
changes.push(
"Migration applied, but config still invalid; fix remaining issues manually.",
);
return { config: null, changes };
}
return { config: validated.config, changes };
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -204,6 +204,10 @@ vi.mock("../config/config.js", () => {
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
STATE_DIR_CLAWDIS: path.dirname(resolveConfigPath()),
isNixMode: false,
migrateLegacyConfig: (raw: unknown) => ({
config: raw as Record<string, unknown>,
changes: [],
}),
loadConfig: () => ({
agent: {
model: "anthropic/claude-opus-4-5",

View File

@@ -48,6 +48,7 @@ import {
CONFIG_PATH_CLAWDIS,
isNixMode,
loadConfig,
migrateLegacyConfig,
parseConfigJson5,
readConfigFileSnapshot,
STATE_DIR_CLAWDIS,
@@ -1322,6 +1323,31 @@ export async function startGatewayServer(
port = 18789,
opts: GatewayServerOptions = {},
): Promise<GatewayServer> {
const configSnapshot = await readConfigFileSnapshot();
if (configSnapshot.legacyIssues.length > 0) {
if (isNixMode) {
throw new Error(
"Legacy config entries detected while running in Nix mode. Update your Nix config to the latest schema and restart.",
);
}
const { config: migrated, changes } = migrateLegacyConfig(
configSnapshot.parsed,
);
if (!migrated) {
throw new Error(
"Legacy config entries detected but auto-migration failed. Run \"clawdis doctor\" to migrate.",
);
}
await writeConfigFile(migrated);
if (changes.length > 0) {
log.info(
`gateway: migrated legacy config entries:\n${changes
.map((entry) => `- ${entry}`)
.join("\n")}`,
);
}
}
const cfgAtStart = loadConfig();
const bindMode = opts.bind ?? cfgAtStart.gateway?.bind ?? "loopback";
const bindHost = opts.host ?? resolveGatewayBindHost(bindMode);