fix: harden pairing flow

This commit is contained in:
Peter Steinberger
2026-01-07 05:06:04 +01:00
parent 6ffece68b0
commit 42ae2341aa
22 changed files with 679 additions and 265 deletions

View File

@@ -17,6 +17,8 @@
- Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior. - Groups: `whatsapp.groups`, `telegram.groups`, and `imessage.groups` now act as allowlists when set. Add `"*"` to keep allow-all behavior.
### Fixes ### Fixes
- Pairing: generate DM pairing codes with CSPRNG, expire pending codes after 1 hour, and avoid re-sending codes for already pending requests.
- Pairing: lock + atomically write pairing stores with 0600 perms and stop logging pairing codes in provider logs.
- Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353. - Tools: add Telegram/WhatsApp reaction tools (with per-provider gating). Thanks @zats for PR #353.
- Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts. - Tools: unify reaction removal semantics across Discord/Slack/Telegram/WhatsApp and allow WhatsApp reaction routing across accounts.
- Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect. - Gateway/CLI: add daemon runtime selection (Node recommended; Bun optional) and document WhatsApp/Baileys Bun WebSocket instability on reconnect.

View File

@@ -195,6 +195,8 @@ Controls how WhatsApp direct chats (DMs) are handled:
- `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`) - `"open"`: allow all inbound DMs (**requires** `whatsapp.allowFrom` to include `"*"`)
- `"disabled"`: ignore all inbound DMs - `"disabled"`: ignore all inbound DMs
Pairing codes expire after 1 hour; the bot only sends a pairing code when a new request is created.
Pairing approvals: Pairing approvals:
- `clawdbot pairing list --provider whatsapp` - `clawdbot pairing list --provider whatsapp`
- `clawdbot pairing approve --provider whatsapp <code>` - `clawdbot pairing approve --provider whatsapp <code>`

View File

@@ -38,7 +38,7 @@ Clawdbots stance:
All current DM-capable providers support a DM policy (`dmPolicy` or `*.dm.policy`) that gates inbound DMs **before** the message is processed: All current DM-capable providers support a DM policy (`dmPolicy` or `*.dm.policy`) that gates inbound DMs **before** the message is processed:
- `pairing` (default): unknown senders receive a short pairing code and the bot ignores their message until approved. - `pairing` (default): unknown senders receive a short pairing code and the bot ignores their message until approved. Codes expire after 1 hour; repeated DMs wont resend a code until a new request is created.
- `allowlist`: unknown senders are blocked (no pairing handshake). - `allowlist`: unknown senders are blocked (no pairing handshake).
- `open`: allow anyone to DM (public). **Requires** the provider allowlist to include `"*"` (explicit opt-in). - `open`: allow anyone to DM (public). **Requires** the provider allowlist to include `"*"` (explicit opt-in).
- `disabled`: ignore inbound DMs entirely. - `disabled`: ignore inbound DMs entirely.

View File

@@ -23,7 +23,7 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa
- If you prefer env vars, still add `discord: { enabled: true }` to `~/.clawdbot/clawdbot.json` and set `DISCORD_BOT_TOKEN`. - If you prefer env vars, still add `discord: { enabled: true }` to `~/.clawdbot/clawdbot.json` and set `DISCORD_BOT_TOKEN`.
5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session. 5. Direct chats: use `user:<id>` (or a `<@id>` mention) when delivering; all turns land in the shared `main` session.
6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel. 6. Guild channels: use `channel:<channelId>` for delivery. Mentions are required by default and can be set per guild or per channel.
7. Direct chats: secure by default via `discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code; approve via `clawdbot pairing approve --provider discord <code>`. 7. Direct chats: secure by default via `discord.dm.policy` (default: `"pairing"`). Unknown senders get a pairing code (expires after 1 hour); approve via `clawdbot pairing approve --provider discord <code>`.
- To keep old “open to anyone” behavior: set `discord.dm.policy="open"` and `discord.dm.allowFrom=["*"]`. - To keep old “open to anyone” behavior: set `discord.dm.policy="open"` and `discord.dm.allowFrom=["*"]`.
- To hard-allowlist: set `discord.dm.policy="allowlist"` and list senders in `discord.dm.allowFrom`. - To hard-allowlist: set `discord.dm.policy="allowlist"` and list senders in `discord.dm.allowFrom`.
- To ignore all DMs: set `discord.dm.enabled=false` or `discord.dm.policy="disabled"`. - To ignore all DMs: set `discord.dm.enabled=false` or `discord.dm.policy="disabled"`.

View File

@@ -39,7 +39,7 @@ Example:
## Access control (DMs + groups) ## Access control (DMs + groups)
DMs: DMs:
- Default: `imessage.dmPolicy = "pairing"`. - Default: `imessage.dmPolicy = "pairing"`.
- Unknown senders receive a pairing code; messages are ignored until approved. - Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
- Approve via: - Approve via:
- `clawdbot pairing list --provider imessage` - `clawdbot pairing list --provider imessage`
- `clawdbot pairing approve --provider imessage <CODE>` - `clawdbot pairing approve --provider imessage <CODE>`

View File

@@ -42,7 +42,7 @@ Example:
## Access control (DMs + groups) ## Access control (DMs + groups)
DMs: DMs:
- Default: `signal.dmPolicy = "pairing"`. - Default: `signal.dmPolicy = "pairing"`.
- Unknown senders receive a pairing code; messages are ignored until approved. - Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
- Approve via: - Approve via:
- `clawdbot pairing list --provider signal` - `clawdbot pairing list --provider signal`
- `clawdbot pairing approve --provider signal <CODE>` - `clawdbot pairing approve --provider signal <CODE>`

View File

@@ -195,7 +195,7 @@ Ack reactions are controlled globally via `messages.ackReaction` +
- Full command list + config: [Slash commands](/tools/slash-commands) - Full command list + config: [Slash commands](/tools/slash-commands)
## DM security (pairing) ## DM security (pairing)
- Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code. - Default: `slack.dm.policy="pairing"` — unknown DM senders get a pairing code (expires after 1 hour).
- Approve via: `clawdbot pairing approve --provider slack <code>`. - Approve via: `clawdbot pairing approve --provider slack <code>`.
- To allow anyone: set `slack.dm.policy="open"` and `slack.dm.allowFrom=["*"]`. - To allow anyone: set `slack.dm.policy="open"` and `slack.dm.allowFrom=["*"]`.

View File

@@ -45,7 +45,7 @@ Telegram forum topics include a `message_thread_id` per message. Clawdbot:
- Exposes `MessageThreadId` + `IsForum` in template context for routing/templating. - Exposes `MessageThreadId` + `IsForum` in template context for routing/templating.
## Access control (DMs + groups) ## Access control (DMs + groups)
- Default: `telegram.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved. - Default: `telegram.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
- Approve via: - Approve via:
- `clawdbot pairing list --provider telegram` - `clawdbot pairing list --provider telegram`
- `clawdbot pairing approve --provider telegram <CODE>` - `clawdbot pairing approve --provider telegram <CODE>`

View File

@@ -51,7 +51,7 @@ WhatsApp requires a real mobile number for verification. VoIP and virtual number
- Status/broadcast chats are ignored. - Status/broadcast chats are ignored.
- Direct chats use E.164; groups use group JID. - Direct chats use E.164; groups use group JID.
- **DM policy**: `whatsapp.dmPolicy` controls direct chat access (default: `pairing`). - **DM policy**: `whatsapp.dmPolicy` controls direct chat access (default: `pairing`).
- Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp <code>`). - Pairing: unknown senders get a pairing code (approve via `clawdbot pairing approve --provider whatsapp <code>`; codes expire after 1 hour).
- Open: requires `whatsapp.allowFrom` to include `"*"`. - Open: requires `whatsapp.allowFrom` to include `"*"`.
- Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number. - Self messages are always allowed; “self-chat mode” still requires `whatsapp.allowFrom` to include your own number.
- **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`). - **Group policy**: `whatsapp.groupPolicy` controls group handling (`open|disabled|allowlist`).

View File

@@ -22,6 +22,10 @@ When a provider is configured with DM policy `pairing`, unknown senders get a sh
Default DM policies are documented in: [Security](/gateway/security) Default DM policies are documented in: [Security](/gateway/security)
Pairing codes:
- 8 characters, uppercase, no ambiguous chars (`0O1I`).
- **Expire after 1 hour**. The bot only sends the pairing message when a new request is created (roughly once per hour per sender).
### Approve a sender ### Approve a sender
```bash ```bash

View File

@@ -412,7 +412,7 @@ export function createDiscordMessageHandler(params: {
if (!permitted) { if (!permitted) {
commandAuthorized = false; commandAuthorized = false;
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const { code } = await upsertProviderPairingRequest({ const { code, created } = await upsertProviderPairingRequest({
provider: "discord", provider: "discord",
id: author.id, id: author.id,
meta: { meta: {
@@ -420,8 +420,9 @@ export function createDiscordMessageHandler(params: {
name: author.username ?? undefined, name: author.username ?? undefined,
}, },
}); });
if (created) {
logVerbose( logVerbose(
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} code=${code}`, `discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)}`,
); );
try { try {
await sendMessageDiscord( await sendMessageDiscord(
@@ -441,6 +442,7 @@ export function createDiscordMessageHandler(params: {
`discord pairing reply failed for ${author.id}: ${String(err)}`, `discord pairing reply failed for ${author.id}: ${String(err)}`,
); );
} }
}
} else { } else {
logVerbose( logVerbose(
`Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy})`, `Blocked unauthorized discord sender ${author.id} (dmPolicy=${dmPolicy})`,
@@ -1107,7 +1109,7 @@ function createDiscordNativeCommand(params: {
if (!permitted) { if (!permitted) {
commandAuthorized = false; commandAuthorized = false;
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const { code } = await upsertProviderPairingRequest({ const { code, created } = await upsertProviderPairingRequest({
provider: "discord", provider: "discord",
id: user.id, id: user.id,
meta: { meta: {
@@ -1115,6 +1117,7 @@ function createDiscordNativeCommand(params: {
name: user.username ?? undefined, name: user.username ?? undefined,
}, },
}); });
if (created) {
await interaction.reply({ await interaction.reply({
content: [ content: [
"Clawdbot: access not configured.", "Clawdbot: access not configured.",
@@ -1126,6 +1129,7 @@ function createDiscordNativeCommand(params: {
].join("\n"), ].join("\n"),
ephemeral: true, ephemeral: true,
}); });
}
} else { } else {
await interaction.reply({ await interaction.reply({
content: "You are not authorized to use this command.", content: "You are not authorized to use this command.",

View File

@@ -230,7 +230,7 @@ export async function monitorIMessageProvider(
if (!dmAuthorized) { if (!dmAuthorized) {
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const senderId = normalizeIMessageHandle(sender); const senderId = normalizeIMessageHandle(sender);
const { code } = await upsertProviderPairingRequest({ const { code, created } = await upsertProviderPairingRequest({
provider: "imessage", provider: "imessage",
id: senderId, id: senderId,
meta: { meta: {
@@ -238,9 +238,8 @@ export async function monitorIMessageProvider(
chatId: chatId ? String(chatId) : undefined, chatId: chatId ? String(chatId) : undefined,
}, },
}); });
logVerbose( if (created) {
`imessage pairing request sender=${senderId} code=${code}`, logVerbose(`imessage pairing request sender=${senderId}`);
);
try { try {
await sendMessageIMessage( await sendMessageIMessage(
sender, sender,
@@ -263,6 +262,7 @@ export async function monitorIMessageProvider(
`imessage pairing reply failed for ${senderId}: ${String(err)}`, `imessage pairing reply failed for ${senderId}: ${String(err)}`,
); );
} }
}
} else { } else {
logVerbose( logVerbose(
`Blocked iMessage sender ${sender} (dmPolicy=${dmPolicy})`, `Blocked iMessage sender ${sender} (dmPolicy=${dmPolicy})`,

View File

@@ -0,0 +1,109 @@
import crypto from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { resolveOAuthDir } from "../config/paths.js";
import {
listProviderPairingRequests,
upsertProviderPairingRequest,
} from "./pairing-store.js";
async function withTempStateDir<T>(fn: (stateDir: string) => Promise<T>) {
const previous = process.env.CLAWDBOT_STATE_DIR;
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-pairing-"));
process.env.CLAWDBOT_STATE_DIR = dir;
try {
return await fn(dir);
} finally {
if (previous === undefined) delete process.env.CLAWDBOT_STATE_DIR;
else process.env.CLAWDBOT_STATE_DIR = previous;
await fs.rm(dir, { recursive: true, force: true });
}
}
describe("pairing store", () => {
it("reuses pending code and reports created=false", async () => {
await withTempStateDir(async () => {
const first = await upsertProviderPairingRequest({
provider: "discord",
id: "u1",
});
const second = await upsertProviderPairingRequest({
provider: "discord",
id: "u1",
});
expect(first.created).toBe(true);
expect(second.created).toBe(false);
expect(second.code).toBe(first.code);
const list = await listProviderPairingRequests("discord");
expect(list).toHaveLength(1);
expect(list[0]?.code).toBe(first.code);
});
});
it("expires pending requests after TTL", async () => {
await withTempStateDir(async (stateDir) => {
const created = await upsertProviderPairingRequest({
provider: "signal",
id: "+15550001111",
});
expect(created.created).toBe(true);
const oauthDir = resolveOAuthDir(process.env, stateDir);
const filePath = path.join(oauthDir, "signal-pairing.json");
const raw = await fs.readFile(filePath, "utf8");
const parsed = JSON.parse(raw) as {
requests?: Array<Record<string, unknown>>;
};
const expiredAt = new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString();
const requests = (parsed.requests ?? []).map((entry) => ({
...entry,
createdAt: expiredAt,
lastSeenAt: expiredAt,
}));
await fs.writeFile(
filePath,
`${JSON.stringify({ version: 1, requests }, null, 2)}\n`,
"utf8",
);
const list = await listProviderPairingRequests("signal");
expect(list).toHaveLength(0);
const next = await upsertProviderPairingRequest({
provider: "signal",
id: "+15550001111",
});
expect(next.created).toBe(true);
});
});
it("regenerates when a generated code collides", async () => {
await withTempStateDir(async () => {
const spy = vi.spyOn(crypto, "randomInt");
try {
spy.mockReturnValue(0);
const first = await upsertProviderPairingRequest({
provider: "telegram",
id: "123",
});
expect(first.code).toBe("AAAAAAAA");
const sequence = Array(8).fill(0).concat(Array(8).fill(1));
let idx = 0;
spy.mockImplementation(() => sequence[idx++] ?? 1);
const second = await upsertProviderPairingRequest({
provider: "telegram",
id: "456",
});
expect(second.code).toBe("BBBBBBBB");
} finally {
spy.mockRestore();
}
});
});
});

View File

@@ -1,9 +1,26 @@
import crypto from "node:crypto";
import fs from "node:fs"; import fs from "node:fs";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import lockfile from "proper-lockfile";
import { resolveOAuthDir, resolveStateDir } from "../config/paths.js"; import { resolveOAuthDir, resolveStateDir } from "../config/paths.js";
const PAIRING_CODE_LENGTH = 8;
const PAIRING_CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
const PAIRING_PENDING_TTL_MS = 60 * 60 * 1000;
const PAIRING_STORE_LOCK_OPTIONS = {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 10_000,
randomize: true,
},
stale: 30_000,
} as const;
export type PairingProvider = export type PairingProvider =
| "telegram" | "telegram"
| "signal" | "signal"
@@ -74,24 +91,92 @@ async function readJsonFile<T>(
} }
async function writeJsonFile(filePath: string, value: unknown): Promise<void> { async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); const dir = path.dirname(filePath);
await fs.promises.writeFile( await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
filePath, const tmp = path.join(
`${JSON.stringify(value, null, 2)}\n`, dir,
"utf-8", `${path.basename(filePath)}.${crypto.randomUUID()}.tmp`,
); );
await fs.promises.writeFile(tmp, `${JSON.stringify(value, null, 2)}\n`, {
encoding: "utf-8",
});
await fs.promises.chmod(tmp, 0o600);
await fs.promises.rename(tmp, filePath);
}
async function ensureJsonFile(filePath: string, fallback: unknown) {
try {
await fs.promises.access(filePath);
} catch {
await writeJsonFile(filePath, fallback);
}
}
async function withFileLock<T>(
filePath: string,
fallback: unknown,
fn: () => Promise<T>,
): Promise<T> {
await ensureJsonFile(filePath, fallback);
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(filePath, PAIRING_STORE_LOCK_OPTIONS);
return await fn();
} finally {
if (release) {
try {
await release();
} catch {
// ignore unlock errors
}
}
}
}
function parseTimestamp(value: string | undefined): number | null {
if (!value) return null;
const parsed = Date.parse(value);
if (!Number.isFinite(parsed)) return null;
return parsed;
}
function isExpired(entry: PairingRequest, nowMs: number): boolean {
const createdAt = parseTimestamp(entry.createdAt);
if (!createdAt) return true;
return nowMs - createdAt > PAIRING_PENDING_TTL_MS;
}
function pruneExpiredRequests(reqs: PairingRequest[], nowMs: number) {
const kept: PairingRequest[] = [];
let removed = false;
for (const req of reqs) {
if (isExpired(req, nowMs)) {
removed = true;
continue;
}
kept.push(req);
}
return { requests: kept, removed };
} }
function randomCode(): string { function randomCode(): string {
// Human-friendly: 8 chars, upper, no ambiguous chars (0O1I). // Human-friendly: 8 chars, upper, no ambiguous chars (0O1I).
const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
let out = ""; let out = "";
for (let i = 0; i < 8; i++) { for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
out += alphabet[Math.floor(Math.random() * alphabet.length)]; const idx = crypto.randomInt(0, PAIRING_CODE_ALPHABET.length);
out += PAIRING_CODE_ALPHABET[idx];
} }
return out; return out;
} }
function generateUniqueCode(existing: Set<string>): string {
for (let attempt = 0; attempt < 500; attempt += 1) {
const code = randomCode();
if (!existing.has(code)) return code;
}
throw new Error("failed to generate unique pairing code");
}
function normalizeId(value: string | number): string { function normalizeId(value: string | number): string {
return String(value).trim(); return String(value).trim();
} }
@@ -129,6 +214,10 @@ export async function addProviderAllowFromStoreEntry(params: {
}): Promise<{ changed: boolean; allowFrom: string[] }> { }): Promise<{ changed: boolean; allowFrom: string[] }> {
const env = params.env ?? process.env; const env = params.env ?? process.env;
const filePath = resolveAllowFromPath(params.provider, env); const filePath = resolveAllowFromPath(params.provider, env);
return await withFileLock(
filePath,
{ version: 1, allowFrom: [] } satisfies AllowFromStore,
async () => {
const { value } = await readJsonFile<AllowFromStore>(filePath, { const { value } = await readJsonFile<AllowFromStore>(filePath, {
version: 1, version: 1,
allowFrom: [], allowFrom: [],
@@ -149,6 +238,8 @@ export async function addProviderAllowFromStoreEntry(params: {
allowFrom: next, allowFrom: next,
} satisfies AllowFromStore); } satisfies AllowFromStore);
return { changed: true, allowFrom: next }; return { changed: true, allowFrom: next };
},
);
} }
export async function listProviderPairingRequests( export async function listProviderPairingRequests(
@@ -156,12 +247,24 @@ export async function listProviderPairingRequests(
env: NodeJS.ProcessEnv = process.env, env: NodeJS.ProcessEnv = process.env,
): Promise<PairingRequest[]> { ): Promise<PairingRequest[]> {
const filePath = resolvePairingPath(provider, env); const filePath = resolvePairingPath(provider, env);
return await withFileLock(
filePath,
{ version: 1, requests: [] } satisfies PairingStore,
async () => {
const { value } = await readJsonFile<PairingStore>(filePath, { const { value } = await readJsonFile<PairingStore>(filePath, {
version: 1, version: 1,
requests: [], requests: [],
}); });
const reqs = Array.isArray(value.requests) ? value.requests : []; const reqs = Array.isArray(value.requests) ? value.requests : [];
return reqs const nowMs = Date.now();
const { requests: pruned, removed } = pruneExpiredRequests(reqs, nowMs);
if (removed) {
await writeJsonFile(filePath, {
version: 1,
requests: pruned,
} satisfies PairingStore);
}
return pruned
.filter( .filter(
(r) => (r) =>
r && r &&
@@ -171,6 +274,8 @@ export async function listProviderPairingRequests(
) )
.slice() .slice()
.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); .sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
);
} }
export async function upsertProviderPairingRequest(params: { export async function upsertProviderPairingRequest(params: {
@@ -181,11 +286,16 @@ export async function upsertProviderPairingRequest(params: {
}): Promise<{ code: string; created: boolean }> { }): Promise<{ code: string; created: boolean }> {
const env = params.env ?? process.env; const env = params.env ?? process.env;
const filePath = resolvePairingPath(params.provider, env); const filePath = resolvePairingPath(params.provider, env);
return await withFileLock(
filePath,
{ version: 1, requests: [] } satisfies PairingStore,
async () => {
const { value } = await readJsonFile<PairingStore>(filePath, { const { value } = await readJsonFile<PairingStore>(filePath, {
version: 1, version: 1,
requests: [], requests: [],
}); });
const now = new Date().toISOString(); const now = new Date().toISOString();
const nowMs = Date.now();
const id = normalizeId(params.id); const id = normalizeId(params.id);
const meta = const meta =
params.meta && typeof params.meta === "object" params.meta && typeof params.meta === "object"
@@ -196,13 +306,25 @@ export async function upsertProviderPairingRequest(params: {
) )
: undefined; : undefined;
const reqs = Array.isArray(value.requests) ? value.requests : []; let reqs = Array.isArray(value.requests) ? value.requests : [];
const { requests: pruned } = pruneExpiredRequests(reqs, nowMs);
reqs = pruned;
const existingIdx = reqs.findIndex((r) => r.id === id); const existingIdx = reqs.findIndex((r) => r.id === id);
const existingCodes = new Set(
reqs.map((req) =>
String(req.code ?? "")
.trim()
.toUpperCase(),
),
);
if (existingIdx >= 0) { if (existingIdx >= 0) {
const existing = reqs[existingIdx]; const existing = reqs[existingIdx];
const existingCode = const existingCode =
existing && typeof existing.code === "string" ? existing.code.trim() : ""; existing && typeof existing.code === "string"
const code = existingCode || randomCode(); ? existing.code.trim()
: "";
const code = existingCode || generateUniqueCode(existingCodes);
const next: PairingRequest = { const next: PairingRequest = {
id, id,
code, code,
@@ -218,7 +340,7 @@ export async function upsertProviderPairingRequest(params: {
return { code, created: false }; return { code, created: false };
} }
const code = randomCode(); const code = generateUniqueCode(existingCodes);
const next: PairingRequest = { const next: PairingRequest = {
id, id,
code, code,
@@ -231,6 +353,8 @@ export async function upsertProviderPairingRequest(params: {
requests: [...reqs, next], requests: [...reqs, next],
} satisfies PairingStore); } satisfies PairingStore);
return { code, created: true }; return { code, created: true };
},
);
} }
export async function approveProviderPairingCode(params: { export async function approveProviderPairingCode(params: {
@@ -243,21 +367,35 @@ export async function approveProviderPairingCode(params: {
if (!code) return null; if (!code) return null;
const filePath = resolvePairingPath(params.provider, env); const filePath = resolvePairingPath(params.provider, env);
return await withFileLock(
filePath,
{ version: 1, requests: [] } satisfies PairingStore,
async () => {
const { value } = await readJsonFile<PairingStore>(filePath, { const { value } = await readJsonFile<PairingStore>(filePath, {
version: 1, version: 1,
requests: [], requests: [],
}); });
const reqs = Array.isArray(value.requests) ? value.requests : []; const reqs = Array.isArray(value.requests) ? value.requests : [];
const idx = reqs.findIndex( const nowMs = Date.now();
const { requests: pruned, removed } = pruneExpiredRequests(reqs, nowMs);
const idx = pruned.findIndex(
(r) => String(r.code ?? "").toUpperCase() === code, (r) => String(r.code ?? "").toUpperCase() === code,
); );
if (idx < 0) return null; if (idx < 0) {
const entry = reqs[idx]; if (removed) {
if (!entry) return null;
reqs.splice(idx, 1);
await writeJsonFile(filePath, { await writeJsonFile(filePath, {
version: 1, version: 1,
requests: reqs, requests: pruned,
} satisfies PairingStore);
}
return null;
}
const entry = pruned[idx];
if (!entry) return null;
pruned.splice(idx, 1);
await writeJsonFile(filePath, {
version: 1,
requests: pruned,
} satisfies PairingStore); } satisfies PairingStore);
await addProviderAllowFromStoreEntry({ await addProviderAllowFromStoreEntry({
provider: params.provider, provider: params.provider,
@@ -265,4 +403,6 @@ export async function approveProviderPairingCode(params: {
env, env,
}); });
return { id: entry.id, entry }; return { id: entry.id, entry };
},
);
} }

View File

@@ -144,4 +144,47 @@ describe("monitorSignalProvider tool results", () => {
"Pairing code: PAIRCODE", "Pairing code: PAIRCODE",
); );
}); });
it("does not resend pairing code when a request is already pending", async () => {
config = {
...config,
signal: { autoStart: false, dmPolicy: "pairing", allowFrom: [] },
};
upsertPairingRequestMock
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
streamMock.mockImplementation(async ({ onEvent }) => {
const payload = {
envelope: {
sourceNumber: "+15550001111",
sourceName: "Ada",
timestamp: 1,
dataMessage: {
message: "hello",
},
},
};
await onEvent({
event: "receive",
data: JSON.stringify(payload),
});
await onEvent({
event: "receive",
data: JSON.stringify({
...payload,
envelope: { ...payload.envelope, timestamp: 2 },
}),
});
});
await monitorSignalProvider({
autoStart: false,
baseUrl: "http://127.0.0.1:8080",
});
await flush();
expect(sendMock).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -336,16 +336,15 @@ export async function monitorSignalProvider(
if (!dmAllowed) { if (!dmAllowed) {
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const senderId = normalizeE164(sender); const senderId = normalizeE164(sender);
const { code } = await upsertProviderPairingRequest({ const { code, created } = await upsertProviderPairingRequest({
provider: "signal", provider: "signal",
id: senderId, id: senderId,
meta: { meta: {
name: envelope.sourceName ?? undefined, name: envelope.sourceName ?? undefined,
}, },
}); });
logVerbose( if (created) {
`signal pairing request sender=${senderId} code=${code}`, logVerbose(`signal pairing request sender=${senderId}`);
);
try { try {
await sendMessageSignal( await sendMessageSignal(
senderId, senderId,
@@ -364,6 +363,7 @@ export async function monitorSignalProvider(
`signal pairing reply failed for ${senderId}: ${String(err)}`, `signal pairing reply failed for ${senderId}: ${String(err)}`,
); );
} }
}
} else { } else {
logVerbose( logVerbose(
`Blocked signal sender ${sender} (dmPolicy=${dmPolicy})`, `Blocked signal sender ${sender} (dmPolicy=${dmPolicy})`,

View File

@@ -399,4 +399,43 @@ describe("monitorSlackProvider tool results", () => {
"Pairing code: PAIRCODE", "Pairing code: PAIRCODE",
); );
}); });
it("does not resend pairing code when a request is already pending", async () => {
config = {
...config,
slack: { dm: { enabled: true, policy: "pairing", allowFrom: [] } },
};
upsertPairingRequestMock
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
const controller = new AbortController();
const run = monitorSlackProvider({
botToken: "bot-token",
appToken: "app-token",
abortSignal: controller.signal,
});
await waitForEvent("message");
const handler = getSlackHandlers()?.get("message");
if (!handler) throw new Error("Slack message handler not registered");
const baseEvent = {
type: "message",
user: "U1",
text: "hello",
ts: "123",
channel: "C1",
channel_type: "im",
};
await handler({ event: baseEvent });
await handler({ event: { ...baseEvent, ts: "124", text: "hello again" } });
await flush();
controller.abort();
await run;
expect(sendMock).toHaveBeenCalledTimes(1);
});
}); });

View File

@@ -653,13 +653,14 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const sender = await resolveUserName(message.user); const sender = await resolveUserName(message.user);
const senderName = sender?.name ?? undefined; const senderName = sender?.name ?? undefined;
const { code } = await upsertProviderPairingRequest({ const { code, created } = await upsertProviderPairingRequest({
provider: "slack", provider: "slack",
id: message.user, id: message.user,
meta: { name: senderName }, meta: { name: senderName },
}); });
if (created) {
logVerbose( logVerbose(
`slack pairing request sender=${message.user} name=${senderName ?? "unknown"} code=${code}`, `slack pairing request sender=${message.user} name=${senderName ?? "unknown"}`,
); );
try { try {
await sendMessageSlack( await sendMessageSlack(
@@ -679,6 +680,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
`slack pairing reply failed for ${message.user}: ${String(err)}`, `slack pairing reply failed for ${message.user}: ${String(err)}`,
); );
} }
}
} else { } else {
logVerbose( logVerbose(
`Blocked unauthorized slack sender ${message.user} (dmPolicy=${dmPolicy})`, `Blocked unauthorized slack sender ${message.user} (dmPolicy=${dmPolicy})`,
@@ -1468,11 +1470,12 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
}); });
if (!permitted) { if (!permitted) {
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const { code } = await upsertProviderPairingRequest({ const { code, created } = await upsertProviderPairingRequest({
provider: "slack", provider: "slack",
id: command.user_id, id: command.user_id,
meta: { name: senderName }, meta: { name: senderName },
}); });
if (created) {
await respond({ await respond({
text: [ text: [
"Clawdbot: access not configured.", "Clawdbot: access not configured.",
@@ -1484,6 +1487,7 @@ export async function monitorSlackProvider(opts: MonitorSlackOpts = {}) {
].join("\n"), ].join("\n"),
response_type: "ephemeral", response_type: "ephemeral",
}); });
}
} else { } else {
await respond({ await respond({
text: "You are not authorized to use this command.", text: "You are not authorized to use this command.",

View File

@@ -193,6 +193,47 @@ describe("createTelegramBot", () => {
expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("PAIRME12"); expect(String(sendMessageSpy.mock.calls[0]?.[1])).toContain("PAIRME12");
}); });
it("does not resend pairing code when a request is already pending", async () => {
onSpy.mockReset();
sendMessageSpy.mockReset();
const replySpy = replyModule.__replySpy as unknown as ReturnType<
typeof vi.fn
>;
replySpy.mockReset();
loadConfig.mockReturnValue({ telegram: { dmPolicy: "pairing" } });
readTelegramAllowFromStore.mockResolvedValue([]);
upsertTelegramPairingRequest
.mockResolvedValueOnce({ code: "PAIRME12", created: true })
.mockResolvedValueOnce({ code: "PAIRME12", created: false });
createTelegramBot({ token: "tok" });
const handler = onSpy.mock.calls[0][1] as (
ctx: Record<string, unknown>,
) => Promise<void>;
const message = {
chat: { id: 1234, type: "private" },
text: "hello",
date: 1736380800,
from: { id: 999, username: "random" },
};
await handler({
message,
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
await handler({
message: { ...message, text: "hello again" },
me: { username: "clawdbot_bot" },
getFile: async () => ({ download: async () => new Uint8Array() }),
});
expect(replySpy).not.toHaveBeenCalled();
expect(sendMessageSpy).toHaveBeenCalledTimes(1);
});
it("triggers typing cue via onReplyStart", async () => { it("triggers typing cue via onReplyStart", async () => {
onSpy.mockReset(); onSpy.mockReset();
sendChatActionSpy.mockReset(); sendChatActionSpy.mockReset();

View File

@@ -247,19 +247,19 @@ export function createTelegramBot(opts: TelegramBotOptions) {
username?: string; username?: string;
} }
| undefined; | undefined;
const { code } = await upsertTelegramPairingRequest({ const { code, created } = await upsertTelegramPairingRequest({
chatId: candidate, chatId: candidate,
username: from?.username, username: from?.username,
firstName: from?.first_name, firstName: from?.first_name,
lastName: from?.last_name, lastName: from?.last_name,
}); });
if (created) {
logger.info( logger.info(
{ {
chatId: candidate, chatId: candidate,
username: from?.username, username: from?.username,
firstName: from?.first_name, firstName: from?.first_name,
lastName: from?.last_name, lastName: from?.last_name,
code,
}, },
"telegram pairing request", "telegram pairing request",
); );
@@ -274,6 +274,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
"clawdbot telegram pairing approve <code>", "clawdbot telegram pairing approve <code>",
].join("\n"), ].join("\n"),
); );
}
} catch (err) { } catch (err) {
logVerbose( logVerbose(
`telegram pairing reply failed for chat ${chatId}: ${String(err)}`, `telegram pairing reply failed for chat ${chatId}: ${String(err)}`,

View File

@@ -258,15 +258,16 @@ export async function monitorWebInbox(options: {
normalizedAllowFrom.includes(candidate)); normalizedAllowFrom.includes(candidate));
if (!allowed) { if (!allowed) {
if (dmPolicy === "pairing") { if (dmPolicy === "pairing") {
const { code } = await upsertProviderPairingRequest({ const { code, created } = await upsertProviderPairingRequest({
provider: "whatsapp", provider: "whatsapp",
id: candidate, id: candidate,
meta: { meta: {
name: (msg.pushName ?? "").trim() || undefined, name: (msg.pushName ?? "").trim() || undefined,
}, },
}); });
if (created) {
logVerbose( logVerbose(
`whatsapp pairing request sender=${candidate} name=${msg.pushName ?? "unknown"} code=${code}`, `whatsapp pairing request sender=${candidate} name=${msg.pushName ?? "unknown"}`,
); );
try { try {
await sock.sendMessage(remoteJid, { await sock.sendMessage(remoteJid, {
@@ -284,6 +285,7 @@ export async function monitorWebInbox(options: {
`whatsapp pairing reply failed for ${candidate}: ${String(err)}`, `whatsapp pairing reply failed for ${candidate}: ${String(err)}`,
); );
} }
}
} else { } else {
logVerbose( logVerbose(
`Blocked unauthorized sender ${candidate} (dmPolicy=${dmPolicy})`, `Blocked unauthorized sender ${candidate} (dmPolicy=${dmPolicy})`,

View File

@@ -1005,6 +1005,9 @@ describe("web monitor inbox", () => {
it("locks down when no config is present (pairing for unknown senders)", async () => { it("locks down when no config is present (pairing for unknown senders)", async () => {
// No config file => locked-down defaults apply (pairing for unknown senders) // No config file => locked-down defaults apply (pairing for unknown senders)
mockLoadConfig.mockReturnValue({}); mockLoadConfig.mockReturnValue({});
upsertPairingRequestMock
.mockResolvedValueOnce({ code: "PAIRCODE", created: true })
.mockResolvedValueOnce({ code: "PAIRCODE", created: false });
const onMessage = vi.fn(); const onMessage = vi.fn();
const listener = await monitorWebInbox({ verbose: false, onMessage }); const listener = await monitorWebInbox({ verbose: false, onMessage });
@@ -1034,6 +1037,26 @@ describe("web monitor inbox", () => {
text: expect.stringContaining("Pairing code: PAIRCODE"), text: expect.stringContaining("Pairing code: PAIRCODE"),
}); });
const upsertBlockedAgain = {
type: "notify",
messages: [
{
key: {
id: "no-config-1b",
fromMe: false,
remoteJid: "999@s.whatsapp.net",
},
message: { conversation: "ping again" },
messageTimestamp: 1_700_000_002,
},
],
};
sock.ev.emit("messages.upsert", upsertBlockedAgain);
await new Promise((resolve) => setImmediate(resolve));
expect(onMessage).not.toHaveBeenCalled();
expect(sock.sendMessage).toHaveBeenCalledTimes(1);
// Message from self should be allowed // Message from self should be allowed
const upsertSelf = { const upsertSelf = {
type: "notify", type: "notify",