refactor: move whatsapp allowFrom config

This commit is contained in:
Peter Steinberger
2026-01-02 12:59:47 +01:00
parent 58d32d4542
commit 0766c5e3cb
39 changed files with 452 additions and 117 deletions

View File

@@ -158,7 +158,7 @@ export async function agentCommand(
});
const workspaceDir = workspace.dir;
const allowFrom = (cfg.routing?.allowFrom ?? [])
const allowFrom = (cfg.whatsapp?.allowFrom ?? [])
.map((val) => normalizeE164(val))
.filter((val) => val.length > 1);
@@ -451,7 +451,7 @@ export async function agentCommand(
if (deliver) {
if (deliveryProvider === "whatsapp" && !whatsappTarget) {
const err = new Error(
"Delivering to WhatsApp requires --to <E.164> or routing.allowFrom[0]",
"Delivering to WhatsApp requires --to <E.164> or whatsapp.allowFrom[0]",
);
if (!bestEffortDeliver) throw err;
logDeliveryError(err);

View File

@@ -0,0 +1,96 @@
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,
config: raw as Record<string, unknown>,
}));
vi.mock("@clack/prompts", () => ({
confirm: vi.fn().mockResolvedValue(true),
intro: vi.fn(),
note: vi.fn(),
outro: vi.fn(),
}));
vi.mock("../agents/skills-status.js", () => ({
buildWorkspaceSkillStatus: () => ({ skills: [] }),
}));
vi.mock("../config/config.js", () => ({
CONFIG_PATH_CLAWDIS: "/tmp/clawdis.json",
readConfigFileSnapshot,
writeConfigFile,
validateConfigObject,
}));
vi.mock("../runtime.js", () => ({
defaultRuntime: {
log: () => {},
error: () => {},
exit: () => {
throw new Error("exit");
},
},
}));
vi.mock("../utils.js", () => ({
resolveUserPath: (value: string) => value,
sleep: vi.fn(),
}));
vi.mock("./health.js", () => ({
healthCommand: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("./onboard-helpers.js", () => ({
applyWizardMetadata: (cfg: Record<string, unknown>) => cfg,
DEFAULT_WORKSPACE: "/tmp",
guardCancel: (value: unknown) => value,
printWizardHeader: vi.fn(),
}));
describe("doctor", () => {
it("migrates routing.allowFrom to whatsapp.allowFrom", async () => {
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/clawdis.json",
exists: true,
raw: "{}",
parsed: { routing: { allowFrom: ["+15555550123"] } },
valid: false,
config: {},
issues: [
{
path: "routing.allowFrom",
message: "legacy",
},
],
legacyIssues: [
{
path: "routing.allowFrom",
message: "legacy",
},
],
});
const { doctorCommand } = await import("./doctor.js");
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await doctorCommand(runtime);
expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record<
string,
unknown
>;
expect((written.whatsapp as Record<string, unknown>)?.allowFrom).toEqual([
"+15555550123",
]);
expect(written.routing).toBeUndefined();
});
});

View File

@@ -5,6 +5,7 @@ import type { ClawdisConfig } from "../config/config.js";
import {
CONFIG_PATH_CLAWDIS,
readConfigFileSnapshot,
validateConfigObject,
writeConfigFile,
} from "../config/config.js";
import { resolveGatewayService } from "../daemon/service.js";
@@ -19,6 +20,65 @@ 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: TBD) — 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";
}
@@ -29,10 +89,37 @@ export async function doctorCommand(runtime: RuntimeEnv = defaultRuntime) {
const snapshot = await readConfigFileSnapshot();
let cfg: ClawdisConfig = snapshot.valid ? snapshot.config : {};
if (snapshot.exists && !snapshot.valid) {
if (snapshot.exists && !snapshot.valid && snapshot.legacyIssues.length === 0) {
note("Config invalid; doctor will run with defaults.", "Config");
}
if (snapshot.legacyIssues.length > 0) {
note(
snapshot.legacyIssues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n"),
"Legacy config keys detected",
);
const migrate = guardCancel(
await confirm({
message: "Migrate legacy config entries now?",
initialValue: true,
}),
runtime,
);
if (migrate) {
const { config: migrated, changes } = applyLegacyMigrations(
snapshot.parsed,
);
if (changes.length > 0) {
note(changes.join("\n"), "Doctor changes");
}
if (migrated) {
cfg = migrated;
}
}
}
const workspaceDir = resolveUserPath(
cfg.agent?.workspace ?? DEFAULT_WORKSPACE,
);

View File

@@ -64,11 +64,11 @@ function noteDiscordTokenHelp(): void {
);
}
function setRoutingAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
function setWhatsAppAllowFrom(cfg: ClawdisConfig, allowFrom?: string[]) {
return {
...cfg,
routing: {
...cfg.routing,
whatsapp: {
...cfg.whatsapp,
allowFrom,
},
};
@@ -78,13 +78,13 @@ async function promptWhatsAppAllowFrom(
cfg: ClawdisConfig,
runtime: RuntimeEnv,
): Promise<ClawdisConfig> {
const existingAllowFrom = cfg.routing?.allowFrom ?? [];
const existingAllowFrom = cfg.whatsapp?.allowFrom ?? [];
const existingLabel =
existingAllowFrom.length > 0 ? existingAllowFrom.join(", ") : "unset";
note(
[
"WhatsApp direct chats are gated by `routing.allowFrom`.",
"WhatsApp direct chats are gated by `whatsapp.allowFrom`.",
'Default (unset) = self-chat only; use "*" to allow anyone.',
`Current: ${existingLabel}`,
].join("\n"),
@@ -114,8 +114,8 @@ async function promptWhatsAppAllowFrom(
) as (typeof options)[number]["value"];
if (mode === "keep") return cfg;
if (mode === "self") return setRoutingAllowFrom(cfg, undefined);
if (mode === "any") return setRoutingAllowFrom(cfg, ["*"]);
if (mode === "self") return setWhatsAppAllowFrom(cfg, undefined);
if (mode === "any") return setWhatsAppAllowFrom(cfg, ["*"]);
const allowRaw = guardCancel(
await text({
@@ -148,7 +148,7 @@ async function promptWhatsAppAllowFrom(
part === "*" ? "*" : normalizeE164(part),
);
const unique = [...new Set(normalized.filter(Boolean))];
return setRoutingAllowFrom(cfg, unique);
return setWhatsAppAllowFrom(cfg, unique);
}
export async function setupProviders(