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

@@ -118,7 +118,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
routing: {
whatsapp: {
allowFrom: ["*"],
},
session: { store: path.join(home, "sessions.json") },
@@ -168,7 +168,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
routing: { allowFrom: ["*"] },
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
@@ -195,7 +195,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
routing: { allowFrom: ["*"] },
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
@@ -208,7 +208,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
routing: { allowFrom: ["*"] },
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
},
);
@@ -264,7 +264,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
routing: {
whatsapp: {
allowFrom: ["*"],
},
session: { store: storePath },
@@ -325,7 +325,7 @@ describe("directive parsing", () => {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "clawd"),
},
routing: {
whatsapp: {
allowFrom: ["*"],
},
session: { store: storePath },
@@ -506,7 +506,7 @@ describe("directive parsing", () => {
workspace: path.join(home, "clawd"),
allowedModels: ["openai/gpt-4.1-mini"],
},
routing: {
whatsapp: {
allowFrom: ["*"],
},
session: { store: storePath },

View File

@@ -42,7 +42,7 @@ function makeCfg(home: string) {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
routing: {
whatsapp: {
allowFrom: ["*"],
},
session: { store: join(home, "sessions.json") },
@@ -283,8 +283,10 @@ describe("trigger handling", () => {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
routing: {
whatsapp: {
allowFrom: ["*"],
},
routing: {
groupChat: { requireMention: false },
},
session: { store: join(home, "sessions.json") },
@@ -324,7 +326,7 @@ describe("trigger handling", () => {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
routing: {
whatsapp: {
allowFrom: ["*"],
},
session: {
@@ -363,7 +365,7 @@ describe("trigger handling", () => {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
routing: {
whatsapp: {
allowFrom: ["*"],
},
session: {

View File

@@ -841,14 +841,20 @@ export async function getReplyFromConfig(
const perMessageQueueMode =
hasQueueDirective && !inlineQueueReset ? inlineQueueMode : undefined;
// Optional allowlist by origin number (E.164 without whatsapp: prefix)
const configuredAllowFrom = cfg.routing?.allowFrom;
const surface = (ctx.Surface ?? "").trim().toLowerCase();
const isWhatsAppSurface =
surface === "whatsapp" ||
(ctx.From ?? "").startsWith("whatsapp:") ||
(ctx.To ?? "").startsWith("whatsapp:");
// WhatsApp owner allowlist (E.164 without whatsapp: prefix); used for group activation only.
const configuredAllowFrom = isWhatsAppSurface
? cfg.whatsapp?.allowFrom
: undefined;
const from = (ctx.From ?? "").replace(/^whatsapp:/, "");
const to = (ctx.To ?? "").replace(/^whatsapp:/, "");
const isSamePhone = from && to && from === to;
// If no config is present, default to self-only DM access.
const defaultAllowFrom =
(!configuredAllowFrom || configuredAllowFrom.length === 0) && to
isWhatsAppSurface && (!configuredAllowFrom || configuredAllowFrom.length === 0) && to
? [to]
: undefined;
const allowFrom =
@@ -862,10 +868,12 @@ export async function getReplyFromConfig(
: rawBodyNormalized;
const activationCommand = parseActivationCommand(commandBodyNormalized);
const senderE164 = normalizeE164(ctx.SenderE164 ?? "");
const ownerCandidates = (allowFrom ?? []).filter(
(entry) => entry && entry !== "*",
);
if (ownerCandidates.length === 0 && to) ownerCandidates.push(to);
const ownerCandidates = isWhatsAppSurface
? (allowFrom ?? []).filter((entry) => entry && entry !== "*")
: [];
if (isWhatsAppSurface && ownerCandidates.length === 0 && to) {
ownerCandidates.push(to);
}
const ownerList = ownerCandidates
.map((entry) => normalizeE164(entry))
.filter((entry): entry is string => Boolean(entry));
@@ -876,20 +884,6 @@ export async function getReplyFromConfig(
abortedLastRun = ABORT_MEMORY.get(abortKey) ?? false;
}
// Same-phone mode (self-messaging) is always allowed
if (isSamePhone) {
logVerbose(`Allowing same-phone mode: from === to (${from})`);
} else if (!isGroup && Array.isArray(allowFrom) && allowFrom.length > 0) {
// Support "*" as wildcard to allow all senders
if (!allowFrom.includes("*") && !allowFrom.includes(from)) {
logVerbose(
`Skipping auto-reply: sender ${from || "<unknown>"} not in allowFrom list`,
);
cleanupTyping();
return undefined;
}
}
if (activationCommand.hasCommand) {
if (!isGroup) {
cleanupTyping();

View File

@@ -14,6 +14,7 @@ import { danger, setVerbose } from "../globals.js";
import { loginWeb, logoutWeb } from "../provider-web.js";
import { defaultRuntime } from "../runtime.js";
import { VERSION } from "../version.js";
import { readConfigFileSnapshot } from "../config/config.js";
import { registerBrowserCli } from "./browser-cli.js";
import { registerCanvasCli } from "./canvas-cli.js";
import { registerCronCli } from "./cron-cli.js";
@@ -68,6 +69,21 @@ export function buildProgram() {
}
program.addHelpText("beforeAll", `\n${formatIntroLine(PROGRAM_VERSION)}\n`);
program.hook("preAction", async (_thisCommand, actionCommand) => {
if (actionCommand.name() === "doctor") return;
const snapshot = await readConfigFileSnapshot();
if (snapshot.legacyIssues.length === 0) return;
const issues = snapshot.legacyIssues
.map((issue) => `- ${issue.path}: ${issue.message}`)
.join("\n");
defaultRuntime.error(
danger(
`Legacy config entries detected. Ask your agent to run \"clawdis doctor\" to migrate.\n${issues}`,
),
);
process.exit(1);
});
const examples = [
[
"clawdis login --verbose",

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(

View File

@@ -488,3 +488,37 @@ describe("talk.voiceAliases", () => {
expect(res.ok).toBe(false);
});
});
describe("legacy config detection", () => {
it("rejects routing.allowFrom", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
routing: { allowFrom: ["+15555550123"] },
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("routing.allowFrom");
}
});
it("surfaces legacy issues in snapshot", async () => {
await withTempHome(async (home) => {
const configPath = path.join(home, ".clawdis", "clawdis.json");
await fs.mkdir(path.dirname(configPath), { recursive: true });
await fs.writeFile(
configPath,
JSON.stringify({ routing: { allowFrom: ["+15555550123"] } }),
"utf-8",
);
vi.resetModules();
const { readConfigFileSnapshot } = await import("./config.js");
const snap = await readConfigFileSnapshot();
expect(snap.valid).toBe(false);
expect(snap.legacyIssues.length).toBe(1);
expect(snap.legacyIssues[0]?.path).toBe("routing.allowFrom");
});
});
});

View File

@@ -58,6 +58,11 @@ export type WebConfig = {
reconnect?: WebReconnectConfig;
};
export type WhatsAppConfig = {
/** Optional allowlist for WhatsApp direct chats (E.164). */
allowFrom?: string[];
};
export type BrowserConfig = {
enabled?: boolean;
/** Base URL of the clawd browser control server. Default: http://127.0.0.1:18791 */
@@ -260,7 +265,6 @@ export type GroupChatConfig = {
};
export type RoutingConfig = {
allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:)
transcribeAudio?: {
// Optional CLI to turn inbound audio into text; templated args, must output transcript to stdout.
command: string[];
@@ -525,6 +529,7 @@ export type ClawdisConfig = {
messages?: MessagesConfig;
session?: SessionConfig;
web?: WebConfig;
whatsapp?: WhatsAppConfig;
telegram?: TelegramConfig;
discord?: DiscordConfig;
signal?: SignalConfig;
@@ -693,7 +698,6 @@ const HeartbeatSchema = z
const RoutingSchema = z
.object({
allowFrom: z.array(z.string()).optional(),
groupChat: GroupChatSchema,
transcribeAudio: TranscribeAudioSchema,
queue: z
@@ -909,6 +913,11 @@ const ClawdisSchema = z.object({
.optional(),
})
.optional(),
whatsapp: z
.object({
allowFrom: z.array(z.string()).optional(),
})
.optional(),
telegram: z
.object({
enabled: z.boolean().optional(),
@@ -1131,6 +1140,11 @@ export type ConfigValidationIssue = {
message: string;
};
export type LegacyConfigIssue = {
path: string;
message: string;
};
export type ConfigFileSnapshot = {
path: string;
exists: boolean;
@@ -1139,8 +1153,42 @@ export type ConfigFileSnapshot = {
valid: boolean;
config: ClawdisConfig;
issues: ConfigValidationIssue[];
legacyIssues: LegacyConfigIssue[];
};
type LegacyConfigRule = {
path: string[];
message: string;
};
const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
{
path: ["routing", "allowFrom"],
message:
"routing.allowFrom was removed; use whatsapp.allowFrom instead (run `clawdis doctor` to migrate).",
},
];
function findLegacyConfigIssues(raw: unknown): LegacyConfigIssue[] {
if (!raw || typeof raw !== "object") return [];
const root = raw as Record<string, unknown>;
const issues: LegacyConfigIssue[] = [];
for (const rule of LEGACY_CONFIG_RULES) {
let cursor: unknown = root;
for (const key of rule.path) {
if (!cursor || typeof cursor !== "object") {
cursor = undefined;
break;
}
cursor = (cursor as Record<string, unknown>)[key];
}
if (cursor !== undefined) {
issues.push({ path: rule.path.join("."), message: rule.message });
}
}
return issues;
}
function escapeRegExp(text: string): string {
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
@@ -1199,6 +1247,16 @@ export function validateConfigObject(
):
| { ok: true; config: ClawdisConfig }
| { ok: false; issues: ConfigValidationIssue[] } {
const legacyIssues = findLegacyConfigIssues(raw);
if (legacyIssues.length > 0) {
return {
ok: false,
issues: legacyIssues.map((iss) => ({
path: iss.path,
message: iss.message,
})),
};
}
const validated = ClawdisSchema.safeParse(raw);
if (!validated.success) {
return {
@@ -1271,6 +1329,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
const exists = fs.existsSync(configPath);
if (!exists) {
const config = applyTalkApiKey({});
const legacyIssues: LegacyConfigIssue[] = [];
return {
path: configPath,
exists: false,
@@ -1279,6 +1338,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
valid: true,
config,
issues: [],
legacyIssues,
};
}
@@ -1296,9 +1356,12 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
issues: [
{ path: "", message: `JSON5 parse failed: ${parsedRes.error}` },
],
legacyIssues: [],
};
}
const legacyIssues = findLegacyConfigIssues(parsedRes.parsed);
const validated = validateConfigObject(parsedRes.parsed);
if (!validated.ok) {
return {
@@ -1309,6 +1372,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
valid: false,
config: {},
issues: validated.issues,
legacyIssues,
};
}
@@ -1320,6 +1384,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
valid: true,
config: applyTalkApiKey(validated.config),
issues: [],
legacyIssues,
};
} catch (err) {
return {
@@ -1330,6 +1395,7 @@ export async function readConfigFileSnapshot(): Promise<ConfigFileSnapshot> {
valid: false,
config: {},
issues: [{ path: "", message: `read failed: ${String(err)}` }],
legacyIssues: [],
};
}
}

View File

@@ -103,7 +103,7 @@ function resolveDeliveryTarget(
const sanitizedWhatsappTo = (() => {
if (channel !== "whatsapp") return to;
const rawAllow = cfg.routing?.allowFrom ?? [];
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) return to;
const allowFrom = rawAllow
.map((val) => normalizeE164(val))

View File

@@ -163,6 +163,7 @@ vi.mock("../config/config.js", () => {
valid: true,
config: {},
issues: [],
legacyIssues: [],
};
}
try {
@@ -176,6 +177,7 @@ vi.mock("../config/config.js", () => {
valid: true,
config: parsed,
issues: [],
legacyIssues: [],
};
} catch (err) {
return {
@@ -186,6 +188,7 @@ vi.mock("../config/config.js", () => {
valid: false,
config: {},
issues: [{ path: "", message: `read failed: ${String(err)}` }],
legacyIssues: [],
};
}
};
@@ -206,7 +209,7 @@ vi.mock("../config/config.js", () => {
model: "anthropic/claude-opus-4-5",
workspace: path.join(os.tmpdir(), "clawd-gateway-test"),
},
routing: {
whatsapp: {
allowFrom: testAllowFrom,
},
session: { mainKey: "main", store: testSessionStorePath },

View File

@@ -6641,7 +6641,7 @@ export async function startGatewayServer(
if (explicit) return resolvedTo;
const cfg = cfgForAgent ?? loadConfig();
const rawAllow = cfg.routing?.allowFrom ?? [];
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) return resolvedTo;
const allowFrom = rawAllow
.map((val) => normalizeE164(val))

View File

@@ -61,8 +61,7 @@ function resolveRuntime(opts: MonitorIMessageOpts): RuntimeEnv {
function resolveAllowFrom(opts: MonitorIMessageOpts): string[] {
const cfg = loadConfig();
const raw =
opts.allowFrom ?? cfg.imessage?.allowFrom ?? cfg.routing?.allowFrom ?? [];
const raw = opts.allowFrom ?? cfg.imessage?.allowFrom ?? [];
return raw.map((entry) => String(entry).trim()).filter(Boolean);
}

View File

@@ -94,7 +94,7 @@ describe("resolveHeartbeatDeliveryTarget", () => {
it("applies allowFrom fallback for WhatsApp targets", () => {
const cfg: ClawdisConfig = {
agent: { heartbeat: { target: "whatsapp", to: "+1999" } },
routing: { allowFrom: ["+1555", "+1666"] },
whatsapp: { allowFrom: ["+1555", "+1666"] },
};
const entry = {
...baseEntry,
@@ -145,7 +145,7 @@ describe("runHeartbeatOnce", () => {
agent: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
},
routing: { allowFrom: ["*"] },
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
};
@@ -206,7 +206,7 @@ describe("runHeartbeatOnce", () => {
agent: {
heartbeat: { every: "5m", target: "whatsapp", to: "+1555" },
},
routing: { allowFrom: ["*"] },
whatsapp: { allowFrom: ["*"] },
session: { store: storePath },
};

View File

@@ -235,7 +235,7 @@ export function resolveHeartbeatDeliveryTarget(params: {
return { channel, to };
}
const rawAllow = cfg.routing?.allowFrom ?? [];
const rawAllow = cfg.whatsapp?.allowFrom ?? [];
if (rawAllow.includes("*")) return { channel, to };
const allowFrom = rawAllow
.map((val) => normalizeE164(val))
@@ -401,7 +401,7 @@ export async function runHeartbeatOnce(opts: {
const startedAt = opts.deps?.nowMs?.() ?? Date.now();
const { entry, sessionKey, storePath } = resolveHeartbeatSession(cfg);
const previousUpdatedAt = entry?.updatedAt;
const allowFrom = cfg.routing?.allowFrom ?? [];
const allowFrom = cfg.whatsapp?.allowFrom ?? [];
const sender = resolveHeartbeatSender({
allowFrom,
lastTo: entry?.lastTo,

View File

@@ -80,8 +80,8 @@ export async function buildProviderSummary(
);
}
const allowFrom = effective.routing?.allowFrom?.length
? effective.routing.allowFrom.map(normalizeE164).filter(Boolean)
const allowFrom = effective.whatsapp?.allowFrom?.length
? effective.whatsapp.allowFrom.map(normalizeE164).filter(Boolean)
: [];
if (allowFrom.length) {
lines.push(chalk.cyan(`AllowFrom: ${allowFrom.join(", ")}`));

View File

@@ -92,8 +92,7 @@ function resolveAccount(opts: MonitorSignalOpts): string | undefined {
function resolveAllowFrom(opts: MonitorSignalOpts): string[] {
const cfg = loadConfig();
const raw =
opts.allowFrom ?? cfg.signal?.allowFrom ?? cfg.routing?.allowFrom ?? [];
const raw = opts.allowFrom ?? cfg.signal?.allowFrom ?? [];
return raw.map((entry) => String(entry).trim()).filter(Boolean);
}

View File

@@ -33,7 +33,7 @@ export function normalizeE164(number: string): string {
/**
* "Self-chat mode" heuristic (single phone): the gateway is logged in as the owner's own WhatsApp account,
* and `routing.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the
* and `whatsapp.allowFrom` includes that same number. Used to avoid side-effects that make no sense when the
* "bot" and the human are the same WhatsApp identity (e.g. auto read receipts, @mention JID triggers).
*/
export function isSelfChatMode(

View File

@@ -111,7 +111,7 @@ describe("partial reply gating", () => {
const replyResolver = vi.fn().mockResolvedValue({ text: "final reply" });
const mockConfig: ClawdisConfig = {
routing: {
whatsapp: {
allowFrom: ["*"],
},
};
@@ -158,7 +158,7 @@ describe("partial reply gating", () => {
const replyResolver = vi.fn().mockResolvedValue(undefined);
const mockConfig: ClawdisConfig = {
routing: {
whatsapp: {
allowFrom: ["*"],
},
session: { store: store.storePath, mainKey: "main" },
@@ -1097,9 +1097,11 @@ describe("web auto-reply", () => {
const resolver = vi.fn().mockResolvedValue({ text: "ok" });
setLoadConfigMock(() => ({
routing: {
whatsapp: {
// Self-chat heuristic: allowFrom includes selfE164.
allowFrom: ["+999"],
},
routing: {
groupChat: {
requireMention: true,
mentionPatterns: ["\\bclawd\\b"],
@@ -1247,7 +1249,7 @@ describe("web auto-reply", () => {
it("prefixes body with same-phone marker when from === to", async () => {
// Enable messagePrefix for same-phone mode testing
setLoadConfigMock(() => ({
routing: {
whatsapp: {
allowFrom: ["*"],
},
messages: {
@@ -1372,7 +1374,7 @@ describe("web auto-reply", () => {
it("applies responsePrefix to regular replies", async () => {
setLoadConfigMock(() => ({
routing: {
whatsapp: {
allowFrom: ["*"],
},
messages: {
@@ -1417,7 +1419,7 @@ describe("web auto-reply", () => {
it("does not deliver HEARTBEAT_OK responses", async () => {
setLoadConfigMock(() => ({
routing: {
whatsapp: {
allowFrom: ["*"],
},
messages: {
@@ -1462,7 +1464,7 @@ describe("web auto-reply", () => {
it("does not double-prefix if responsePrefix already present", async () => {
setLoadConfigMock(() => ({
routing: {
whatsapp: {
allowFrom: ["*"],
},
messages: {
@@ -1508,7 +1510,7 @@ describe("web auto-reply", () => {
it("sends tool summaries immediately with responsePrefix", async () => {
setLoadConfigMock(() => ({
routing: {
whatsapp: {
allowFrom: ["*"],
},
messages: {

View File

@@ -116,7 +116,7 @@ function buildMentionConfig(cfg: ReturnType<typeof loadConfig>): MentionConfig {
}
})
.filter((r): r is RegExp => Boolean(r)) ?? [];
return { mentionRegexes, allowFrom: cfg.routing?.allowFrom };
return { mentionRegexes, allowFrom: cfg.whatsapp?.allowFrom };
}
function isBotMentioned(
@@ -448,8 +448,8 @@ export function resolveHeartbeatRecipients(
const sessionRecipients = getSessionRecipients(cfg);
const allowFrom =
Array.isArray(cfg.routing?.allowFrom) && cfg.routing.allowFrom.length > 0
? cfg.routing.allowFrom.filter((v) => v !== "*").map(normalizeE164)
Array.isArray(cfg.whatsapp?.allowFrom) && cfg.whatsapp.allowFrom.length > 0
? cfg.whatsapp.allowFrom.filter((v) => v !== "*").map(normalizeE164)
: [];
const unique = (list: string[]) => [...new Set(list.filter(Boolean))];
@@ -918,7 +918,7 @@ export async function monitorWebProvider(
// Build message prefix: explicit config > default based on allowFrom
let messagePrefix = cfg.messages?.messagePrefix;
if (messagePrefix === undefined) {
const hasAllowFrom = (cfg.routing?.allowFrom?.length ?? 0) > 0;
const hasAllowFrom = (cfg.whatsapp?.allowFrom?.length ?? 0) > 0;
messagePrefix = hasAllowFrom ? "" : "[clawdis]";
}
const prefixStr = messagePrefix ? `${messagePrefix} ` : "";

View File

@@ -7,7 +7,7 @@ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
vi.mock("../config/config.js", () => ({
loadConfig: vi.fn().mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["*"], // Allow all in tests
},
messages: {

View File

@@ -157,7 +157,7 @@ export async function monitorWebInbox(options: {
// Filter unauthorized senders early to prevent wasted processing
// and potential session corruption from Bad MAC errors
const cfg = loadConfig();
const configuredAllowFrom = cfg.routing?.allowFrom;
const configuredAllowFrom = cfg.whatsapp?.allowFrom;
// Without user config, default to self-only DM access so the owner can talk to themselves
const defaultAllowFrom =
(!configuredAllowFrom || configuredAllowFrom.length === 0) && selfE164

View File

@@ -10,7 +10,7 @@ vi.mock("../media/store.js", () => ({
}));
const mockLoadConfig = vi.fn().mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["*"], // Allow all in tests by default
},
messages: {
@@ -450,7 +450,7 @@ describe("web monitor inbox", () => {
it("still forwards group messages (with sender info) even when allowFrom is restrictive", async () => {
mockLoadConfig.mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["+111"], // does not include +777
},
messages: {
@@ -506,7 +506,7 @@ describe("web monitor inbox", () => {
// Test for auto-recovery fix: early allowFrom filtering prevents Bad MAC errors
// from unauthorized senders corrupting sessions
mockLoadConfig.mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["+111"], // Only allow +111
},
messages: {
@@ -546,7 +546,7 @@ describe("web monitor inbox", () => {
// Reset mock for other tests
mockLoadConfig.mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["*"],
},
messages: {
@@ -561,7 +561,7 @@ describe("web monitor inbox", () => {
it("skips read receipts in self-chat mode", async () => {
mockLoadConfig.mockReturnValue({
routing: {
whatsapp: {
// Self-chat heuristic: allowFrom includes selfE164 (+123).
allowFrom: ["+123"],
},
@@ -598,7 +598,7 @@ describe("web monitor inbox", () => {
// Reset mock for other tests
mockLoadConfig.mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["*"],
},
messages: {
@@ -613,7 +613,7 @@ describe("web monitor inbox", () => {
it("lets group messages through even when sender not in allowFrom", async () => {
mockLoadConfig.mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["+1234"],
},
messages: {
@@ -655,7 +655,7 @@ describe("web monitor inbox", () => {
it("allows messages from senders in allowFrom list", async () => {
mockLoadConfig.mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["+111", "+999"], // Allow +999
},
messages: {
@@ -690,7 +690,7 @@ describe("web monitor inbox", () => {
// Reset mock for other tests
mockLoadConfig.mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["*"],
},
messages: {
@@ -707,7 +707,7 @@ describe("web monitor inbox", () => {
// Same-phone mode: when from === selfJid, should always be allowed
// This allows users to message themselves even with restrictive allowFrom
mockLoadConfig.mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["+111"], // Only allow +111, but self is +123
},
messages: {
@@ -810,7 +810,7 @@ it("defaults to self-only when no config is present", async () => {
// Reset mock for other tests
mockLoadConfig.mockReturnValue({
routing: {
whatsapp: {
allowFrom: ["*"],
},
messages: {

View File

@@ -6,7 +6,7 @@ import { createMockBaileys } from "../../test/mocks/baileys.js";
// Use globalThis to store the mock config so it survives vi.mock hoisting
const CONFIG_KEY = Symbol.for("clawdis:testConfigMock");
const DEFAULT_CONFIG = {
routing: {
whatsapp: {
// Tests can override; default remains open to avoid surprising fixtures
allowFrom: ["*"],
},