Files
clawdbot/src/pairing/pairing-store.ts
2026-01-21 05:34:53 +00:00

466 lines
14 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import lockfile from "proper-lockfile";
import { getPairingAdapter } from "../channels/plugins/pairing.js";
import type { ChannelId, ChannelPairingAdapter } from "../channels/plugins/types.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_PENDING_MAX = 3;
const PAIRING_STORE_LOCK_OPTIONS = {
retries: {
retries: 10,
factor: 2,
minTimeout: 100,
maxTimeout: 10_000,
randomize: true,
},
stale: 30_000,
} as const;
export type PairingChannel = ChannelId;
export type PairingRequest = {
id: string;
code: string;
createdAt: string;
lastSeenAt: string;
meta?: Record<string, string>;
};
type PairingStore = {
version: 1;
requests: PairingRequest[];
};
type AllowFromStore = {
version: 1;
allowFrom: string[];
};
function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string {
const stateDir = resolveStateDir(env, os.homedir);
return resolveOAuthDir(env, stateDir);
}
/** Sanitize channel ID for use in filenames (prevent path traversal). */
function safeChannelKey(channel: PairingChannel): string {
const raw = String(channel).trim().toLowerCase();
if (!raw) throw new Error("invalid pairing channel");
const safe = raw.replace(/[\\/:*?"<>|]/g, "_").replace(/\.\./g, "_");
if (!safe || safe === "_") throw new Error("invalid pairing channel");
return safe;
}
function resolvePairingPath(channel: PairingChannel, env: NodeJS.ProcessEnv = process.env): string {
return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`);
}
function resolveAllowFromPath(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): string {
return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-allowFrom.json`);
}
function safeParseJson<T>(raw: string): T | null {
try {
return JSON.parse(raw) as T;
} catch {
return null;
}
}
async function readJsonFile<T>(
filePath: string,
fallback: T,
): Promise<{ value: T; exists: boolean }> {
try {
const raw = await fs.promises.readFile(filePath, "utf-8");
const parsed = safeParseJson<T>(raw);
if (parsed == null) return { value: fallback, exists: true };
return { value: parsed, exists: true };
} catch (err) {
const code = (err as { code?: string }).code;
if (code === "ENOENT") return { value: fallback, exists: false };
return { value: fallback, exists: false };
}
}
async function writeJsonFile(filePath: string, value: unknown): Promise<void> {
const dir = path.dirname(filePath);
await fs.promises.mkdir(dir, { recursive: true, mode: 0o700 });
const tmp = path.join(dir, `${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 resolveLastSeenAt(entry: PairingRequest): number {
return parseTimestamp(entry.lastSeenAt) ?? parseTimestamp(entry.createdAt) ?? 0;
}
function pruneExcessRequests(reqs: PairingRequest[], maxPending: number) {
if (maxPending <= 0 || reqs.length <= maxPending) {
return { requests: reqs, removed: false };
}
const sorted = reqs.slice().sort((a, b) => resolveLastSeenAt(a) - resolveLastSeenAt(b));
return { requests: sorted.slice(-maxPending), removed: true };
}
function randomCode(): string {
// Human-friendly: 8 chars, upper, no ambiguous chars (0O1I).
let out = "";
for (let i = 0; i < PAIRING_CODE_LENGTH; i++) {
const idx = crypto.randomInt(0, PAIRING_CODE_ALPHABET.length);
out += PAIRING_CODE_ALPHABET[idx];
}
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 {
return String(value).trim();
}
function normalizeAllowEntry(channel: PairingChannel, entry: string): string {
const trimmed = entry.trim();
if (!trimmed) return "";
if (trimmed === "*") return "";
const adapter = getPairingAdapter(channel);
const normalized = adapter?.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed;
return String(normalized).trim();
}
export async function readChannelAllowFromStore(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): Promise<string[]> {
const filePath = resolveAllowFromPath(channel, env);
const { value } = await readJsonFile<AllowFromStore>(filePath, {
version: 1,
allowFrom: [],
});
const list = Array.isArray(value.allowFrom) ? value.allowFrom : [];
return list.map((v) => normalizeAllowEntry(channel, String(v))).filter(Boolean);
}
export async function addChannelAllowFromStoreEntry(params: {
channel: PairingChannel;
entry: string | number;
env?: NodeJS.ProcessEnv;
}): Promise<{ changed: boolean; allowFrom: string[] }> {
const env = params.env ?? process.env;
const filePath = resolveAllowFromPath(params.channel, env);
return await withFileLock(
filePath,
{ version: 1, allowFrom: [] } satisfies AllowFromStore,
async () => {
const { value } = await readJsonFile<AllowFromStore>(filePath, {
version: 1,
allowFrom: [],
});
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
.map((v) => normalizeAllowEntry(params.channel, String(v)))
.filter(Boolean);
const normalized = normalizeAllowEntry(params.channel, 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 };
},
);
}
export async function removeChannelAllowFromStoreEntry(params: {
channel: PairingChannel;
entry: string | number;
env?: NodeJS.ProcessEnv;
}): Promise<{ changed: boolean; allowFrom: string[] }> {
const env = params.env ?? process.env;
const filePath = resolveAllowFromPath(params.channel, env);
return await withFileLock(
filePath,
{ version: 1, allowFrom: [] } satisfies AllowFromStore,
async () => {
const { value } = await readJsonFile<AllowFromStore>(filePath, {
version: 1,
allowFrom: [],
});
const current = (Array.isArray(value.allowFrom) ? value.allowFrom : [])
.map((v) => normalizeAllowEntry(params.channel, String(v)))
.filter(Boolean);
const normalized = normalizeAllowEntry(params.channel, normalizeId(params.entry));
if (!normalized) return { changed: false, allowFrom: current };
const next = current.filter((entry) => entry !== normalized);
if (next.length === current.length) return { changed: false, allowFrom: current };
await writeJsonFile(filePath, {
version: 1,
allowFrom: next,
} satisfies AllowFromStore);
return { changed: true, allowFrom: next };
},
);
}
export async function listChannelPairingRequests(
channel: PairingChannel,
env: NodeJS.ProcessEnv = process.env,
): Promise<PairingRequest[]> {
const filePath = resolvePairingPath(channel, env);
return await withFileLock(
filePath,
{ version: 1, requests: [] } satisfies PairingStore,
async () => {
const { value } = await readJsonFile<PairingStore>(filePath, {
version: 1,
requests: [],
});
const reqs = Array.isArray(value.requests) ? value.requests : [];
const nowMs = Date.now();
const { requests: prunedExpired, removed: expiredRemoved } = pruneExpiredRequests(
reqs,
nowMs,
);
const { requests: pruned, removed: cappedRemoved } = pruneExcessRequests(
prunedExpired,
PAIRING_PENDING_MAX,
);
if (expiredRemoved || cappedRemoved) {
await writeJsonFile(filePath, {
version: 1,
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 upsertChannelPairingRequest(params: {
channel: PairingChannel;
id: string | number;
meta?: Record<string, string | undefined | null>;
env?: NodeJS.ProcessEnv;
/** Extension channels can pass their adapter directly to bypass registry lookup. */
pairingAdapter?: ChannelPairingAdapter;
}): Promise<{ code: string; created: boolean }> {
const env = params.env ?? process.env;
const filePath = resolvePairingPath(params.channel, env);
return await withFileLock(
filePath,
{ version: 1, requests: [] } satisfies PairingStore,
async () => {
const { value } = await readJsonFile<PairingStore>(filePath, {
version: 1,
requests: [],
});
const now = new Date().toISOString();
const nowMs = Date.now();
const id = normalizeId(params.id);
const meta =
params.meta && typeof params.meta === "object"
? Object.fromEntries(
Object.entries(params.meta)
.map(([k, v]) => [k, String(v ?? "").trim()] as const)
.filter(([_, v]) => Boolean(v)),
)
: undefined;
let reqs = Array.isArray(value.requests) ? value.requests : [];
const { requests: prunedExpired, removed: expiredRemoved } = pruneExpiredRequests(
reqs,
nowMs,
);
reqs = prunedExpired;
const existingIdx = reqs.findIndex((r) => r.id === id);
const existingCodes = new Set(
reqs.map((req) =>
String(req.code ?? "")
.trim()
.toUpperCase(),
),
);
if (existingIdx >= 0) {
const existing = reqs[existingIdx];
const existingCode =
existing && typeof existing.code === "string" ? existing.code.trim() : "";
const code = existingCode || generateUniqueCode(existingCodes);
const next: PairingRequest = {
id,
code,
createdAt: existing?.createdAt ?? now,
lastSeenAt: now,
meta: meta ?? existing?.meta,
};
reqs[existingIdx] = next;
const { requests: capped } = pruneExcessRequests(reqs, PAIRING_PENDING_MAX);
await writeJsonFile(filePath, {
version: 1,
requests: capped,
} satisfies PairingStore);
return { code, created: false };
}
const { requests: capped, removed: cappedRemoved } = pruneExcessRequests(
reqs,
PAIRING_PENDING_MAX,
);
reqs = capped;
if (PAIRING_PENDING_MAX > 0 && reqs.length >= PAIRING_PENDING_MAX) {
if (expiredRemoved || cappedRemoved) {
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 approveChannelPairingCode(params: {
channel: PairingChannel;
code: string;
env?: NodeJS.ProcessEnv;
}): Promise<{ id: string; entry?: PairingRequest } | null> {
const env = params.env ?? process.env;
const code = params.code.trim().toUpperCase();
if (!code) return null;
const filePath = resolvePairingPath(params.channel, env);
return await withFileLock(
filePath,
{ version: 1, requests: [] } satisfies PairingStore,
async () => {
const { value } = await readJsonFile<PairingStore>(filePath, {
version: 1,
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 addChannelAllowFromStoreEntry({
channel: params.channel,
entry: entry.id,
env,
});
return { id: entry.id, entry };
},
);
}