diff --git a/extensions/zalo/src/core-bridge.ts b/extensions/zalo/src/core-bridge.ts index 46162412b..77cb72271 100644 --- a/extensions/zalo/src/core-bridge.ts +++ b/extensions/zalo/src/core-bridge.ts @@ -30,6 +30,11 @@ export type CoreChannelDeps = { channel: string; id: string; meta?: { name?: string }; + pairingAdapter?: { + idLabel: string; + normalizeAllowEntry?: (entry: string) => string; + notifyApproval?: (params: { cfg: unknown; id: string; runtime?: unknown }) => Promise; + }; }) => Promise<{ code: string; created: boolean }>; fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>; saveMediaBuffer: ( diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index 6770cf53b..f7cd5d042 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -12,6 +12,7 @@ import { type ZaloMessage, type ZaloUpdate, } from "./api.js"; +import { zaloPlugin } from "./channel.js"; import { loadCoreChannelDeps } from "./core-bridge.js"; import { resolveZaloProxyFetch } from "./proxy.js"; import type { CoreConfig } from "./types.js"; @@ -176,8 +177,12 @@ export async function handleZaloWebhookRequest( return true; } - const payload = body.value as { ok?: boolean; result?: ZaloUpdate }; - if (!payload?.ok || !payload.result) { + // Zalo sends updates directly as { event_name, message, ... }, not wrapped in { ok, result } + const raw = body.value as Record; + const update: ZaloUpdate | undefined = + raw.ok === true && raw.result ? (raw.result as ZaloUpdate) : (raw as ZaloUpdate); + + if (!update?.event_name) { res.statusCode = 400; res.end("invalid payload"); return true; @@ -185,7 +190,7 @@ export async function handleZaloWebhookRequest( target.statusSink?.({ lastInboundAt: Date.now() }); processUpdate( - payload.result, + update, target.token, target.account, target.config, @@ -445,6 +450,7 @@ async function processMessageWithPipeline(params: { channel: "zalo", id: senderId, meta: { name: senderName ?? undefined }, + pairingAdapter: zaloPlugin.pairing, }); if (created) { diff --git a/src/channels/plugins/pairing.ts b/src/channels/plugins/pairing.ts index ea4ca7f7c..31c4be75e 100644 --- a/src/channels/plugins/pairing.ts +++ b/src/channels/plugins/pairing.ts @@ -53,8 +53,11 @@ export async function notifyPairingApproved(params: { id: string; cfg: ClawdbotConfig; runtime?: RuntimeEnv; + /** Extension channels can pass their adapter directly to bypass registry lookup. */ + pairingAdapter?: ChannelPairingAdapter; }): Promise { - const adapter = requirePairingAdapter(params.channelId); + // Extensions may provide adapter directly to bypass ESM module isolation + const adapter = params.pairingAdapter ?? requirePairingAdapter(params.channelId); if (!adapter.notifyApproval) return; await adapter.notifyApproval({ cfg: params.cfg, diff --git a/src/cli/pairing-cli.test.ts b/src/cli/pairing-cli.test.ts index a646f9fd5..456171452 100644 --- a/src/cli/pairing-cli.test.ts +++ b/src/cli/pairing-cli.test.ts @@ -8,11 +8,10 @@ const pairingIdLabels: Record = { telegram: "telegramUserId", discord: "discordUserId", }; -const requirePairingAdapter = vi.fn((channel: string) => ({ +const getPairingAdapter = vi.fn((channel: string) => ({ idLabel: pairingIdLabels[channel] ?? "userId", })); const listPairingChannels = vi.fn(() => ["telegram", "discord"]); -const resolvePairingChannel = vi.fn((raw: string) => raw); vi.mock("../pairing/pairing-store.js", () => ({ listChannelPairingRequests, @@ -21,9 +20,8 @@ vi.mock("../pairing/pairing-store.js", () => ({ vi.mock("../channels/plugins/pairing.js", () => ({ listPairingChannels, - resolvePairingChannel, notifyPairingApproved, - requirePairingAdapter, + getPairingAdapter, })); vi.mock("../config/config.js", () => ({ diff --git a/src/cli/pairing-cli.ts b/src/cli/pairing-cli.ts index cce5f35b4..a272b135a 100644 --- a/src/cli/pairing-cli.ts +++ b/src/cli/pairing-cli.ts @@ -2,7 +2,6 @@ import type { Command } from "commander"; import { listPairingChannels, notifyPairingApproved, - resolvePairingChannel, } from "../channels/plugins/pairing.js"; import { loadConfig } from "../config/config.js"; import { resolvePairingIdLabel } from "../pairing/pairing-labels.js"; @@ -16,8 +15,14 @@ import { theme } from "../terminal/theme.js"; const CHANNELS: PairingChannel[] = listPairingChannels(); +/** Parse channel, allowing extension channels not in core registry. */ function parseChannel(raw: unknown): PairingChannel { - return resolvePairingChannel(raw); + const value = String(raw ?? "").trim().toLowerCase(); + if (!value) throw new Error("Channel required"); + if (CHANNELS.includes(value as PairingChannel)) return value as PairingChannel; + // Allow extension channels: validate format but don't require registry + if (/^[a-z][a-z0-9_-]{0,63}$/.test(value)) return value as PairingChannel; + throw new Error(`Invalid channel: ${value}`); } async function notifyApproved(channel: PairingChannel, id: string) { diff --git a/src/pairing/pairing-labels.ts b/src/pairing/pairing-labels.ts index 1288081f2..a7a514543 100644 --- a/src/pairing/pairing-labels.ts +++ b/src/pairing/pairing-labels.ts @@ -1,6 +1,6 @@ -import { requirePairingAdapter } from "../channels/plugins/pairing.js"; +import { getPairingAdapter } from "../channels/plugins/pairing.js"; import type { PairingChannel } from "./pairing-store.js"; export function resolvePairingIdLabel(channel: PairingChannel): string { - return requirePairingAdapter(channel).idLabel; + return getPairingAdapter(channel)?.idLabel ?? "userId"; } diff --git a/src/pairing/pairing-store.ts b/src/pairing/pairing-store.ts index 80e9b6f2c..26c0ae910 100644 --- a/src/pairing/pairing-store.ts +++ b/src/pairing/pairing-store.ts @@ -4,8 +4,8 @@ import os from "node:os"; import path from "node:path"; import lockfile from "proper-lockfile"; -import { requirePairingAdapter } from "../channels/plugins/pairing.js"; -import type { ChannelId } from "../channels/plugins/types.js"; +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; @@ -48,15 +48,24 @@ function resolveCredentialsDir(env: NodeJS.ProcessEnv = process.env): string { 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), `${channel}-pairing.json`); + return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-pairing.json`); } function resolveAllowFromPath( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, ): string { - return path.join(resolveCredentialsDir(env), `${channel}-allowFrom.json`); + return path.join(resolveCredentialsDir(env), `${safeChannelKey(channel)}-allowFrom.json`); } function safeParseJson(raw: string): T | null { @@ -184,11 +193,11 @@ function normalizeId(value: string | number): string { } function normalizeAllowEntry(channel: PairingChannel, entry: string): string { - const adapter = requirePairingAdapter(channel); const trimmed = entry.trim(); if (!trimmed) return ""; if (trimmed === "*") return ""; - const normalized = adapter.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed; + const adapter = getPairingAdapter(channel); + const normalized = adapter?.normalizeAllowEntry ? adapter.normalizeAllowEntry(trimmed) : trimmed; return String(normalized).trim(); } @@ -196,7 +205,6 @@ export async function readChannelAllowFromStore( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, ): Promise { - requirePairingAdapter(channel); const filePath = resolveAllowFromPath(channel, env); const { value } = await readJsonFile(filePath, { version: 1, @@ -211,7 +219,6 @@ export async function addChannelAllowFromStoreEntry(params: { entry: string | number; env?: NodeJS.ProcessEnv; }): Promise<{ changed: boolean; allowFrom: string[] }> { - requirePairingAdapter(params.channel); const env = params.env ?? process.env; const filePath = resolveAllowFromPath(params.channel, env); return await withFileLock( @@ -242,7 +249,6 @@ export async function listChannelPairingRequests( channel: PairingChannel, env: NodeJS.ProcessEnv = process.env, ): Promise { - requirePairingAdapter(channel); const filePath = resolvePairingPath(channel, env); return await withFileLock( filePath, @@ -287,8 +293,9 @@ export async function upsertChannelPairingRequest(params: { id: string | number; meta?: Record; env?: NodeJS.ProcessEnv; + /** Extension channels can pass their adapter directly to bypass registry lookup. */ + pairingAdapter?: ChannelPairingAdapter; }): Promise<{ code: string; created: boolean }> { - requirePairingAdapter(params.channel); const env = params.env ?? process.env; const filePath = resolvePairingPath(params.channel, env); return await withFileLock( @@ -383,7 +390,6 @@ export async function approveChannelPairingCode(params: { code: string; env?: NodeJS.ProcessEnv; }): Promise<{ id: string; entry?: PairingRequest } | null> { - requirePairingAdapter(params.channel); const env = params.env ?? process.env; const code = params.code.trim().toUpperCase(); if (!code) return null;