From 16420e5b31f08f2b7848bff5da463b915a9f9934 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 2 Jan 2026 13:07:14 +0100 Subject: [PATCH] refactor: auto-migrate legacy config in gateway --- src/cli/program.ts | 2 +- src/commands/doctor.test.ts | 11 +++-- src/commands/doctor.ts | 80 +++++++------------------------------ src/config/config.test.ts | 11 +++++ src/config/config.ts | 58 +++++++++++++++++++++++++++ src/gateway/server.test.ts | 4 ++ src/gateway/server.ts | 26 ++++++++++++ 7 files changed, 123 insertions(+), 69 deletions(-) diff --git a/src/cli/program.ts b/src/cli/program.ts index d5e9d6a52..926094987 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -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); diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index a988a370d..000e6c01c 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -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, + 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); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 1758dff0b..ac35fbe30 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -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, 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).allowFrom; - if (allowFrom === undefined) return; - - const whatsapp = - raw.whatsapp && typeof raw.whatsapp === "object" - ? (raw.whatsapp as Record) - : {}; - - 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).allowFrom; - if (Object.keys(routing as Record).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; - 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}`); + } } } } diff --git a/src/config/config.test.ts b/src/config/config.test.ts index 06e9e8377..4d46782fe 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -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"); diff --git a/src/config/config.ts b/src/config/config.ts index 9c217fc95..2ea0300bf 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -1161,6 +1161,12 @@ type LegacyConfigRule = { message: string; }; +type LegacyConfigMigration = { + id: string; + describe: string; + apply: (raw: Record, 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).allowFrom; + if (allowFrom === undefined) return; + + const whatsapp = + raw.whatsapp && typeof raw.whatsapp === "object" + ? (raw.whatsapp as Record) + : {}; + + 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).allowFrom; + if (Object.keys(routing as Record).length === 0) { + delete raw.routing; + } + raw.whatsapp = whatsapp; + }, + }, +]; + function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] { if (!raw || typeof raw !== "object") return []; const root = raw as Record; @@ -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; + 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, "\\$&"); } diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index 2c58d039f..f9e059693 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -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, + changes: [], + }), loadConfig: () => ({ agent: { model: "anthropic/claude-opus-4-5", diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 979e04188..1ffb6724c 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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 { + 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);