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,26 +420,28 @@ export function createDiscordMessageHandler(params: {
name: author.username ?? undefined, name: author.username ?? undefined,
}, },
}); });
logVerbose( if (created) {
`discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)} code=${code}`,
);
try {
await sendMessageDiscord(
`user:${author.id}`,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider discord <code>",
].join("\n"),
{ token, rest: client.rest },
);
} catch (err) {
logVerbose( logVerbose(
`discord pairing reply failed for ${author.id}: ${String(err)}`, `discord pairing request sender=${author.id} tag=${formatDiscordUserTag(author)}`,
); );
try {
await sendMessageDiscord(
`user:${author.id}`,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider discord <code>",
].join("\n"),
{ token, rest: client.rest },
);
} catch (err) {
logVerbose(
`discord pairing reply failed for ${author.id}: ${String(err)}`,
);
}
} }
} else { } else {
logVerbose( logVerbose(
@@ -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,17 +1117,19 @@ function createDiscordNativeCommand(params: {
name: user.username ?? undefined, name: user.username ?? undefined,
}, },
}); });
await interaction.reply({ if (created) {
content: [ await interaction.reply({
"Clawdbot: access not configured.", content: [
"", "Clawdbot: access not configured.",
`Pairing code: ${code}`, "",
"", `Pairing code: ${code}`,
"Ask the bot owner to approve with:", "",
"clawdbot pairing approve --provider discord <code>", "Ask the bot owner to approve with:",
].join("\n"), "clawdbot pairing approve --provider discord <code>",
ephemeral: true, ].join("\n"),
}); 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,30 +238,30 @@ 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, [
[ "Clawdbot: access not configured.",
"Clawdbot: access not configured.", "",
"", `Pairing code: ${code}`,
`Pairing code: ${code}`, "",
"", "Ask the bot owner to approve with:",
"Ask the bot owner to approve with:", "clawdbot pairing approve --provider imessage <code>",
"clawdbot pairing approve --provider imessage <code>", ].join("\n"),
].join("\n"), {
{ client,
client, maxBytes: mediaMaxBytes,
maxBytes: mediaMaxBytes, ...(chatId ? { chatId } : {}),
...(chatId ? { chatId } : {}), },
}, );
); } catch (err) {
} catch (err) { logVerbose(
logVerbose( `imessage pairing reply failed for ${senderId}: ${String(err)}`,
`imessage pairing reply failed for ${senderId}: ${String(err)}`, );
); }
} }
} else { } else {
logVerbose( logVerbose(

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,26 +214,32 @@ 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);
const { value } = await readJsonFile<AllowFromStore>(filePath, { return await withFileLock(
version: 1, filePath,
allowFrom: [], { version: 1, allowFrom: [] } satisfies AllowFromStore,
}); async () => {
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : []) const { value } = await readJsonFile<AllowFromStore>(filePath, {
.map((v) => normalizeAllowEntry(params.provider, String(v))) version: 1,
.filter(Boolean); allowFrom: [],
const normalized = normalizeAllowEntry( });
params.provider, const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
normalizeId(params.entry), .map((v) => normalizeAllowEntry(params.provider, String(v)))
.filter(Boolean);
const normalized = normalizeAllowEntry(
params.provider,
normalizeId(params.entry),
);
if (!normalized) return { changed: false, allowFrom: current };
if (current.includes(normalized))
return { changed: false, allowFrom: current };
const next = [...current, normalized];
await writeJsonFile(filePath, {
version: 1,
allowFrom: next,
} satisfies AllowFromStore);
return { changed: true, allowFrom: next };
},
); );
if (!normalized) return { changed: false, allowFrom: current };
if (current.includes(normalized))
return { changed: false, allowFrom: current };
const next = [...current, normalized];
await writeJsonFile(filePath, {
version: 1,
allowFrom: next,
} satisfies AllowFromStore);
return { changed: true, allowFrom: next };
} }
export async function listProviderPairingRequests( export async function listProviderPairingRequests(
@@ -156,21 +247,35 @@ 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);
const { value } = await readJsonFile<PairingStore>(filePath, { return await withFileLock(
version: 1, filePath,
requests: [], { version: 1, requests: [] } satisfies PairingStore,
}); async () => {
const reqs = Array.isArray(value.requests) ? value.requests : []; const { value } = await readJsonFile<PairingStore>(filePath, {
return reqs version: 1,
.filter( requests: [],
(r) => });
r && const reqs = Array.isArray(value.requests) ? value.requests : [];
typeof r.id === "string" && const nowMs = Date.now();
typeof r.code === "string" && const { requests: pruned, removed } = pruneExpiredRequests(reqs, nowMs);
typeof r.createdAt === "string", if (removed) {
) await writeJsonFile(filePath, {
.slice() version: 1,
.sort((a, b) => a.createdAt.localeCompare(b.createdAt)); requests: pruned,
} satisfies PairingStore);
}
return pruned
.filter(
(r) =>
r &&
typeof r.id === "string" &&
typeof r.code === "string" &&
typeof r.createdAt === "string",
)
.slice()
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
},
);
} }
export async function upsertProviderPairingRequest(params: { export async function upsertProviderPairingRequest(params: {
@@ -181,56 +286,75 @@ 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);
const { value } = await readJsonFile<PairingStore>(filePath, { return await withFileLock(
version: 1, filePath,
requests: [], { version: 1, requests: [] } satisfies PairingStore,
}); async () => {
const now = new Date().toISOString(); const { value } = await readJsonFile<PairingStore>(filePath, {
const id = normalizeId(params.id); version: 1,
const meta = requests: [],
params.meta && typeof params.meta === "object" });
? Object.fromEntries( const now = new Date().toISOString();
Object.entries(params.meta) const nowMs = Date.now();
.map(([k, v]) => [k, String(v ?? "").trim()] as const) const id = normalizeId(params.id);
.filter(([_, v]) => Boolean(v)), const meta =
) params.meta && typeof params.meta === "object"
: undefined; ? Object.fromEntries(
Object.entries(params.meta)
.map(([k, v]) => [k, String(v ?? "").trim()] as const)
.filter(([_, v]) => Boolean(v)),
)
: undefined;
const reqs = Array.isArray(value.requests) ? value.requests : []; let reqs = Array.isArray(value.requests) ? value.requests : [];
const existingIdx = reqs.findIndex((r) => r.id === id); const { requests: pruned } = pruneExpiredRequests(reqs, nowMs);
if (existingIdx >= 0) { reqs = pruned;
const existing = reqs[existingIdx]; const existingIdx = reqs.findIndex((r) => r.id === id);
const existingCode = const existingCodes = new Set(
existing && typeof existing.code === "string" ? existing.code.trim() : ""; reqs.map((req) =>
const code = existingCode || randomCode(); String(req.code ?? "")
const next: PairingRequest = { .trim()
id, .toUpperCase(),
code, ),
createdAt: existing?.createdAt ?? now, );
lastSeenAt: now,
meta: meta ?? existing?.meta,
};
reqs[existingIdx] = next;
await writeJsonFile(filePath, {
version: 1,
requests: reqs,
} satisfies PairingStore);
return { code, created: false };
}
const code = randomCode(); if (existingIdx >= 0) {
const next: PairingRequest = { const existing = reqs[existingIdx];
id, const existingCode =
code, existing && typeof existing.code === "string"
createdAt: now, ? existing.code.trim()
lastSeenAt: now, : "";
...(meta ? { meta } : {}), const code = existingCode || generateUniqueCode(existingCodes);
}; const next: PairingRequest = {
await writeJsonFile(filePath, { id,
version: 1, code,
requests: [...reqs, next], createdAt: existing?.createdAt ?? now,
} satisfies PairingStore); lastSeenAt: now,
return { code, created: true }; meta: meta ?? existing?.meta,
};
reqs[existingIdx] = next;
await writeJsonFile(filePath, {
version: 1,
requests: reqs,
} satisfies PairingStore);
return { code, created: false };
}
const code = generateUniqueCode(existingCodes);
const next: PairingRequest = {
id,
code,
createdAt: now,
lastSeenAt: now,
...(meta ? { meta } : {}),
};
await writeJsonFile(filePath, {
version: 1,
requests: [...reqs, next],
} satisfies PairingStore);
return { code, created: true };
},
);
} }
export async function approveProviderPairingCode(params: { export async function approveProviderPairingCode(params: {
@@ -243,26 +367,42 @@ 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);
const { value } = await readJsonFile<PairingStore>(filePath, { return await withFileLock(
version: 1, filePath,
requests: [], { version: 1, requests: [] } satisfies PairingStore,
}); async () => {
const reqs = Array.isArray(value.requests) ? value.requests : []; const { value } = await readJsonFile<PairingStore>(filePath, {
const idx = reqs.findIndex( version: 1,
(r) => String(r.code ?? "").toUpperCase() === code, requests: [],
});
const reqs = Array.isArray(value.requests) ? value.requests : [];
const nowMs = Date.now();
const { requests: pruned, removed } = pruneExpiredRequests(reqs, nowMs);
const idx = pruned.findIndex(
(r) => String(r.code ?? "").toUpperCase() === code,
);
if (idx < 0) {
if (removed) {
await writeJsonFile(filePath, {
version: 1,
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);
await addProviderAllowFromStoreEntry({
provider: params.provider,
entry: entry.id,
env,
});
return { id: entry.id, entry };
},
); );
if (idx < 0) return null;
const entry = reqs[idx];
if (!entry) return null;
reqs.splice(idx, 1);
await writeJsonFile(filePath, {
version: 1,
requests: reqs,
} satisfies PairingStore);
await addProviderAllowFromStoreEntry({
provider: params.provider,
entry: entry.id,
env,
});
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,33 +336,33 @@ 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, [
[ "Clawdbot: access not configured.",
"Clawdbot: access not configured.", "",
"", `Pairing code: ${code}`,
`Pairing code: ${code}`, "",
"", "Ask the bot owner to approve with:",
"Ask the bot owner to approve with:", "clawdbot pairing approve --provider signal <code>",
"clawdbot pairing approve --provider signal <code>", ].join("\n"),
].join("\n"), { baseUrl, account, maxBytes: mediaMaxBytes },
{ baseUrl, account, maxBytes: mediaMaxBytes }, );
); } catch (err) {
} catch (err) { logVerbose(
logVerbose( `signal pairing reply failed for ${senderId}: ${String(err)}`,
`signal pairing reply failed for ${senderId}: ${String(err)}`, );
); }
} }
} else { } else {
logVerbose( logVerbose(

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,31 +653,33 @@ 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 },
}); });
logVerbose( if (created) {
`slack pairing request sender=${message.user} name=${senderName ?? "unknown"} code=${code}`,
);
try {
await sendMessageSlack(
message.channel,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider slack <code>",
].join("\n"),
{ token: botToken, client: app.client },
);
} catch (err) {
logVerbose( logVerbose(
`slack pairing reply failed for ${message.user}: ${String(err)}`, `slack pairing request sender=${message.user} name=${senderName ?? "unknown"}`,
); );
try {
await sendMessageSlack(
message.channel,
[
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider slack <code>",
].join("\n"),
{ token: botToken, client: app.client },
);
} catch (err) {
logVerbose(
`slack pairing reply failed for ${message.user}: ${String(err)}`,
);
}
} }
} else { } else {
logVerbose( logVerbose(
@@ -1468,22 +1470,24 @@ 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 },
}); });
await respond({ if (created) {
text: [ await respond({
"Clawdbot: access not configured.", text: [
"", "Clawdbot: access not configured.",
`Pairing code: ${code}`, "",
"", `Pairing code: ${code}`,
"Ask the bot owner to approve with:", "",
"clawdbot pairing approve --provider slack <code>", "Ask the bot owner to approve with:",
].join("\n"), "clawdbot pairing approve --provider slack <code>",
response_type: "ephemeral", ].join("\n"),
}); 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,33 +247,34 @@ 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,
}); });
logger.info( if (created) {
{ logger.info(
chatId: candidate, {
username: from?.username, chatId: candidate,
firstName: from?.first_name, username: from?.username,
lastName: from?.last_name, firstName: from?.first_name,
code, lastName: from?.last_name,
}, },
"telegram pairing request", "telegram pairing request",
); );
await bot.api.sendMessage( await bot.api.sendMessage(
chatId, chatId,
[ [
"Clawdbot: access not configured.", "Clawdbot: access not configured.",
"", "",
`Pairing code: ${code}`, `Pairing code: ${code}`,
"", "",
"Ask the bot owner to approve with:", "Ask the bot owner to approve with:",
"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,31 +258,33 @@ 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,
}, },
}); });
logVerbose( if (created) {
`whatsapp pairing request sender=${candidate} name=${msg.pushName ?? "unknown"} code=${code}`,
);
try {
await sock.sendMessage(remoteJid, {
text: [
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider whatsapp <code>",
].join("\n"),
});
} catch (err) {
logVerbose( logVerbose(
`whatsapp pairing reply failed for ${candidate}: ${String(err)}`, `whatsapp pairing request sender=${candidate} name=${msg.pushName ?? "unknown"}`,
); );
try {
await sock.sendMessage(remoteJid, {
text: [
"Clawdbot: access not configured.",
"",
`Pairing code: ${code}`,
"",
"Ask the bot owner to approve with:",
"clawdbot pairing approve --provider whatsapp <code>",
].join("\n"),
});
} catch (err) {
logVerbose(
`whatsapp pairing reply failed for ${candidate}: ${String(err)}`,
);
}
} }
} else { } else {
logVerbose( logVerbose(

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",