From af954a81d13eedf9aa0fb02cadefa785248e84e0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 17 Apr 2026 16:04:32 +0100 Subject: [PATCH] perf: optimize bundled extension tests --- extensions/active-memory/index.ts | 4 +- extensions/googlechat/src/accounts.ts | 9 +- extensions/googlechat/src/actions.test.ts | 30 +-- extensions/googlechat/src/actions.ts | 16 +- extensions/googlechat/src/api.ts | 2 +- extensions/googlechat/src/auth.ts | 5 +- extensions/googlechat/src/gateway.ts | 10 +- extensions/googlechat/src/monitor-access.ts | 38 +-- extensions/googlechat/src/monitor-routing.ts | 55 ++++ extensions/googlechat/src/monitor-types.ts | 2 +- .../googlechat/src/monitor-webhook.test.ts | 5 +- extensions/googlechat/src/monitor-webhook.ts | 11 +- extensions/googlechat/src/monitor.ts | 53 +--- .../src/monitor.webhook-routing.test.ts | 5 +- extensions/googlechat/src/sender-allow.ts | 48 ++++ extensions/googlechat/src/setup-surface.ts | 22 +- extensions/googlechat/src/setup.test.ts | 33 ++- extensions/googlechat/src/targets.test.ts | 6 +- extensions/googlechat/src/targets.ts | 5 +- extensions/lmstudio/src/stream.test.ts | 5 +- extensions/lmstudio/src/stream.ts | 5 +- extensions/nextcloud-talk/src/accounts.ts | 22 +- extensions/nextcloud-talk/src/channel-api.ts | 4 +- .../nextcloud-talk/src/channel.adapters.ts | 5 +- extensions/nextcloud-talk/src/core.test.ts | 149 +---------- .../nextcloud-talk/src/monitor.replay.test.ts | 127 +++------ extensions/nextcloud-talk/src/monitor.ts | 2 +- extensions/nextcloud-talk/src/normalize.ts | 4 +- extensions/nextcloud-talk/src/policy.ts | 47 ++-- extensions/nextcloud-talk/src/replay-guard.ts | 28 +- .../nextcloud-talk/src/room-info.test.ts | 116 ++++++++ .../nextcloud-talk/src/session-route.ts | 29 +- extensions/nextcloud-talk/src/setup-core.ts | 10 +- .../nextcloud-talk/src/setup-surface.ts | 9 +- extensions/nextcloud-talk/src/signature.ts | 5 +- extensions/nostr/src/channel-api.ts | 13 +- extensions/nostr/src/channel.inbound.test.ts | 5 +- extensions/nostr/src/channel.outbound.test.ts | 5 +- extensions/nostr/src/channel.test.ts | 5 +- extensions/nostr/src/channel.ts | 2 +- extensions/nostr/src/gateway.ts | 5 +- .../nostr/src/inbound-direct-dm-runtime.ts | 1 + extensions/nostr/src/nostr-bus.fuzz.test.ts | 253 +++--------------- extensions/nostr/src/nostr-bus.test.ts | 2 +- extensions/nostr/src/nostr-bus.ts | 122 +-------- extensions/nostr/src/nostr-key-utils.ts | 94 +++++++ extensions/nostr/src/nostr-profile-core.ts | 134 ++++++++++ .../nostr/src/nostr-profile-http-runtime.ts | 6 + .../nostr/src/nostr-profile-http.test.ts | 23 +- extensions/nostr/src/nostr-profile-http.ts | 27 +- .../nostr/src/nostr-profile-url-safety.ts | 5 +- .../nostr/src/nostr-profile.fuzz.test.ts | 59 +--- extensions/nostr/src/nostr-profile.ts | 151 +---------- extensions/nostr/src/setup-surface.ts | 2 +- extensions/nostr/src/types.ts | 2 +- extensions/qqbot/src/channel-config-shared.ts | 24 +- extensions/qqbot/src/config.test.ts | 123 +-------- extensions/qqbot/src/config.ts | 7 +- extensions/qqbot/src/manifest-schema.test.ts | 56 ++++ extensions/qqbot/src/outbound-deliver.ts | 16 +- extensions/qqbot/src/slash-commands.test.ts | 27 +- .../qqbot/src/utils/file-utils-runtime.ts | 1 + extensions/qqbot/src/utils/file-utils.test.ts | 2 +- extensions/qqbot/src/utils/file-utils.ts | 18 +- extensions/qqbot/src/utils/media-tags.ts | 3 +- extensions/qqbot/src/utils/text-parsing.ts | 36 ++- extensions/synology-chat/src/accounts.ts | 2 +- extensions/synology-chat/src/channel.test.ts | 4 +- extensions/synology-chat/src/channel.ts | 5 +- extensions/synology-chat/src/client.ts | 5 +- extensions/synology-chat/src/inbound-turn.ts | 2 +- extensions/synology-chat/src/runtime.ts | 3 +- extensions/synology-chat/src/session-key.ts | 2 +- extensions/synology-chat/src/setup-surface.ts | 9 +- .../synology-chat/src/webhook-handler.ts | 5 +- extensions/tlon/src/monitor/approval.ts | 5 +- extensions/tlon/src/monitor/media.test.ts | 52 ++-- extensions/tlon/src/monitor/utils.ts | 15 +- extensions/tlon/src/urbit/auth.ssrf.test.ts | 4 +- extensions/tlon/src/urbit/base-url.ts | 3 +- extensions/tlon/src/urbit/fetch.ts | 6 +- extensions/tlon/src/urbit/upload.test.ts | 4 +- extensions/tlon/src/urbit/upload.ts | 2 +- extensions/twitch/src/probe.ts | 14 +- extensions/twitch/src/token.ts | 2 +- extensions/twitch/src/utils/twitch.ts | 5 +- extensions/whatsapp/src/session.ts | 5 +- 87 files changed, 1099 insertions(+), 1210 deletions(-) create mode 100644 extensions/googlechat/src/monitor-routing.ts create mode 100644 extensions/googlechat/src/sender-allow.ts create mode 100644 extensions/nextcloud-talk/src/room-info.test.ts create mode 100644 extensions/nostr/src/inbound-direct-dm-runtime.ts create mode 100644 extensions/nostr/src/nostr-key-utils.ts create mode 100644 extensions/nostr/src/nostr-profile-core.ts create mode 100644 extensions/nostr/src/nostr-profile-http-runtime.ts create mode 100644 extensions/qqbot/src/manifest-schema.test.ts create mode 100644 extensions/qqbot/src/utils/file-utils-runtime.ts diff --git a/extensions/active-memory/index.ts b/extensions/active-memory/index.ts index b9c22652a80..09bd8db55b4 100644 --- a/extensions/active-memory/index.ts +++ b/extensions/active-memory/index.ts @@ -1527,7 +1527,9 @@ function extractRecentTurns(messages: unknown[]): ActiveRecallRecentTurn[] { } const rawText = extractTextContent(typed.content); const text = - role === "assistant" ? stripRecalledContextNoise(rawText) : stripInjectedActiveMemoryPrefixOnly(rawText); + role === "assistant" + ? stripRecalledContextNoise(rawText) + : stripInjectedActiveMemoryPrefixOnly(rawText); if (!text) { continue; } diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index 4bf9112cc32..dd7e78110f3 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -8,7 +8,6 @@ import { } from "openclaw/plugin-sdk/account-resolution"; import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared"; import { isSecretRef } from "openclaw/plugin-sdk/secret-input"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { z } from "zod"; import type { GoogleChatAccountConfig } from "./types.config.js"; @@ -28,6 +27,14 @@ const ENV_SERVICE_ACCOUNT = "GOOGLE_CHAT_SERVICE_ACCOUNT"; const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; const JsonRecordSchema = z.record(z.string(), z.unknown()); +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + const { listAccountIds: listGoogleChatAccountIds, resolveDefaultAccountId: resolveDefaultGoogleChatAccountId, diff --git a/extensions/googlechat/src/actions.test.ts b/extensions/googlechat/src/actions.test.ts index 07a8293cf82..a2e0eee1bea 100644 --- a/extensions/googlechat/src/actions.test.ts +++ b/extensions/googlechat/src/actions.test.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn()); @@ -9,7 +10,6 @@ const sendGoogleChatMessage = vi.hoisted(() => vi.fn()); const uploadGoogleChatAttachment = vi.hoisted(() => vi.fn()); const resolveGoogleChatOutboundSpace = vi.hoisted(() => vi.fn()); const getGoogleChatRuntime = vi.hoisted(() => vi.fn()); -const loadOutboundMediaFromUrl = vi.hoisted(() => vi.fn()); vi.mock("./accounts.js", () => ({ listEnabledGoogleChatAccounts, @@ -32,15 +32,6 @@ vi.mock("./targets.js", () => ({ resolveGoogleChatOutboundSpace, })); -vi.mock("../runtime-api.js", async () => { - const actual = await vi.importActual("../runtime-api.js"); - return { - ...actual, - loadOutboundMediaFromUrl: (...args: Parameters) => - (loadOutboundMediaFromUrl as unknown as typeof actual.loadOutboundMediaFromUrl)(...args), - }; -}); - let googlechatMessageActions: typeof import("./actions.js").googlechatMessageActions; describe("googlechat message actions", () => { @@ -161,11 +152,9 @@ describe("googlechat message actions", () => { config: { mediaMaxMb: 5 }, }); resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB"); - loadOutboundMediaFromUrl.mockResolvedValue({ - buffer: Buffer.from("local-bytes"), - fileName: "local.txt", - contentType: "text/plain", - }); + const localRoot = "/tmp/googlechat-action-test"; + const localPath = path.join(localRoot, "local.md"); + const readFile = vi.fn(async () => Buffer.from("local-bytes")); getGoogleChatRuntime.mockReturnValue({ channel: { media: { @@ -187,23 +176,22 @@ describe("googlechat message actions", () => { action: "upload-file", params: { to: "spaces/BBB", - path: "/tmp/local.txt", + path: localPath, message: "notes", filename: "renamed.txt", }, cfg: {}, accountId: "default", - mediaLocalRoots: ["/tmp"], + mediaLocalRoots: [localRoot], + mediaReadFile: readFile, } as never); - expect(loadOutboundMediaFromUrl).toHaveBeenCalledWith( - "/tmp/local.txt", - expect.objectContaining({ mediaLocalRoots: ["/tmp"] }), - ); + expect(readFile).toHaveBeenCalledWith(localPath); expect(uploadGoogleChatAttachment).toHaveBeenCalledWith( expect.objectContaining({ space: "spaces/BBB", filename: "renamed.txt", + buffer: Buffer.from("local-bytes"), }), ); expect(sendGoogleChatMessage).toHaveBeenCalledWith( diff --git a/extensions/googlechat/src/actions.ts b/extensions/googlechat/src/actions.ts index cdac49620e8..74ddf4101d2 100644 --- a/extensions/googlechat/src/actions.ts +++ b/extensions/googlechat/src/actions.ts @@ -1,17 +1,17 @@ -import type { - ChannelMessageActionAdapter, - ChannelMessageActionName, - OpenClawConfig, -} from "../runtime-api.js"; import { createActionGate, - extractToolSend, jsonResult, - loadOutboundMediaFromUrl, readNumberParam, readReactionParams, readStringParam, -} from "../runtime-api.js"; +} from "openclaw/plugin-sdk/channel-actions"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionName, +} from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; +import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js"; import { createGoogleChatReaction, diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index f5262b59286..2120256c2fa 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -1,6 +1,6 @@ import crypto from "node:crypto"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { fetchWithSsrFGuard } from "../runtime-api.js"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { getGoogleChatAccessToken } from "./auth.js"; import type { GoogleChatReaction } from "./types.js"; diff --git a/extensions/googlechat/src/auth.ts b/extensions/googlechat/src/auth.ts index 0317f70a96d..9845aefb043 100644 --- a/extensions/googlechat/src/auth.ts +++ b/extensions/googlechat/src/auth.ts @@ -1,5 +1,4 @@ import { GoogleAuth, OAuth2Client } from "google-auth-library"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot"; @@ -16,6 +15,10 @@ const verifyClient = new OAuth2Client(); let cachedCerts: { fetchedAt: number; certs: Record } | null = null; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + function buildAuthKey(account: ResolvedGoogleChatAccount): string { if (account.credentialsFile) { return `file:${account.credentialsFile}`; diff --git a/extensions/googlechat/src/gateway.ts b/extensions/googlechat/src/gateway.ts index 9c05a13ec02..02db0ad51ae 100644 --- a/extensions/googlechat/src/gateway.ts +++ b/extensions/googlechat/src/gateway.ts @@ -1,11 +1,11 @@ -import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers"; import { createAccountStatusSink, runPassiveAccountLifecycle, - type OpenClawConfig, - type ResolvedGoogleChatAccount, -} from "./channel.deps.runtime.js"; +} from "openclaw/plugin-sdk/channel-lifecycle"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; +import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers"; +import type { ResolvedGoogleChatAccount } from "./accounts.js"; import type { GoogleChatRuntimeEnv } from "./monitor-types.js"; const loadGoogleChatChannelRuntime = createLazyRuntimeNamedExport( diff --git a/extensions/googlechat/src/monitor-access.ts b/extensions/googlechat/src/monitor-access.ts index ef87f621c10..20abe76bccb 100644 --- a/extensions/googlechat/src/monitor-access.ts +++ b/extensions/googlechat/src/monitor-access.ts @@ -18,6 +18,7 @@ import { import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { sendGoogleChatMessage } from "./api.js"; import type { GoogleChatCoreRuntime } from "./monitor-types.js"; +import { isSenderAllowed } from "./sender-allow.js"; import type { GoogleChatAnnotation, GoogleChatMessage, GoogleChatSpace } from "./types.js"; function normalizeUserId(raw?: string | null): string { @@ -28,42 +29,7 @@ function normalizeUserId(raw?: string | null): string { return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, "")); } -function isEmailLike(value: string): boolean { - // Keep this intentionally loose; allowlists are user-provided config. - return value.includes("@"); -} - -export function isSenderAllowed( - senderId: string, - senderEmail: string | undefined, - allowFrom: string[], - allowNameMatching = false, -) { - if (allowFrom.includes("*")) { - return true; - } - const normalizedSenderId = normalizeUserId(senderId); - const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? ""); - return allowFrom.some((entry) => { - const normalized = normalizeLowercaseStringOrEmpty(entry); - if (!normalized) { - return false; - } - - // Accept `googlechat:` but treat `users/...` as an *ID* only (deprecated `users/`). - const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, ""); - if (withoutPrefix.startsWith("users/")) { - return normalizeUserId(withoutPrefix) === normalizedSenderId; - } - - // Raw email allowlist entries are a break-glass override. - if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) { - return withoutPrefix === normalizedEmail; - } - - return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId; - }); -} +export { isSenderAllowed } from "./sender-allow.js"; type GoogleChatGroupEntry = { requireMention?: boolean; diff --git a/extensions/googlechat/src/monitor-routing.ts b/extensions/googlechat/src/monitor-routing.ts new file mode 100644 index 00000000000..a082b05a6e0 --- /dev/null +++ b/extensions/googlechat/src/monitor-routing.ts @@ -0,0 +1,55 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import { createWebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards"; +import { registerWebhookTargetWithPluginRoute } from "openclaw/plugin-sdk/webhook-targets"; +import type { WebhookTarget } from "./monitor-types.js"; +import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js"; +import type { GoogleChatEvent } from "./types.js"; + +type ProcessGoogleChatEvent = (event: GoogleChatEvent, target: WebhookTarget) => Promise; + +const webhookTargets = new Map(); +const webhookInFlightLimiter = createWebhookInFlightLimiter(); + +let processGoogleChatEvent: ProcessGoogleChatEvent = async () => {}; + +export function setGoogleChatWebhookEventProcessor(processEvent: ProcessGoogleChatEvent): void { + processGoogleChatEvent = processEvent; +} + +const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({ + webhookTargets, + webhookInFlightLimiter, + processEvent: async (event, target) => { + await processGoogleChatEvent(event, target); + }, +}); + +export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void { + return registerWebhookTargetWithPluginRoute({ + targetsByPath: webhookTargets, + target, + route: { + auth: "plugin", + match: "exact", + pluginId: "googlechat", + source: "googlechat-webhook", + accountId: target.account.accountId, + log: target.runtime.log, + handler: async (req, res) => { + const handled = await handleGoogleChatWebhookRequest(req, res); + if (!handled && !res.headersSent) { + res.statusCode = 404; + res.setHeader("Content-Type", "text/plain; charset=utf-8"); + res.end("Not Found"); + } + }, + }, + }).unregister; +} + +export async function handleGoogleChatWebhookRequest( + req: IncomingMessage, + res: ServerResponse, +): Promise { + return await googleChatWebhookRequestHandler(req, res); +} diff --git a/extensions/googlechat/src/monitor-types.ts b/extensions/googlechat/src/monitor-types.ts index 26027be5d17..4586ae03534 100644 --- a/extensions/googlechat/src/monitor-types.ts +++ b/extensions/googlechat/src/monitor-types.ts @@ -1,7 +1,7 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import type { GoogleChatAudienceType } from "./auth.js"; -import { getGoogleChatRuntime } from "./runtime.js"; +import type { getGoogleChatRuntime } from "./runtime.js"; export type GoogleChatRuntimeEnv = { log?: (message: string) => void; diff --git a/extensions/googlechat/src/monitor-webhook.test.ts b/extensions/googlechat/src/monitor-webhook.test.ts index e070802e17b..5ee092bc21a 100644 --- a/extensions/googlechat/src/monitor-webhook.test.ts +++ b/extensions/googlechat/src/monitor-webhook.test.ts @@ -8,8 +8,11 @@ const resolveWebhookTargetWithAuthOrReject = vi.hoisted(() => vi.fn()); const withResolvedWebhookRequestPipeline = vi.hoisted(() => vi.fn()); const verifyGoogleChatRequest = vi.hoisted(() => vi.fn()); -vi.mock("../runtime-api.js", () => ({ +vi.mock("openclaw/plugin-sdk/webhook-request-guards", () => ({ readJsonWebhookBodyOrReject, +})); + +vi.mock("openclaw/plugin-sdk/webhook-targets", () => ({ resolveWebhookTargetWithAuthOrReject, withResolvedWebhookRequestPipeline, })); diff --git a/extensions/googlechat/src/monitor-webhook.ts b/extensions/googlechat/src/monitor-webhook.ts index 939bc7c211e..b5020d8a27e 100644 --- a/extensions/googlechat/src/monitor-webhook.ts +++ b/extensions/googlechat/src/monitor-webhook.ts @@ -1,11 +1,10 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import type { WebhookInFlightLimiter } from "openclaw/plugin-sdk/webhook-request-guards"; +import { readJsonWebhookBodyOrReject } from "openclaw/plugin-sdk/webhook-request-guards"; import { - readJsonWebhookBodyOrReject, resolveWebhookTargetWithAuthOrReject, withResolvedWebhookRequestPipeline, - type WebhookInFlightLimiter, -} from "../runtime-api.js"; +} from "openclaw/plugin-sdk/webhook-targets"; import { verifyGoogleChatRequest } from "./auth.js"; import type { WebhookTarget } from "./monitor-types.js"; import type { @@ -15,6 +14,10 @@ import type { GoogleChatUser, } from "./types.js"; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + function extractBearerToken(header: unknown): string { const authHeader = Array.isArray(header) ? typeof header[0] === "string" diff --git a/extensions/googlechat/src/monitor.ts b/extensions/googlechat/src/monitor.ts index bc916c80641..941246cfc36 100644 --- a/extensions/googlechat/src/monitor.ts +++ b/extensions/googlechat/src/monitor.ts @@ -1,4 +1,3 @@ -import type { IncomingMessage, ServerResponse } from "node:http"; import { deliverTextOrMediaReply, resolveSendableOutboundReplyParts, @@ -7,8 +6,6 @@ import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runti import type { OpenClawConfig } from "../runtime-api.js"; import { createChannelReplyPipeline, - createWebhookInFlightLimiter, - registerWebhookTargetWithPluginRoute, resolveInboundRouteEnvelopeBuilderWithRuntime, resolveWebhookPath, } from "../runtime-api.js"; @@ -21,27 +18,27 @@ import { } from "./api.js"; import { type GoogleChatAudienceType } from "./auth.js"; import { applyGoogleChatInboundAccessPolicy, isSenderAllowed } from "./monitor-access.js"; +import { + handleGoogleChatWebhookRequest, + registerGoogleChatWebhookTarget, + setGoogleChatWebhookEventProcessor, +} from "./monitor-routing.js"; import type { GoogleChatCoreRuntime, GoogleChatMonitorOptions, GoogleChatRuntimeEnv, WebhookTarget, } from "./monitor-types.js"; -import { createGoogleChatWebhookRequestHandler } from "./monitor-webhook.js"; import { getGoogleChatRuntime } from "./runtime.js"; import type { GoogleChatAttachment, GoogleChatEvent } from "./types.js"; export type { GoogleChatMonitorOptions, GoogleChatRuntimeEnv } from "./monitor-types.js"; +export { + handleGoogleChatWebhookRequest, + registerGoogleChatWebhookTarget, +} from "./monitor-routing.js"; export { isSenderAllowed }; -const webhookTargets = new Map(); -const webhookInFlightLimiter = createWebhookInFlightLimiter(); -const googleChatWebhookRequestHandler = createGoogleChatWebhookRequestHandler({ - webhookTargets, - webhookInFlightLimiter, - processEvent: async (event, target) => { - await processGoogleChatEvent(event, target); - }, -}); +setGoogleChatWebhookEventProcessor(processGoogleChatEvent); function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, message: string) { if (core.logging.shouldLogVerbose()) { @@ -49,29 +46,6 @@ function logVerbose(core: GoogleChatCoreRuntime, runtime: GoogleChatRuntimeEnv, } } -export function registerGoogleChatWebhookTarget(target: WebhookTarget): () => void { - return registerWebhookTargetWithPluginRoute({ - targetsByPath: webhookTargets, - target, - route: { - auth: "plugin", - match: "exact", - pluginId: "googlechat", - source: "googlechat-webhook", - accountId: target.account.accountId, - log: target.runtime.log, - handler: async (req, res) => { - const handled = await handleGoogleChatWebhookRequest(req, res); - if (!handled && !res.headersSent) { - res.statusCode = 404; - res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end("Not Found"); - } - }, - }, - }).unregister; -} - function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | undefined { const normalized = normalizeOptionalLowercaseString(value); if (normalized === "app-url" || normalized === "app_url" || normalized === "app") { @@ -87,13 +61,6 @@ function normalizeAudienceType(value?: string | null): GoogleChatAudienceType | return undefined; } -export async function handleGoogleChatWebhookRequest( - req: IncomingMessage, - res: ServerResponse, -): Promise { - return await googleChatWebhookRequestHandler(req, res); -} - async function processGoogleChatEvent(event: GoogleChatEvent, target: WebhookTarget) { const eventType = event.type ?? (event as { eventType?: string }).eventType; if (eventType !== "MESSAGE") { diff --git a/extensions/googlechat/src/monitor.webhook-routing.test.ts b/extensions/googlechat/src/monitor.webhook-routing.test.ts index 6fdfa1e879f..44c839ca9de 100644 --- a/extensions/googlechat/src/monitor.webhook-routing.test.ts +++ b/extensions/googlechat/src/monitor.webhook-routing.test.ts @@ -7,7 +7,10 @@ import { createMockServerResponse } from "../../../test/helpers/plugins/mock-htt import type { OpenClawConfig, PluginRuntime } from "../runtime-api.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { verifyGoogleChatRequest } from "./auth.js"; -import { handleGoogleChatWebhookRequest, registerGoogleChatWebhookTarget } from "./monitor.js"; +import { + handleGoogleChatWebhookRequest, + registerGoogleChatWebhookTarget, +} from "./monitor-routing.js"; vi.mock("./auth.js", () => ({ verifyGoogleChatRequest: vi.fn(), diff --git a/extensions/googlechat/src/sender-allow.ts b/extensions/googlechat/src/sender-allow.ts new file mode 100644 index 00000000000..037527010a9 --- /dev/null +++ b/extensions/googlechat/src/sender-allow.ts @@ -0,0 +1,48 @@ +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function normalizeUserId(raw?: string | null): string { + const trimmed = typeof raw === "string" ? raw.trim() : ""; + if (!trimmed) { + return ""; + } + return normalizeLowercaseStringOrEmpty(trimmed.replace(/^users\//i, "")); +} + +function isEmailLike(value: string): boolean { + // Keep this intentionally loose; allowlists are user-provided config. + return value.includes("@"); +} + +export function isSenderAllowed( + senderId: string, + senderEmail: string | undefined, + allowFrom: string[], + allowNameMatching = false, +) { + if (allowFrom.includes("*")) { + return true; + } + const normalizedSenderId = normalizeUserId(senderId); + const normalizedEmail = normalizeLowercaseStringOrEmpty(senderEmail ?? ""); + return allowFrom.some((entry) => { + const normalized = normalizeLowercaseStringOrEmpty(entry); + if (!normalized) { + return false; + } + + // Accept `googlechat:` but treat `users/...` as an *ID* only (deprecated `users/`). + const withoutPrefix = normalized.replace(/^(googlechat|google-chat|gchat):/i, ""); + if (withoutPrefix.startsWith("users/")) { + return normalizeUserId(withoutPrefix) === normalizedSenderId; + } + + // Raw email allowlist entries are a break-glass override. + if (allowNameMatching && normalizedEmail && isEmailLike(withoutPrefix)) { + return withoutPrefix === normalizedEmail; + } + + return withoutPrefix.replace(/^users\//i, "") === normalizedSenderId; + }); +} diff --git a/extensions/googlechat/src/setup-surface.ts b/extensions/googlechat/src/setup-surface.ts index 0f8d9ef7db4..0681211a48e 100644 --- a/extensions/googlechat/src/setup-surface.ts +++ b/extensions/googlechat/src/setup-surface.ts @@ -11,10 +11,6 @@ import { type ChannelSetupDmPolicy, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; -import { - normalizeOptionalString, - normalizeStringifiedOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; import { resolveDefaultGoogleChatAccountId, resolveGoogleChatAccount } from "./accounts.js"; const channel = "googlechat" as const; @@ -23,6 +19,24 @@ const ENV_SERVICE_ACCOUNT_FILE = "GOOGLE_CHAT_SERVICE_ACCOUNT_FILE"; const USE_ENV_FLAG = "__googlechatUseEnv"; const AUTH_METHOD_FLAG = "__googlechatAuthMethod"; +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed : undefined; +} + +function normalizeStringifiedOptionalString(value: unknown): string | undefined { + if (typeof value === "string") { + return normalizeOptionalString(value); + } + if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") { + return normalizeOptionalString(String(value)); + } + return undefined; +} + const promptAllowFrom = createPromptParsedAllowFromForAccount({ defaultAccountId: resolveDefaultGoogleChatAccountId, message: "Google Chat allowFrom (users/ or raw email; avoid users/)", diff --git a/extensions/googlechat/src/setup.test.ts b/extensions/googlechat/src/setup.test.ts index 2e4bc7d85dc..1a978394d31 100644 --- a/extensions/googlechat/src/setup.test.ts +++ b/extensions/googlechat/src/setup.test.ts @@ -11,14 +11,14 @@ import { expectLifecyclePatch, expectPendingUntilAbort, startAccountAndTrackLifecycle, - waitForStartedMocks, } from "../../../test/helpers/plugins/start-account-lifecycle.js"; import type { OpenClawConfig } from "../runtime-api.js"; -import { resolveGoogleChatAccount, type ResolvedGoogleChatAccount } from "./accounts.js"; import { listGoogleChatAccountIds, + resolveGoogleChatAccount, resolveDefaultGoogleChatAccountId, -} from "./channel.deps.runtime.js"; + type ResolvedGoogleChatAccount, +} from "./accounts.js"; import { startGoogleChatGatewayAccount } from "./gateway.js"; import { googlechatSetupAdapter } from "./setup-core.js"; import { googlechatSetupWizard } from "./setup-surface.js"; @@ -27,13 +27,16 @@ const hoisted = vi.hoisted(() => ({ startGoogleChatMonitor: vi.fn(), })); -vi.mock("./monitor.js", async () => { - const actual = await vi.importActual("./monitor.js"); - return { - ...actual, +vi.mock("./channel.runtime.js", () => ({ + googleChatChannelRuntime: { + resolveGoogleChatWebhookPath: ({ + account, + }: { + account: { config: { webhookPath?: string } }; + }) => account.config.webhookPath ?? "/googlechat", startGoogleChatMonitor: hoisted.startGoogleChatMonitor, - }; -}); + }, +})); const googlechatSetupPlugin = { id: "googlechat", @@ -65,6 +68,16 @@ function buildAccount(): ResolvedGoogleChatAccount { }; } +async function waitForGoogleChatMonitorStarted() { + for (let attempt = 0; attempt < 10; attempt += 1) { + if (hoisted.startGoogleChatMonitor.mock.calls.length === 1) { + return; + } + await new Promise((resolve) => setImmediate(resolve)); + } + expect(hoisted.startGoogleChatMonitor).toHaveBeenCalledOnce(); +} + describe("googlechat setup", () => { afterEach(() => { vi.clearAllMocks(); @@ -356,7 +369,7 @@ describe("googlechat setup", () => { account: buildAccount(), }); await expectPendingUntilAbort({ - waitForStarted: waitForStartedMocks(hoisted.startGoogleChatMonitor), + waitForStarted: waitForGoogleChatMonitorStarted, isSettled, abort, task, diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts index ed3c88df866..00f29a98792 100644 --- a/extensions/googlechat/src/targets.test.ts +++ b/extensions/googlechat/src/targets.test.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { downloadGoogleChatMedia, sendGoogleChatMessage } from "./api.js"; import { resolveGoogleChatGroupRequireMention } from "./group-policy.js"; -import { isSenderAllowed } from "./monitor.js"; +import { isSenderAllowed } from "./sender-allow.js"; import { isGoogleChatSpaceTarget, isGoogleChatUserTarget, @@ -18,10 +18,8 @@ const mocks = vi.hoisted(() => ({ getGoogleChatAccessToken: vi.fn().mockResolvedValue("token"), })); -vi.mock("../runtime-api.js", async () => { - const actual = await vi.importActual("../runtime-api.js"); +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => { return { - ...actual, fetchWithSsrFGuard: mocks.fetchWithSsrFGuard, }; }); diff --git a/extensions/googlechat/src/targets.ts b/extensions/googlechat/src/targets.ts index cf092cfab4d..64b4b599a7e 100644 --- a/extensions/googlechat/src/targets.ts +++ b/extensions/googlechat/src/targets.ts @@ -1,7 +1,10 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { findGoogleChatDirectMessage } from "./api.js"; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + export function normalizeGoogleChatTarget(raw?: string | null): string | undefined { const trimmed = raw?.trim(); if (!trimmed) { diff --git a/extensions/lmstudio/src/stream.test.ts b/extensions/lmstudio/src/stream.test.ts index 73098d90618..75ea243af20 100644 --- a/extensions/lmstudio/src/stream.test.ts +++ b/extensions/lmstudio/src/stream.test.ts @@ -1,10 +1,7 @@ import type { StreamFn } from "@mariozechner/pi-agent-core"; import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - __resetLmstudioPreloadCooldownForTest, - wrapLmstudioInferencePreload, -} from "./stream.js"; +import { __resetLmstudioPreloadCooldownForTest, wrapLmstudioInferencePreload } from "./stream.js"; const ensureLmstudioModelLoadedMock = vi.hoisted(() => vi.fn()); const resolveLmstudioProviderHeadersMock = vi.hoisted(() => diff --git a/extensions/lmstudio/src/stream.ts b/extensions/lmstudio/src/stream.ts index cbfd2674301..ae2567cfb1e 100644 --- a/extensions/lmstudio/src/stream.ts +++ b/extensions/lmstudio/src/stream.ts @@ -241,10 +241,7 @@ export function wrapLmstudioInferencePreload(ctx: ProviderWrapStreamFnContext): }; const cause = annotated.cause ?? error; const failures = annotated.consecutiveFailures ?? 1; - const cooldownSec = Math.max( - 0, - Math.round((annotated.cooldownMs ?? 0) / 1000), - ); + const cooldownSec = Math.max(0, Math.round((annotated.cooldownMs ?? 0) / 1000)); log.warn( `LM Studio inference preload failed for "${modelKey}" (${failures} consecutive failure${ failures === 1 ? "" : "s" diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts index f3fc90a0943..53ac2c2157d 100644 --- a/extensions/nextcloud-talk/src/accounts.ts +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -1,18 +1,26 @@ -import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution"; -import { tryReadSecretFileSync } from "openclaw/plugin-sdk/channel-core"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; import { createAccountListHelpers, DEFAULT_ACCOUNT_ID, normalizeAccountId, resolveAccountWithDefaultFallback, -} from "../runtime-api.js"; + resolveMergedAccountConfig, +} from "openclaw/plugin-sdk/account-core"; +import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime"; import { normalizeResolvedSecretInputString } from "./secret-input.js"; import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js"; +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return normalizeOptionalString(value)?.toLowerCase() ?? ""; +} + function isTruthyEnvValue(value?: string): boolean { const normalized = normalizeLowercaseStringOrEmpty(value); return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on"; diff --git a/extensions/nextcloud-talk/src/channel-api.ts b/extensions/nextcloud-talk/src/channel-api.ts index 6aaee922dcc..ea880fc4743 100644 --- a/extensions/nextcloud-talk/src/channel-api.ts +++ b/extensions/nextcloud-talk/src/channel-api.ts @@ -1,5 +1,5 @@ -export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; +export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common"; export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -export { clearAccountEntryFields } from "openclaw/plugin-sdk/channel-core"; +export { clearAccountEntryFields } from "openclaw/plugin-sdk/channel-plugin-common"; export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema"; diff --git a/extensions/nextcloud-talk/src/channel.adapters.ts b/extensions/nextcloud-talk/src/channel.adapters.ts index abaec882394..440937ec09d 100644 --- a/extensions/nextcloud-talk/src/channel.adapters.ts +++ b/extensions/nextcloud-talk/src/channel.adapters.ts @@ -5,7 +5,6 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, @@ -14,6 +13,10 @@ import { } from "./accounts.js"; import type { CoreConfig } from "./types.js"; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + export const nextcloudTalkConfigAdapter = createScopedChannelConfigAdapter< ResolvedNextcloudTalkAccount, ResolvedNextcloudTalkAccount, diff --git a/extensions/nextcloud-talk/src/core.test.ts b/extensions/nextcloud-talk/src/core.test.ts index ae931baee9a..10601bcb80a 100644 --- a/extensions/nextcloud-talk/src/core.test.ts +++ b/extensions/nextcloud-talk/src/core.test.ts @@ -1,7 +1,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; import { looksLikeNextcloudTalkTargetId, normalizeNextcloudTalkMessagingTarget, @@ -16,39 +16,9 @@ import { verifyNextcloudTalkSignature, } from "./signature.js"; -const fetchWithSsrFGuard = vi.hoisted(() => vi.fn()); -const readFileSync = vi.hoisted(() => vi.fn()); - -vi.mock("../runtime-api.js", () => { - return vi - .importActual("../runtime-api.js") - .then((actual) => ({ - ...actual, - fetchWithSsrFGuard, - })); -}); - -vi.mock("node:fs", () => { - return vi.importActual("node:fs").then((actual) => ({ - ...actual, - readFileSync, - })); -}); - const tempDirs: string[] = []; -let resolveNextcloudTalkRoomKind: typeof import("./room-info.js").resolveNextcloudTalkRoomKind; -let resetNextcloudTalkRoomCache: () => void; - -beforeAll(async () => { - const roomInfo = await import("./room-info.js"); - resolveNextcloudTalkRoomKind = roomInfo.resolveNextcloudTalkRoomKind; - resetNextcloudTalkRoomCache = roomInfo.__testing.resetRoomCache; -}); afterEach(async () => { - fetchWithSsrFGuard.mockReset(); - readFileSync.mockReset(); - resetNextcloudTalkRoomCache(); while (tempDirs.length > 0) { const dir = tempDirs.pop(); if (dir) { @@ -161,7 +131,7 @@ describe("nextcloud talk core", () => { ).toBeNull(); }); - it("persists replay decisions across guard instances", async () => { + it("persists replay decisions across guard instances and scopes account namespaces", async () => { const stateDir = await makeTempDir(); const firstGuard = createNextcloudTalkReplayGuard({ stateDir }); @@ -182,34 +152,20 @@ describe("nextcloud talk core", () => { roomToken: "room-1", messageId: "msg-1", }); + const otherAccountFirstAttempt = await secondGuard.shouldProcessMessage({ + accountId: "account-b", + roomToken: "room-1", + messageId: "msg-1", + }); expect(firstAttempt).toBe(true); expect(replayAttempt).toBe(false); expect(restartReplayAttempt).toBe(false); - }); - - it("scopes replay state by account namespace", async () => { - const stateDir = await makeTempDir(); - const guard = createNextcloudTalkReplayGuard({ stateDir }); - - const accountAFirst = await guard.shouldProcessMessage({ - accountId: "account-a", - roomToken: "room-1", - messageId: "msg-9", - }); - const accountBFirst = await guard.shouldProcessMessage({ - accountId: "account-b", - roomToken: "room-1", - messageId: "msg-9", - }); - - expect(accountAFirst).toBe(true); - expect(accountBFirst).toBe(true); + expect(otherAccountFirstAttempt).toBe(true); }); it("releases in-flight replay claims when processing fails", async () => { - const stateDir = await makeTempDir(); - const guard = createNextcloudTalkReplayGuard({ stateDir }); + const guard = createNextcloudTalkReplayGuard({}); const firstClaim = await guard.claimMessage({ accountId: "account-a", @@ -345,91 +301,4 @@ describe("nextcloud talk core", () => { innerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" }, }); }); - - it("resolves direct rooms from the room info endpoint", async () => { - const release = vi.fn(async () => {}); - fetchWithSsrFGuard.mockResolvedValue({ - response: { - ok: true, - json: async () => ({ - ocs: { - data: { - type: 1, - }, - }, - }), - }, - release, - }); - - const kind = await resolveNextcloudTalkRoomKind({ - account: { - accountId: "acct-direct", - baseUrl: "https://nc.example.com", - config: { - apiUser: "bot", - apiPassword: "secret", - }, - } as never, - roomToken: "room-direct", - }); - - expect(kind).toBe("direct"); - expect(fetchWithSsrFGuard).toHaveBeenCalledWith( - expect.objectContaining({ - url: "https://nc.example.com/ocs/v2.php/apps/spreed/api/v4/room/room-direct", - auditContext: "nextcloud-talk.room-info", - }), - ); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("reads the api password from a file and logs non-ok room info responses", async () => { - const release = vi.fn(async () => {}); - const log = vi.fn(); - const error = vi.fn(); - const exit = vi.fn(); - readFileSync.mockReturnValue("file-secret\n"); - fetchWithSsrFGuard.mockResolvedValue({ - response: { - ok: false, - status: 403, - json: async () => ({}), - }, - release, - }); - - const kind = await resolveNextcloudTalkRoomKind({ - account: { - accountId: "acct-group", - baseUrl: "https://nc.example.com", - config: { - apiUser: "bot", - apiPasswordFile: "/tmp/nextcloud-secret", - }, - } as never, - roomToken: "room-group", - runtime: { log, error, exit }, - }); - - expect(kind).toBeUndefined(); - expect(readFileSync).toHaveBeenCalledWith("/tmp/nextcloud-secret", "utf-8"); - expect(log).toHaveBeenCalledWith("nextcloud-talk: room lookup failed (403) token=room-group"); - expect(release).toHaveBeenCalledTimes(1); - }); - - it("returns undefined from room info without credentials or base url", async () => { - await expect( - resolveNextcloudTalkRoomKind({ - account: { - accountId: "acct-missing", - baseUrl: "", - config: {}, - } as never, - roomToken: "room-missing", - }), - ).resolves.toBeUndefined(); - - expect(fetchWithSsrFGuard).not.toHaveBeenCalled(); - }); }); diff --git a/extensions/nextcloud-talk/src/monitor.replay.test.ts b/extensions/nextcloud-talk/src/monitor.replay.test.ts index 509477720c1..9af0996b7e3 100644 --- a/extensions/nextcloud-talk/src/monitor.replay.test.ts +++ b/extensions/nextcloud-talk/src/monitor.replay.test.ts @@ -1,7 +1,4 @@ -import fs from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js"; import { NextcloudTalkRetryableWebhookError, @@ -14,17 +11,6 @@ import { createNextcloudTalkReplayGuard } from "./replay-guard.js"; import { generateNextcloudTalkSignature } from "./signature.js"; import type { NextcloudTalkInboundMessage } from "./types.js"; -const tempDirs: string[] = []; - -afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) { - fs.rmSync(dir, { recursive: true, force: true }); - } - } -}); - describe("readNextcloudTalkWebhookBody", () => { it("reads valid body within max bytes", async () => { const req = createMockIncomingRequest(['{"type":"Create"}']); @@ -89,22 +75,35 @@ describe("createNextcloudTalkWebhookServer backend allowlist", () => { }); describe("createNextcloudTalkWebhookServer replay handling", () => { - function createReplayAwareProcessMessage(params: { - stateDir: string; + function createReplayGuardedProcess(params: { + stateDir?: string; accountId?: string; - handleMessage: (message: NextcloudTalkInboundMessage) => Promise; + handleMessage: () => Promise; }) { - const replayGuard = createNextcloudTalkReplayGuard({ - stateDir: params.stateDir, - }); + const replayGuard = createNextcloudTalkReplayGuard( + params.stateDir ? { stateDir: params.stateDir } : {}, + ); - return async (message: NextcloudTalkInboundMessage): Promise => { - await processNextcloudTalkReplayGuardedMessage({ + return (message: NextcloudTalkInboundMessage) => + processNextcloudTalkReplayGuardedMessage({ replayGuard, accountId: params.accountId ?? "acct", message, - handleMessage: () => params.handleMessage(message), + handleMessage: params.handleMessage, }); + } + + function buildInboundMessage(): NextcloudTalkInboundMessage { + return { + messageId: "msg-1", + roomToken: "room-token", + roomName: "Room 1", + senderId: "alice", + senderName: "Alice", + text: "hello", + mediaType: "text/plain", + timestamp: 1_700_000_000_000, + isGroupChat: true, }; } @@ -143,91 +142,41 @@ describe("createNextcloudTalkWebhookServer replay handling", () => { expect(onMessage).toHaveBeenCalledTimes(1); }); - it("allows a retry after processMessage fails before replay commit", async () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nextcloud-talk-replay-")); - tempDirs.push(stateDir); + it("allows a retry after replay-guarded processing fails before commit", async () => { let attempts = 0; - const onError = vi.fn(); const handleMessage = vi.fn(async () => { attempts += 1; if (attempts === 1) { throw new NextcloudTalkRetryableWebhookError("transient nextcloud failure"); } }); - const processMessage = vi.fn( - createReplayAwareProcessMessage({ - stateDir, - handleMessage, - }), - ); - const harness = await startWebhookServer({ - path: "/nextcloud-replay-process", - processMessage, - onMessage: vi.fn(), - onError, + const processMessage = createReplayGuardedProcess({ + handleMessage, }); + const message = buildInboundMessage(); - const { body, headers } = createSignedCreateMessageRequest(); + await expect(processMessage(message)).rejects.toThrow("transient nextcloud failure"); + await expect(processMessage(message)).resolves.toBe("processed"); - const first = await fetch(harness.webhookUrl, { - method: "POST", - headers, - body, - }); - await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); - const second = await fetch(harness.webhookUrl, { - method: "POST", - headers, - body, - }); - - expect(first.status).toBe(200); - expect(second.status).toBe(200); - await vi.waitFor(() => expect(handleMessage).toHaveBeenCalledTimes(2)); - expect(onError).toHaveBeenCalledTimes(1); + expect(handleMessage).toHaveBeenCalledTimes(2); }); - it("keeps replay committed after a non-retryable processMessage failure", async () => { - const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "nextcloud-talk-replay-")); - tempDirs.push(stateDir); - const onError = vi.fn(); + it("keeps replay committed after a non-retryable replay-guarded processing failure", async () => { const visibleSideEffect = vi.fn(); const handleMessage = vi.fn(async () => { visibleSideEffect(); throw new Error("post-send failure"); }); - const processMessage = vi.fn( - createReplayAwareProcessMessage({ - stateDir, - handleMessage, - }), - ); - const harness = await startWebhookServer({ - path: "/nextcloud-replay-post-send", - processMessage, - onMessage: vi.fn(), - onError, + const processMessage = createReplayGuardedProcess({ + handleMessage, }); + const message = buildInboundMessage(); - const { body, headers } = createSignedCreateMessageRequest(); + await expect(processMessage(message)).rejects.toThrow("post-send failure"); + await expect(processMessage(message)).resolves.toBe("duplicate"); - const first = await fetch(harness.webhookUrl, { - method: "POST", - headers, - body, - }); - await vi.waitFor(() => expect(onError).toHaveBeenCalledTimes(1)); - const second = await fetch(harness.webhookUrl, { - method: "POST", - headers, - body, - }); - - expect(first.status).toBe(200); - expect(second.status).toBe(200); expect(handleMessage).toHaveBeenCalledTimes(1); expect(visibleSideEffect).toHaveBeenCalledTimes(1); - expect(onError).toHaveBeenCalledTimes(1); }); }); @@ -273,7 +222,7 @@ describe("createNextcloudTalkWebhookServer payload validation", () => { describe("createNextcloudTalkWebhookServer auth rate limiting", () => { it("rate limits repeated invalid signature attempts from the same source", async () => { - const maxRequests = 2; + const maxRequests = 1; const harness = await startWebhookServer({ path: "/nextcloud-auth-rate-limit", authRateLimit: { maxRequests }, @@ -307,7 +256,7 @@ describe("createNextcloudTalkWebhookServer auth rate limiting", () => { }); it("does not rate limit valid signed webhook bursts from the same source", async () => { - const maxRequests = 2; + const maxRequests = 1; const harness = await startWebhookServer({ path: "/nextcloud-auth-rate-limit-valid", authRateLimit: { maxRequests }, diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts index 9ebff45cc62..468d8af6927 100644 --- a/extensions/nextcloud-talk/src/monitor.ts +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -2,12 +2,12 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse } import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared"; import { WEBHOOK_RATE_LIMIT_DEFAULTS, + createAuthRateLimiter, isRequestBodyLimitError, readRequestBodyWithLimit, requestBodyErrorToText, } from "openclaw/plugin-sdk/webhook-ingress"; import { z } from "zod"; -import { createAuthRateLimiter } from "./api.js"; import type { NextcloudTalkReplayGuard } from "./replay-guard.js"; import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js"; import type { diff --git a/extensions/nextcloud-talk/src/normalize.ts b/extensions/nextcloud-talk/src/normalize.ts index 4ac56462b55..295caadd8a4 100644 --- a/extensions/nextcloud-talk/src/normalize.ts +++ b/extensions/nextcloud-talk/src/normalize.ts @@ -1,5 +1,3 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; - export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined { const trimmed = raw.trim(); if (!trimmed) { @@ -29,7 +27,7 @@ export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined { const normalized = stripNextcloudTalkTargetPrefix(raw); - return normalized ? normalizeLowercaseStringOrEmpty(`nextcloud-talk:${normalized}`) : undefined; + return normalized ? `nextcloud-talk:${normalized}`.toLowerCase() : undefined; } export function looksLikeNextcloudTalkTargetId(raw: string): boolean { diff --git a/extensions/nextcloud-talk/src/policy.ts b/extensions/nextcloud-talk/src/policy.ts index 925cca9ce48..1d057cecdf1 100644 --- a/extensions/nextcloud-talk/src/policy.ts +++ b/extensions/nextcloud-talk/src/policy.ts @@ -1,22 +1,23 @@ -import { resolveInboundMentionDecision } from "openclaw/plugin-sdk/channel-inbound"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; +import { + buildChannelKeyCandidates, + normalizeChannelSlug, + resolveChannelEntryMatchWithFallback, + resolveNestedAllowlistDecision, +} from "openclaw/plugin-sdk/channel-targets"; +import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access"; import type { AllowlistMatch, ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig, } from "../runtime-api.js"; -import { - buildChannelKeyCandidates, - evaluateMatchedGroupAccessForPolicy, - normalizeChannelSlug, - resolveChannelEntryMatchWithFallback, - resolveNestedAllowlistDecision, -} from "../runtime-api.js"; import type { NextcloudTalkRoomConfig } from "./types.js"; function normalizeAllowEntry(raw: string): string { - return normalizeLowercaseStringOrEmpty(raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, "")); + return raw + .trim() + .replace(/^(nextcloud-talk|nc-talk|nc):/i, "") + .toLowerCase(); } export function normalizeNextcloudTalkAllowlist( @@ -165,19 +166,15 @@ export function resolveNextcloudTalkMentionGate(params: { hasControlCommand: boolean; commandAuthorized: boolean; }): { shouldSkip: boolean; shouldBypassMention: boolean } { - const result = resolveInboundMentionDecision({ - facts: { - canDetectMention: true, - wasMentioned: params.wasMentioned, - implicitMentionKinds: [], - }, - policy: { - isGroup: params.isGroup, - requireMention: params.requireMention, - allowTextCommands: params.allowTextCommands, - hasControlCommand: params.hasControlCommand, - commandAuthorized: params.commandAuthorized, - }, - }); - return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention }; + const shouldBypassMention = + params.isGroup && + params.requireMention && + !params.wasMentioned && + params.allowTextCommands && + params.commandAuthorized && + params.hasControlCommand; + return { + shouldBypassMention, + shouldSkip: params.requireMention && !params.wasMentioned && !shouldBypassMention, + }; } diff --git a/extensions/nextcloud-talk/src/replay-guard.ts b/extensions/nextcloud-talk/src/replay-guard.ts index d0bf6e30094..24abfb47bcc 100644 --- a/extensions/nextcloud-talk/src/replay-guard.ts +++ b/extensions/nextcloud-talk/src/replay-guard.ts @@ -23,7 +23,7 @@ function buildReplayKey(params: { roomToken: string; messageId: string }): strin } export type NextcloudTalkReplayGuardOptions = { - stateDir: string; + stateDir?: string; ttlMs?: number; memoryMaxSize?: number; fileMaxEntries?: number; @@ -57,15 +57,27 @@ export type NextcloudTalkReplayGuard = { export function createNextcloudTalkReplayGuard( options: NextcloudTalkReplayGuardOptions, ): NextcloudTalkReplayGuard { - const stateDir = options.stateDir.trim(); - const dedupe = createClaimableDedupe({ + const stateDir = options.stateDir?.trim(); + const baseOptions = { ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS, memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE, - fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES, - resolveFilePath: (namespace) => - path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`), - onDiskError: options.onDiskError, - }); + }; + const dedupe = createClaimableDedupe( + stateDir + ? { + ...baseOptions, + fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES, + resolveFilePath: (namespace) => + path.join( + stateDir, + "nextcloud-talk", + "replay-dedupe", + `${sanitizeSegment(namespace)}.json`, + ), + onDiskError: options.onDiskError, + } + : baseOptions, + ); return { claimMessage: async ({ accountId, roomToken, messageId }) => { diff --git a/extensions/nextcloud-talk/src/room-info.test.ts b/extensions/nextcloud-talk/src/room-info.test.ts new file mode 100644 index 00000000000..57e5e5bc40c --- /dev/null +++ b/extensions/nextcloud-talk/src/room-info.test.ts @@ -0,0 +1,116 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveNextcloudTalkRoomKind, __testing } from "./room-info.js"; + +const fetchWithSsrFGuard = vi.hoisted(() => vi.fn()); +const readFileSync = vi.hoisted(() => vi.fn()); + +vi.mock("../runtime-api.js", () => { + return vi + .importActual("../runtime-api.js") + .then((actual) => ({ + ...actual, + fetchWithSsrFGuard, + })); +}); + +vi.mock("node:fs", () => { + return vi.importActual("node:fs").then((actual) => ({ + ...actual, + readFileSync, + })); +}); + +afterEach(() => { + fetchWithSsrFGuard.mockReset(); + readFileSync.mockReset(); + __testing.resetRoomCache(); +}); + +describe("nextcloud talk room info", () => { + it("resolves direct rooms from the room info endpoint", async () => { + const release = vi.fn(async () => {}); + fetchWithSsrFGuard.mockResolvedValue({ + response: { + ok: true, + json: async () => ({ + ocs: { + data: { + type: 1, + }, + }, + }), + }, + release, + }); + + const kind = await resolveNextcloudTalkRoomKind({ + account: { + accountId: "acct-direct", + baseUrl: "https://nc.example.com", + config: { + apiUser: "bot", + apiPassword: "secret", + }, + } as never, + roomToken: "room-direct", + }); + + expect(kind).toBe("direct"); + expect(fetchWithSsrFGuard).toHaveBeenCalledWith( + expect.objectContaining({ + url: "https://nc.example.com/ocs/v2.php/apps/spreed/api/v4/room/room-direct", + auditContext: "nextcloud-talk.room-info", + }), + ); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("reads the api password from a file and logs non-ok room info responses", async () => { + const release = vi.fn(async () => {}); + const log = vi.fn(); + const error = vi.fn(); + const exit = vi.fn(); + readFileSync.mockReturnValue("file-secret\n"); + fetchWithSsrFGuard.mockResolvedValue({ + response: { + ok: false, + status: 403, + json: async () => ({}), + }, + release, + }); + + const kind = await resolveNextcloudTalkRoomKind({ + account: { + accountId: "acct-group", + baseUrl: "https://nc.example.com", + config: { + apiUser: "bot", + apiPasswordFile: "/tmp/nextcloud-secret", + }, + } as never, + roomToken: "room-group", + runtime: { log, error, exit }, + }); + + expect(kind).toBeUndefined(); + expect(readFileSync).toHaveBeenCalledWith("/tmp/nextcloud-secret", "utf-8"); + expect(log).toHaveBeenCalledWith("nextcloud-talk: room lookup failed (403) token=room-group"); + expect(release).toHaveBeenCalledTimes(1); + }); + + it("returns undefined from room info without credentials or base url", async () => { + await expect( + resolveNextcloudTalkRoomKind({ + account: { + accountId: "acct-missing", + baseUrl: "", + config: {}, + } as never, + roomToken: "room-missing", + }), + ).resolves.toBeUndefined(); + + expect(fetchWithSsrFGuard).not.toHaveBeenCalled(); + }); +}); diff --git a/extensions/nextcloud-talk/src/session-route.ts b/extensions/nextcloud-talk/src/session-route.ts index 7ff7c498b8d..6ebc143ecbf 100644 --- a/extensions/nextcloud-talk/src/session-route.ts +++ b/extensions/nextcloud-talk/src/session-route.ts @@ -1,17 +1,22 @@ -import { - buildChannelOutboundSessionRoute, - type ChannelOutboundSessionRouteParams, -} from "openclaw/plugin-sdk/channel-core"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; +import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/routing"; import { stripNextcloudTalkTargetPrefix } from "./normalize.js"; +type NextcloudTalkOutboundSessionRouteParams = { + cfg: OpenClawConfig; + agentId: string; + accountId?: string | null; + target: string; +}; + export function resolveNextcloudTalkOutboundSessionRoute( - params: ChannelOutboundSessionRouteParams, + params: NextcloudTalkOutboundSessionRouteParams, ) { const roomId = stripNextcloudTalkTargetPrefix(params.target); if (!roomId) { return null; } - return buildChannelOutboundSessionRoute({ + const baseSessionKey = buildOutboundBaseSessionKey({ cfg: params.cfg, agentId: params.agentId, channel: "nextcloud-talk", @@ -20,8 +25,16 @@ export function resolveNextcloudTalkOutboundSessionRoute( kind: "group", id: roomId, }, - chatType: "group", + }); + return { + sessionKey: baseSessionKey, + baseSessionKey, + peer: { + kind: "group" as const, + id: roomId, + }, + chatType: "group" as const, from: `nextcloud-talk:room:${roomId}`, to: `nextcloud-talk:${roomId}`, - }); + }; } diff --git a/extensions/nextcloud-talk/src/setup-core.ts b/extensions/nextcloud-talk/src/setup-core.ts index 85b76ad37ad..30bf537202b 100644 --- a/extensions/nextcloud-talk/src/setup-core.ts +++ b/extensions/nextcloud-talk/src/setup-core.ts @@ -1,6 +1,10 @@ import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} from "openclaw/plugin-sdk/setup"; import { createSetupInputPresenceValidator, mergeAllowFromEntries, @@ -10,8 +14,6 @@ import { type WizardPrompter, } from "openclaw/plugin-sdk/setup-runtime"; import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { applyAccountNameToChannelSection, patchScopedAccountConfig } from "../runtime-api.js"; import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount } from "./accounts.js"; import type { CoreConfig } from "./types.js"; @@ -24,6 +26,10 @@ type NextcloudSetupInput = ChannelSetupInput & { }; type NextcloudTalkSection = NonNullable["nextcloud-talk"]; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + function addWildcardAllowFrom(allowFrom?: Array | null): string[] { return mergeAllowFromEntries(allowFrom, ["*"]); } diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts index 8d4e7ca16f0..a91f9aae9a9 100644 --- a/extensions/nextcloud-talk/src/setup-surface.ts +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -6,7 +6,6 @@ import { setSetupChannelEnabled, type ChannelSetupWizard, } from "openclaw/plugin-sdk/setup"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { resolveNextcloudTalkAccount } from "./accounts.js"; import { clearNextcloudTalkAccountFields, @@ -21,6 +20,14 @@ import type { CoreConfig } from "./types.js"; const channel = "nextcloud-talk" as const; const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials"; +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + export const nextcloudTalkSetupWizard: ChannelSetupWizard = { channel, stepOrder: "text-first", diff --git a/extensions/nextcloud-talk/src/signature.ts b/extensions/nextcloud-talk/src/signature.ts index 4f84417a82c..b091bccdca4 100644 --- a/extensions/nextcloud-talk/src/signature.ts +++ b/extensions/nextcloud-talk/src/signature.ts @@ -1,11 +1,14 @@ import { createHmac, randomBytes } from "node:crypto"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import type { NextcloudTalkWebhookHeaders } from "./types.js"; const SIGNATURE_HEADER = "x-nextcloud-talk-signature"; const RANDOM_HEADER = "x-nextcloud-talk-random"; const BACKEND_HEADER = "x-nextcloud-talk-backend"; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + /** * Verify the HMAC-SHA256 signature of an incoming webhook request. * Signature is calculated as: HMAC-SHA256(random + body, secret) diff --git a/extensions/nostr/src/channel-api.ts b/extensions/nostr/src/channel-api.ts index 10d1e722016..b9ff2292339 100644 --- a/extensions/nostr/src/channel-api.ts +++ b/extensions/nostr/src/channel-api.ts @@ -1,12 +1,15 @@ -export { buildChannelConfigSchema, formatPairingApproveHint } from "openclaw/plugin-sdk/core"; -export type { ChannelOutboundAdapter, ChannelPlugin } from "openclaw/plugin-sdk/core"; -export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core"; +export { + buildChannelConfigSchema, + DEFAULT_ACCOUNT_ID, + formatPairingApproveHint, + type ChannelPlugin, +} from "openclaw/plugin-sdk/channel-plugin-common"; +export type { ChannelOutboundAdapter } from "openclaw/plugin-sdk/channel-contract"; export { collectStatusIssuesFromLastError, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; export { createPreCryptoDirectDmAuthorizer, - dispatchInboundDirectDmWithRuntime, resolveInboundDirectDmAccessWithRuntime, -} from "openclaw/plugin-sdk/direct-dm"; +} from "openclaw/plugin-sdk/direct-dm-access"; diff --git a/extensions/nostr/src/channel.inbound.test.ts b/extensions/nostr/src/channel.inbound.test.ts index d1265654a56..e22da53d019 100644 --- a/extensions/nostr/src/channel.inbound.test.ts +++ b/extensions/nostr/src/channel.inbound.test.ts @@ -17,9 +17,12 @@ const mocks = vi.hoisted(() => ({ vi.mock("./nostr-bus.js", () => ({ DEFAULT_RELAYS: ["wss://relay.example.com"], + startNostrBus: mocks.startNostrBus, +})); + +vi.mock("./nostr-key-utils.js", () => ({ getPublicKeyFromPrivate: vi.fn(() => "bot-pubkey"), normalizePubkey: mocks.normalizePubkey, - startNostrBus: mocks.startNostrBus, })); function createMockBus() { diff --git a/extensions/nostr/src/channel.outbound.test.ts b/extensions/nostr/src/channel.outbound.test.ts index 6dee73580d2..450b3057209 100644 --- a/extensions/nostr/src/channel.outbound.test.ts +++ b/extensions/nostr/src/channel.outbound.test.ts @@ -13,9 +13,12 @@ const mocks = vi.hoisted(() => ({ vi.mock("./nostr-bus.js", () => ({ DEFAULT_RELAYS: ["wss://relay.example.com"], + startNostrBus: mocks.startNostrBus, +})); + +vi.mock("./nostr-key-utils.js", () => ({ getPublicKeyFromPrivate: vi.fn(() => "pubkey"), normalizePubkey: mocks.normalizePubkey, - startNostrBus: mocks.startNostrBus, })); function createCfg() { diff --git a/extensions/nostr/src/channel.test.ts b/extensions/nostr/src/channel.test.ts index e53c7527c93..9bc18fb72ce 100644 --- a/extensions/nostr/src/channel.test.ts +++ b/extensions/nostr/src/channel.test.ts @@ -10,6 +10,7 @@ import { nostrSetupWizard } from "./setup-surface.js"; import { TEST_HEX_PRIVATE_KEY, TEST_SETUP_RELAY_URLS, + buildResolvedNostrAccount, createConfiguredNostrCfg, } from "./test-fixtures.js"; import { listNostrAccountIds, resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js"; @@ -226,7 +227,9 @@ describe("nostrPlugin", () => { dmPolicy: "allowlist", allowFrom: [` nostr:${TEST_HEX_PRIVATE_KEY} `], }); - const account = nostrTestPlugin.config.resolveAccount(cfg, "default"); + const account = buildResolvedNostrAccount({ + config: cfg.channels.nostr, + }); const result = resolveDmPolicy({ cfg, account }); if (!result) { diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 8d4b7dbc20b..8c3472dfaa8 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -25,7 +25,7 @@ import { nostrPairingTextAdapter, startNostrGatewayAccount, } from "./gateway.js"; -import { normalizePubkey } from "./nostr-bus.js"; +import { normalizePubkey } from "./nostr-key-utils.js"; import type { ProfilePublishResult } from "./nostr-profile.js"; import { resolveNostrOutboundSessionRoute } from "./session-route.js"; import { nostrSetupAdapter, nostrSetupWizard } from "./setup-surface.js"; diff --git a/extensions/nostr/src/gateway.ts b/extensions/nostr/src/gateway.ts index e06d6aa6e64..4a54f2f8a7e 100644 --- a/extensions/nostr/src/gateway.ts +++ b/extensions/nostr/src/gateway.ts @@ -4,13 +4,13 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { createPreCryptoDirectDmAuthorizer, DEFAULT_ACCOUNT_ID, - dispatchInboundDirectDmWithRuntime, type ChannelOutboundAdapter, resolveInboundDirectDmAccessWithRuntime, type ChannelPlugin, } from "./channel-api.js"; import type { MetricEvent, MetricsSnapshot } from "./metrics.js"; -import { normalizePubkey, startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; +import { startNostrBus, type NostrBusHandle } from "./nostr-bus.js"; +import { normalizePubkey } from "./nostr-key-utils.js"; import { getNostrRuntime } from "./runtime.js"; import { resolveDefaultNostrAccountId, type ResolvedNostrAccount } from "./types.js"; @@ -148,6 +148,7 @@ export const startNostrGatewayAccount: NostrGatewayStart = async (ctx) => { return; } + const { dispatchInboundDirectDmWithRuntime } = await import("./inbound-direct-dm-runtime.js"); await dispatchInboundDirectDmWithRuntime({ cfg: ctx.cfg, runtime, diff --git a/extensions/nostr/src/inbound-direct-dm-runtime.ts b/extensions/nostr/src/inbound-direct-dm-runtime.ts new file mode 100644 index 00000000000..d1492a4be0f --- /dev/null +++ b/extensions/nostr/src/inbound-direct-dm-runtime.ts @@ -0,0 +1 @@ +export { dispatchInboundDirectDmWithRuntime } from "openclaw/plugin-sdk/direct-dm"; diff --git a/extensions/nostr/src/nostr-bus.fuzz.test.ts b/extensions/nostr/src/nostr-bus.fuzz.test.ts index 58a9656cb80..8065a377e2d 100644 --- a/extensions/nostr/src/nostr-bus.fuzz.test.ts +++ b/extensions/nostr/src/nostr-bus.fuzz.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; import { createMetrics, type MetricName } from "./metrics.js"; -import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-bus.js"; +import { validatePrivateKey, isValidPubkey, normalizePubkey } from "./nostr-key-utils.js"; import { createSeenTracker } from "./seen-tracker.js"; import { TEST_HEX_PRIVATE_KEY } from "./test-fixtures.js"; @@ -26,90 +26,31 @@ function createCollectingMetrics() { describe("validatePrivateKey fuzz", () => { describe("type confusion", () => { - it("rejects null input", () => { - expect(() => validatePrivateKey(null as unknown as string)).toThrow(); - }); - - it("rejects undefined input", () => { - expect(() => validatePrivateKey(undefined as unknown as string)).toThrow(); - }); - - it("rejects number input", () => { - expect(() => validatePrivateKey(123 as unknown as string)).toThrow(); - }); - - it("rejects boolean input", () => { - expect(() => validatePrivateKey(true as unknown as string)).toThrow(); - }); - - it("rejects object input", () => { - expect(() => validatePrivateKey({} as unknown as string)).toThrow(); - }); - - it("rejects array input", () => { - expect(() => validatePrivateKey([] as unknown as string)).toThrow(); - }); - - it("rejects function input", () => { - expect(() => validatePrivateKey((() => {}) as unknown as string)).toThrow(); + it("rejects non-string input", () => { + for (const value of [null, undefined, 123, true, {}, [], () => {}]) { + expect(() => validatePrivateKey(value as unknown as string)).toThrow(); + } }); }); describe("unicode attacks", () => { - it("rejects unicode lookalike characters", () => { - // Using zero-width characters - const withZeroWidth = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u200Bf"; - expect(() => validatePrivateKey(withZeroWidth)).toThrow(); - }); + it("rejects unicode and control-character attacks", () => { + const invalidKeys = [ + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u200Bf", + `\u202E${TEST_HEX_PRIVATE_KEY}`, + "0123456789\u0430bcdef0123456789abcdef0123456789abcdef0123456789abcdef", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab😀", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u0301", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\x00f", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\nf", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\rf", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\tf", + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\ff", + ]; - it("rejects RTL override", () => { - const withRtl = `\u202E${TEST_HEX_PRIVATE_KEY}`; - expect(() => validatePrivateKey(withRtl)).toThrow(); - }); - - it("rejects homoglyph 'a' (Cyrillic а)", () => { - // Using Cyrillic 'а' (U+0430) instead of Latin 'a' - const withCyrillicA = "0123456789\u0430bcdef0123456789abcdef0123456789abcdef0123456789abcdef"; - expect(() => validatePrivateKey(withCyrillicA)).toThrow(); - }); - - it("rejects emoji", () => { - const withEmoji = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789ab😀"; - expect(() => validatePrivateKey(withEmoji)).toThrow(); - }); - - it("rejects combining characters", () => { - // 'a' followed by combining acute accent - const withCombining = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\u0301"; - expect(() => validatePrivateKey(withCombining)).toThrow(); - }); - }); - - describe("injection attempts", () => { - it("rejects null byte injection", () => { - const withNullByte = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\x00f"; - expect(() => validatePrivateKey(withNullByte)).toThrow(); - }); - - it("rejects newline injection", () => { - const withNewline = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\nf"; - expect(() => validatePrivateKey(withNewline)).toThrow(); - }); - - it("rejects carriage return injection", () => { - const withCR = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\rf"; - expect(() => validatePrivateKey(withCR)).toThrow(); - }); - - it("rejects tab injection", () => { - const withTab = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\tf"; - expect(() => validatePrivateKey(withTab)).toThrow(); - }); - - it("rejects form feed injection", () => { - const withFormFeed = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcde\ff"; - expect(() => validatePrivateKey(withFormFeed)).toThrow(); + for (const key of invalidKeys) { + expect(() => validatePrivateKey(key)).toThrow(); + } }); }); @@ -154,34 +95,18 @@ describe("validatePrivateKey fuzz", () => { describe("isValidPubkey fuzz", () => { describe("type confusion", () => { - it("handles null gracefully", () => { - expect(isValidPubkey(null as unknown as string)).toBe(false); - }); - - it("handles undefined gracefully", () => { - expect(isValidPubkey(undefined as unknown as string)).toBe(false); - }); - - it("handles number gracefully", () => { - expect(isValidPubkey(123 as unknown as string)).toBe(false); - }); - - it("handles object gracefully", () => { - expect(isValidPubkey({} as unknown as string)).toBe(false); + it("handles non-string input gracefully", () => { + for (const value of [null, undefined, 123, {}]) { + expect(isValidPubkey(value as unknown as string)).toBe(false); + } }); }); describe("malicious inputs", () => { - it("rejects __proto__ key", () => { - expect(isValidPubkey("__proto__")).toBe(false); - }); - - it("rejects constructor key", () => { - expect(isValidPubkey("constructor")).toBe(false); - }); - - it("rejects toString key", () => { - expect(isValidPubkey("toString")).toBe(false); + it("rejects prototype property names", () => { + for (const value of ["__proto__", "constructor", "toString"]) { + expect(isValidPubkey(value)).toBe(false); + } }); }); }); @@ -192,16 +117,10 @@ describe("isValidPubkey fuzz", () => { describe("normalizePubkey fuzz", () => { describe("prototype pollution attempts", () => { - it("throws for __proto__", () => { - expect(() => normalizePubkey("__proto__")).toThrow(); - }); - - it("throws for constructor", () => { - expect(() => normalizePubkey("constructor")).toThrow(); - }); - - it("throws for prototype", () => { - expect(() => normalizePubkey("prototype")).toThrow(); + it("throws for prototype property names", () => { + for (const value of ["__proto__", "constructor", "prototype"]) { + expect(() => normalizePubkey(value)).toThrow(); + } }); }); @@ -439,109 +358,3 @@ describe("Metrics fuzz", () => { }); }); }); - -// ============================================================================ -// Event Shape Validation (simulating malformed events) -// ============================================================================ - -describe("Event shape validation", () => { - describe("malformed event structures", () => { - // These test what happens if malformed data somehow gets through - - it("identifies missing required fields", () => { - const malformedEvents = [ - {}, // empty - { id: "abc" }, // missing pubkey, created_at, etc. - { id: null, pubkey: null }, // null values - { id: 123, pubkey: 456 }, // wrong types - { tags: "not-an-array" }, // wrong type for tags - { tags: [[1, 2, 3]] }, // wrong type for tag elements - ]; - - for (const event of malformedEvents) { - // These should be caught by shape validation before processing - const hasId = typeof event?.id === "string"; - const hasPubkey = typeof (event as { pubkey?: unknown })?.pubkey === "string"; - const hasTags = Array.isArray((event as { tags?: unknown })?.tags); - - // At least one should be invalid - expect(hasId && hasPubkey && hasTags).toBe(false); - } - }); - }); - - describe("timestamp edge cases", () => { - const testTimestamps = [ - { value: NaN, desc: "NaN" }, - { value: Infinity, desc: "Infinity" }, - { value: -Infinity, desc: "-Infinity" }, - { value: -1, desc: "negative" }, - { value: 0, desc: "zero" }, - { value: 253402300800, desc: "year 10000" }, // Far future - { value: -62135596800, desc: "year 0001" }, // Far past - { value: 1.5, desc: "float" }, - ]; - - for (const { value, desc } of testTimestamps) { - it(`handles ${desc} timestamp`, () => { - const isValidTimestamp = - typeof value === "number" && - !isNaN(value) && - isFinite(value) && - value >= 0 && - Number.isInteger(value); - - // Timestamps should be validated as positive integers - if (["NaN", "Infinity", "-Infinity", "negative", "float"].includes(desc)) { - expect(isValidTimestamp).toBe(false); - } - }); - } - }); -}); - -// ============================================================================ -// JSON parsing edge cases (simulating relay responses) -// ============================================================================ - -describe("JSON parsing edge cases", () => { - const malformedJsonCases = [ - { input: "", desc: "empty string" }, - { input: "null", desc: "null literal" }, - { input: "undefined", desc: "undefined literal" }, - { input: "{", desc: "incomplete object" }, - { input: "[", desc: "incomplete array" }, - { input: '{"key": undefined}', desc: "undefined value" }, - { input: "{'key': 'value'}", desc: "single quotes" }, - { input: '{"key": NaN}', desc: "NaN value" }, - { input: '{"key": Infinity}', desc: "Infinity value" }, - { input: "\x00", desc: "null byte" }, - { input: "abc", desc: "plain string" }, - { input: "123", desc: "plain number" }, - ]; - - for (const { input, desc } of malformedJsonCases) { - it(`handles malformed JSON: ${desc}`, () => { - let parsed: unknown; - let parseError = false; - - try { - parsed = JSON.parse(input); - } catch { - parseError = true; - } - - // Either it throws or produces something that needs validation - if (!parseError) { - // If it parsed, we need to validate the structure - const isValidRelayMessage = - Array.isArray(parsed) && parsed.length >= 2 && typeof parsed[0] === "string"; - - // Most malformed cases won't produce valid relay messages - if (["null literal", "plain number", "plain string"].includes(desc)) { - expect(isValidRelayMessage).toBe(false); - } - } - }); - } -}); diff --git a/extensions/nostr/src/nostr-bus.test.ts b/extensions/nostr/src/nostr-bus.test.ts index 3eea805a1b7..67978b849a4 100644 --- a/extensions/nostr/src/nostr-bus.test.ts +++ b/extensions/nostr/src/nostr-bus.test.ts @@ -5,7 +5,7 @@ import { isValidPubkey, normalizePubkey, pubkeyToNpub, -} from "./nostr-bus.js"; +} from "./nostr-key-utils.js"; import { TEST_HEX_PRIVATE_KEY, TEST_NSEC } from "./test-fixtures.js"; describe("validatePrivateKey", () => { diff --git a/extensions/nostr/src/nostr-bus.ts b/extensions/nostr/src/nostr-bus.ts index 51a59cf138e..091e6f97365 100644 --- a/extensions/nostr/src/nostr-bus.ts +++ b/extensions/nostr/src/nostr-bus.ts @@ -1,17 +1,9 @@ -import { - SimplePool, - finalizeEvent, - getPublicKey, - verifyEvent, - nip19, - type Event, -} from "nostr-tools"; +import { SimplePool, finalizeEvent, getPublicKey, verifyEvent, type Event } from "nostr-tools"; import { decrypt, encrypt } from "nostr-tools/nip04"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { createDirectDmPreCryptoGuardPolicy, type DirectDmPreCryptoGuardPolicyOverrides, -} from "../runtime-api.js"; +} from "openclaw/plugin-sdk/direct-dm-guard-policy"; import type { NostrProfile } from "./config-schema.js"; import { DEFAULT_RELAYS } from "./default-relays.js"; import { @@ -21,6 +13,7 @@ import { type MetricsSnapshot, type MetricEvent, } from "./metrics.js"; +import { validatePrivateKey } from "./nostr-key-utils.js"; import { publishProfile as publishProfileFn, type ProfilePublishResult } from "./nostr-profile.js"; import { readNostrBusState, @@ -31,6 +24,14 @@ import { } from "./nostr-state-store.js"; import { createSeenTracker, type SeenTracker } from "./seen-tracker.js"; +export { + validatePrivateKey, + getPublicKeyFromPrivate, + isValidPubkey, + normalizePubkey, + pubkeyToNpub, +} from "./nostr-key-utils.js"; + // ============================================================================ // Constants // ============================================================================ @@ -340,46 +341,6 @@ function createRelayHealthTracker(): RelayHealthTracker { }; } -// ============================================================================ -// Key Validation -// ============================================================================ - -/** - * Validate and normalize a private key (accepts hex or nsec format) - */ -export function validatePrivateKey(key: string): Uint8Array { - const trimmed = key.trim(); - - // Handle nsec (bech32) format - if (trimmed.startsWith("nsec1")) { - const decoded = nip19.decode(trimmed); - if (decoded.type !== "nsec") { - throw new Error("Invalid nsec key: wrong type"); - } - return decoded.data; - } - - // Handle hex format - if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { - throw new Error("Private key must be 64 hex characters or nsec bech32 format"); - } - - // Convert hex string to Uint8Array - const bytes = new Uint8Array(32); - for (let i = 0; i < 32; i++) { - bytes[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16); - } - return bytes; -} - -/** - * Get public key from private key (hex or nsec format) - */ -export function getPublicKeyFromPrivate(privateKey: string): string { - const sk = validatePrivateKey(privateKey); - return getPublicKey(sk); -} - // ============================================================================ // Main Bus // ============================================================================ @@ -834,64 +795,3 @@ async function sendEncryptedDm( throw new Error(`Failed to publish to any relay: ${lastError?.message}`); } - -// ============================================================================ -// Pubkey Utilities -// ============================================================================ - -/** - * Check if a string looks like a valid Nostr pubkey (hex or npub) - */ -export function isValidPubkey(input: string): boolean { - if (typeof input !== "string") { - return false; - } - const trimmed = input.trim(); - - // npub format - if (trimmed.startsWith("npub1")) { - try { - const decoded = nip19.decode(trimmed); - return decoded.type === "npub"; - } catch { - return false; - } - } - - // Hex format - return /^[0-9a-fA-F]{64}$/.test(trimmed); -} - -/** - * Normalize a pubkey to hex format (accepts npub or hex) - */ -export function normalizePubkey(input: string): string { - const trimmed = input.trim(); - - // npub format - decode to hex - if (trimmed.startsWith("npub1")) { - const decoded = nip19.decode(trimmed); - if (decoded.type !== "npub") { - throw new Error("Invalid npub key"); - } - // Convert Uint8Array to hex string - return Array.from(decoded.data as unknown as Uint8Array) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - } - - // Already hex - validate and return lowercase - if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { - throw new Error("Pubkey must be 64 hex characters or npub format"); - } - return normalizeLowercaseStringOrEmpty(trimmed); -} - -/** - * Convert a hex pubkey to npub format - */ -export function pubkeyToNpub(hexPubkey: string): string { - const normalized = normalizePubkey(hexPubkey); - // npubEncode expects a hex string, not Uint8Array - return nip19.npubEncode(normalized); -} diff --git a/extensions/nostr/src/nostr-key-utils.ts b/extensions/nostr/src/nostr-key-utils.ts new file mode 100644 index 00000000000..5c5d335ad10 --- /dev/null +++ b/extensions/nostr/src/nostr-key-utils.ts @@ -0,0 +1,94 @@ +import { getPublicKey, nip19 } from "nostr-tools"; + +/** + * Validate and normalize a private key (accepts hex or nsec format) + */ +export function validatePrivateKey(key: string): Uint8Array { + const trimmed = key.trim(); + + // Handle nsec (bech32) format + if (trimmed.startsWith("nsec1")) { + const decoded = nip19.decode(trimmed); + if (decoded.type !== "nsec") { + throw new Error("Invalid nsec key: wrong type"); + } + return decoded.data; + } + + // Handle hex format + if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { + throw new Error("Private key must be 64 hex characters or nsec bech32 format"); + } + + // Convert hex string to Uint8Array + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) { + bytes[i] = parseInt(trimmed.slice(i * 2, i * 2 + 2), 16); + } + return bytes; +} + +/** + * Get public key from private key (hex or nsec format) + */ +export function getPublicKeyFromPrivate(privateKey: string): string { + const sk = validatePrivateKey(privateKey); + return getPublicKey(sk); +} + +/** + * Check if a string looks like a valid Nostr pubkey (hex or npub) + */ +export function isValidPubkey(input: string): boolean { + if (typeof input !== "string") { + return false; + } + const trimmed = input.trim(); + + // npub format + if (trimmed.startsWith("npub1")) { + try { + const decoded = nip19.decode(trimmed); + return decoded.type === "npub"; + } catch { + return false; + } + } + + // Hex format + return /^[0-9a-fA-F]{64}$/.test(trimmed); +} + +/** + * Normalize a pubkey to hex format (accepts npub or hex) + */ +export function normalizePubkey(input: string): string { + const trimmed = input.trim(); + + // npub format - decode to hex + if (trimmed.startsWith("npub1")) { + const decoded = nip19.decode(trimmed); + if (decoded.type !== "npub") { + throw new Error("Invalid npub key"); + } + // Convert Uint8Array to hex string + return Array.from(decoded.data as unknown as Uint8Array) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } + + // Already hex - validate and return lowercase + if (!/^[0-9a-fA-F]{64}$/.test(trimmed)) { + throw new Error("Pubkey must be 64 hex characters or npub format"); + } + return trimmed.toLowerCase(); +} + +/** + * Convert a hex pubkey to npub format + */ +export function pubkeyToNpub(hexPubkey: string): string { + const normalized = normalizePubkey(hexPubkey); + // npubEncode expects a hex string, not Uint8Array + return nip19.npubEncode(normalized); +} diff --git a/extensions/nostr/src/nostr-profile-core.ts b/extensions/nostr/src/nostr-profile-core.ts new file mode 100644 index 00000000000..f47279526a8 --- /dev/null +++ b/extensions/nostr/src/nostr-profile-core.ts @@ -0,0 +1,134 @@ +import { type NostrProfile, NostrProfileSchema } from "./config-schema.js"; + +/** NIP-01 profile content (JSON inside kind:0 event). */ +export interface ProfileContent { + name?: string; + display_name?: string; + about?: string; + picture?: string; + banner?: string; + website?: string; + nip05?: string; + lud16?: string; +} + +/** + * Convert our config profile schema to NIP-01 content format. + * Strips undefined fields and validates URLs. + */ +export function profileToContent(profile: NostrProfile): ProfileContent { + const validated = NostrProfileSchema.parse(profile); + + const content: ProfileContent = {}; + + if (validated.name !== undefined) { + content.name = validated.name; + } + if (validated.displayName !== undefined) { + content.display_name = validated.displayName; + } + if (validated.about !== undefined) { + content.about = validated.about; + } + if (validated.picture !== undefined) { + content.picture = validated.picture; + } + if (validated.banner !== undefined) { + content.banner = validated.banner; + } + if (validated.website !== undefined) { + content.website = validated.website; + } + if (validated.nip05 !== undefined) { + content.nip05 = validated.nip05; + } + if (validated.lud16 !== undefined) { + content.lud16 = validated.lud16; + } + + return content; +} + +/** + * Convert NIP-01 content format back to our config profile schema. + * Useful for importing existing profiles from relays. + */ +export function contentToProfile(content: ProfileContent): NostrProfile { + const profile: NostrProfile = {}; + + if (content.name !== undefined) { + profile.name = content.name; + } + if (content.display_name !== undefined) { + profile.displayName = content.display_name; + } + if (content.about !== undefined) { + profile.about = content.about; + } + if (content.picture !== undefined) { + profile.picture = content.picture; + } + if (content.banner !== undefined) { + profile.banner = content.banner; + } + if (content.website !== undefined) { + profile.website = content.website; + } + if (content.nip05 !== undefined) { + profile.nip05 = content.nip05; + } + if (content.lud16 !== undefined) { + profile.lud16 = content.lud16; + } + + return profile; +} + +/** + * Validate a profile without throwing (returns result object). + */ +export function validateProfile(profile: unknown): { + valid: boolean; + profile?: NostrProfile; + errors?: string[]; +} { + const result = NostrProfileSchema.safeParse(profile); + + if (result.success) { + return { valid: true, profile: result.data }; + } + + return { + valid: false, + errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`), + }; +} + +/** + * Sanitize profile text fields to prevent XSS when displaying in UI. + * Escapes HTML special characters. + */ +export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile { + const escapeHtml = (str: string | undefined): string | undefined => { + if (str === undefined) { + return undefined; + } + return str + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); + }; + + return { + name: escapeHtml(profile.name), + displayName: escapeHtml(profile.displayName), + about: escapeHtml(profile.about), + picture: profile.picture, + banner: profile.banner, + website: profile.website, + nip05: escapeHtml(profile.nip05), + lud16: escapeHtml(profile.lud16), + }; +} diff --git a/extensions/nostr/src/nostr-profile-http-runtime.ts b/extensions/nostr/src/nostr-profile-http-runtime.ts new file mode 100644 index 00000000000..cf33cc5f74d --- /dev/null +++ b/extensions/nostr/src/nostr-profile-http-runtime.ts @@ -0,0 +1,6 @@ +export { + readJsonBodyWithLimit, + requestBodyErrorToText, +} from "openclaw/plugin-sdk/webhook-request-guards"; +export { createFixedWindowRateLimiter } from "openclaw/plugin-sdk/webhook-ingress"; +export { getPluginRuntimeGatewayRequestScope } from "../runtime-api.js"; diff --git a/extensions/nostr/src/nostr-profile-http.test.ts b/extensions/nostr/src/nostr-profile-http.test.ts index fdca579d4b8..8b58079aec1 100644 --- a/extensions/nostr/src/nostr-profile-http.test.ts +++ b/extensions/nostr/src/nostr-profile-http.test.ts @@ -5,7 +5,6 @@ import { IncomingMessage, ServerResponse } from "node:http"; import { Socket } from "node:net"; import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; -import * as runtimeApi from "../runtime-api.js"; import { clearNostrProfileRateLimitStateForTest, createNostrProfileHttpHandler, @@ -14,6 +13,19 @@ import { type NostrProfileHttpContext, } from "./nostr-profile-http.js"; +const runtimeScopeMock = vi.hoisted(() => vi.fn()); + +vi.mock("./nostr-profile-http-runtime.js", async () => { + const webhookIngress = await import("openclaw/plugin-sdk/webhook-ingress"); + const requestGuards = await import("openclaw/plugin-sdk/webhook-request-guards"); + return { + createFixedWindowRateLimiter: webhookIngress.createFixedWindowRateLimiter, + readJsonBodyWithLimit: requestGuards.readJsonBodyWithLimit, + requestBodyErrorToText: requestGuards.requestBodyErrorToText, + getPluginRuntimeGatewayRequestScope: runtimeScopeMock, + }; +}); + // Mock the channel exports vi.mock("./channel.js", () => ({ publishNostrProfile: vi.fn(), @@ -35,24 +47,23 @@ import { TEST_HEX_PUBLIC_KEY, TEST_SETUP_RELAY_URLS } from "./test-fixtures.js"; // ============================================================================ const TEST_PROFILE_RELAY_URL = TEST_SETUP_RELAY_URLS[0]; -const runtimeScopeSpy = vi.spyOn(runtimeApi, "getPluginRuntimeGatewayRequestScope"); afterAll(() => { - runtimeScopeSpy.mockRestore(); + runtimeScopeMock.mockReset(); }); function setGatewayRuntimeScopes(scopes: readonly string[] | undefined): void { if (!scopes) { - runtimeScopeSpy.mockReturnValue(undefined); + runtimeScopeMock.mockReturnValue(undefined); return; } - runtimeScopeSpy.mockReturnValue({ + runtimeScopeMock.mockReturnValue({ client: { connect: { scopes: [...scopes], }, }, - } as unknown as ReturnType); + }); } function responseChunkText(chunk: unknown): string { diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index c02074256f2..5a9b4fd0100 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -8,20 +8,15 @@ */ import type { IncomingMessage, ServerResponse } from "node:http"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, - readStringValue, -} from "openclaw/plugin-sdk/text-runtime"; import { z } from "openclaw/plugin-sdk/zod"; +import { publishNostrProfile, getNostrProfileState } from "./channel.js"; +import { NostrProfileSchema, type NostrProfile } from "./config-schema.js"; import { createFixedWindowRateLimiter, getPluginRuntimeGatewayRequestScope, readJsonBodyWithLimit, requestBodyErrorToText, -} from "../runtime-api.js"; -import { publishNostrProfile, getNostrProfileState } from "./channel.js"; -import { NostrProfileSchema, type NostrProfile } from "./config-schema.js"; +} from "./nostr-profile-http-runtime.js"; import { importProfileFromRelays, mergeProfiles } from "./nostr-profile-import.js"; import { validateUrlSafety } from "./nostr-profile-url-safety.js"; @@ -29,6 +24,22 @@ import { validateUrlSafety } from "./nostr-profile-url-safety.js"; // Types // ============================================================================ +function readStringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function normalizeOptionalLowercaseString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed ? trimmed.toLowerCase() : undefined; +} + +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return normalizeOptionalLowercaseString(value) ?? ""; +} + export interface NostrProfileHttpContext { /** Get current profile from config */ getConfigProfile: (accountId: string) => NostrProfile | undefined; diff --git a/extensions/nostr/src/nostr-profile-url-safety.ts b/extensions/nostr/src/nostr-profile-url-safety.ts index bbee86101b0..467a3e79d8a 100644 --- a/extensions/nostr/src/nostr-profile-url-safety.ts +++ b/extensions/nostr/src/nostr-profile-url-safety.ts @@ -1,5 +1,4 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; -import { isBlockedHostnameOrIp } from "../runtime-api.js"; +import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; export function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; error: string } { try { @@ -9,7 +8,7 @@ export function validateUrlSafety(urlStr: string): { ok: true } | { ok: false; e return { ok: false, error: "URL must use https:// protocol" }; } - const hostname = normalizeLowercaseStringOrEmpty(url.hostname); + const hostname = url.hostname.trim().toLowerCase(); if (isBlockedHostnameOrIp(hostname)) { return { ok: false, error: "URL must not point to private/internal addresses" }; diff --git a/extensions/nostr/src/nostr-profile.fuzz.test.ts b/extensions/nostr/src/nostr-profile.fuzz.test.ts index c0b2df701e2..93f58921bbf 100644 --- a/extensions/nostr/src/nostr-profile.fuzz.test.ts +++ b/extensions/nostr/src/nostr-profile.fuzz.test.ts @@ -1,16 +1,10 @@ import { describe, expect, it } from "vitest"; import type { NostrProfile } from "./config-schema.js"; import { - createProfileEvent, profileToContent, - validateProfile, sanitizeProfileForDisplay, -} from "./nostr-profile.js"; -import { TEST_HEX_PRIVATE_KEY_BYTES } from "./test-fixtures.js"; - -function createTestProfileEvent(profile: NostrProfile, lastPublishedAt?: number) { - return createProfileEvent(TEST_HEX_PRIVATE_KEY_BYTES, profile, lastPublishedAt); -} + validateProfile, +} from "./nostr-profile-core.js"; // ============================================================================ // Unicode Attack Vectors @@ -434,52 +428,3 @@ describe("profile type confusion", () => { expect(({} as Record).polluted).toBeUndefined(); }); }); - -// ============================================================================ -// Event Creation Edge Cases -// ============================================================================ - -describe("event creation edge cases", () => { - it("handles profile with all fields at max length", () => { - const profile: NostrProfile = { - name: "a".repeat(256), - displayName: "b".repeat(256), - about: "c".repeat(2000), - nip05: "d".repeat(200) + "@example.com", - lud16: "e".repeat(200) + "@example.com", - }; - - const event = createTestProfileEvent(profile); - expect(event.kind).toBe(0); - - // Content should be parseable JSON - expect(() => JSON.parse(event.content)).not.toThrow(); - }); - - it("handles rapid sequential events with monotonic timestamps", () => { - const profile: NostrProfile = { name: "rapid" }; - - // Create events in quick succession - let lastTimestamp = 0; - for (let i = 0; i < 25; i++) { - const event = createTestProfileEvent(profile, lastTimestamp); - expect(event.created_at).toBeGreaterThan(lastTimestamp); - lastTimestamp = event.created_at; - } - }); - - it("handles JSON special characters in content", () => { - const profile: NostrProfile = { - name: 'test"user', - about: "line1\nline2\ttab\\backslash", - }; - - const event = createTestProfileEvent(profile); - const parsed = JSON.parse(event.content) as { name: string; about: string }; - - expect(parsed.name).toBe('test"user'); - expect(parsed.about).toContain("\n"); - expect(parsed.about).toContain("\t"); - expect(parsed.about).toContain("\\"); - }); -}); diff --git a/extensions/nostr/src/nostr-profile.ts b/extensions/nostr/src/nostr-profile.ts index 251c86cc859..0caf8855b9d 100644 --- a/extensions/nostr/src/nostr-profile.ts +++ b/extensions/nostr/src/nostr-profile.ts @@ -7,7 +7,15 @@ import { finalizeEvent, SimplePool, type Event } from "nostr-tools"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { type NostrProfile, NostrProfileSchema } from "./config-schema.js"; +import type { NostrProfile } from "./config-schema.js"; +import { profileToContent } from "./nostr-profile-core.js"; +export { + contentToProfile, + profileToContent, + sanitizeProfileForDisplay, + validateProfile, + type ProfileContent, +} from "./nostr-profile-core.js"; // ============================================================================ // Types @@ -25,94 +33,6 @@ export interface ProfilePublishResult { createdAt: number; } -/** NIP-01 profile content (JSON inside kind:0 event) */ -export interface ProfileContent { - name?: string; - display_name?: string; - about?: string; - picture?: string; - banner?: string; - website?: string; - nip05?: string; - lud16?: string; -} - -// ============================================================================ -// Profile Content Conversion -// ============================================================================ - -/** - * Convert our config profile schema to NIP-01 content format. - * Strips undefined fields and validates URLs. - */ -export function profileToContent(profile: NostrProfile): ProfileContent { - const validated = NostrProfileSchema.parse(profile); - - const content: ProfileContent = {}; - - if (validated.name !== undefined) { - content.name = validated.name; - } - if (validated.displayName !== undefined) { - content.display_name = validated.displayName; - } - if (validated.about !== undefined) { - content.about = validated.about; - } - if (validated.picture !== undefined) { - content.picture = validated.picture; - } - if (validated.banner !== undefined) { - content.banner = validated.banner; - } - if (validated.website !== undefined) { - content.website = validated.website; - } - if (validated.nip05 !== undefined) { - content.nip05 = validated.nip05; - } - if (validated.lud16 !== undefined) { - content.lud16 = validated.lud16; - } - - return content; -} - -/** - * Convert NIP-01 content format back to our config profile schema. - * Useful for importing existing profiles from relays. - */ -export function contentToProfile(content: ProfileContent): NostrProfile { - const profile: NostrProfile = {}; - - if (content.name !== undefined) { - profile.name = content.name; - } - if (content.display_name !== undefined) { - profile.displayName = content.display_name; - } - if (content.about !== undefined) { - profile.about = content.about; - } - if (content.picture !== undefined) { - profile.picture = content.picture; - } - if (content.banner !== undefined) { - profile.banner = content.banner; - } - if (content.website !== undefined) { - profile.website = content.website; - } - if (content.nip05 !== undefined) { - profile.nip05 = content.nip05; - } - if (content.lud16 !== undefined) { - profile.lud16 = content.lud16; - } - - return profile; -} - // ============================================================================ // Event Creation // ============================================================================ @@ -222,56 +142,3 @@ export async function publishProfile( const event = createProfileEvent(sk, profile, lastPublishedAt); return publishProfileEvent(pool, relays, event); } - -// ============================================================================ -// Profile Validation Helpers -// ============================================================================ - -/** - * Validate a profile without throwing (returns result object). - */ -export function validateProfile(profile: unknown): { - valid: boolean; - profile?: NostrProfile; - errors?: string[]; -} { - const result = NostrProfileSchema.safeParse(profile); - - if (result.success) { - return { valid: true, profile: result.data }; - } - - return { - valid: false, - errors: result.error.issues.map((e) => `${e.path.join(".")}: ${e.message}`), - }; -} - -/** - * Sanitize profile text fields to prevent XSS when displaying in UI. - * Escapes HTML special characters. - */ -export function sanitizeProfileForDisplay(profile: NostrProfile): NostrProfile { - const escapeHtml = (str: string | undefined): string | undefined => { - if (str === undefined) { - return undefined; - } - return str - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - }; - - return { - name: escapeHtml(profile.name), - displayName: escapeHtml(profile.displayName), - about: escapeHtml(profile.about), - picture: profile.picture, // URLs already validated by schema - banner: profile.banner, - website: profile.website, - nip05: escapeHtml(profile.nip05), - lud16: escapeHtml(profile.lud16), - }; -} diff --git a/extensions/nostr/src/setup-surface.ts b/extensions/nostr/src/setup-surface.ts index 8e5ceb2fac3..f841fc93fdd 100644 --- a/extensions/nostr/src/setup-surface.ts +++ b/extensions/nostr/src/setup-surface.ts @@ -16,7 +16,7 @@ import { splitSetupEntries, } from "openclaw/plugin-sdk/setup"; import { DEFAULT_RELAYS } from "./default-relays.js"; -import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-bus.js"; +import { getPublicKeyFromPrivate, normalizePubkey } from "./nostr-key-utils.js"; import { resolveDefaultNostrAccountId, resolveNostrAccount } from "./types.js"; const channel = "nostr" as const; diff --git a/extensions/nostr/src/types.ts b/extensions/nostr/src/types.ts index 28f84cce045..f358a06eb09 100644 --- a/extensions/nostr/src/types.ts +++ b/extensions/nostr/src/types.ts @@ -12,7 +12,7 @@ import { normalizeSecretInputString, type SecretInput } from "openclaw/plugin-sd import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { NostrProfile } from "./config-schema.js"; import { DEFAULT_RELAYS } from "./default-relays.js"; -import { getPublicKeyFromPrivate } from "./nostr-bus.js"; +import { getPublicKeyFromPrivate } from "./nostr-key-utils.js"; export interface NostrAccountConfig { enabled?: boolean; diff --git a/extensions/qqbot/src/channel-config-shared.ts b/extensions/qqbot/src/channel-config-shared.ts index ba20be5aafe..6170a57006b 100644 --- a/extensions/qqbot/src/channel-config-shared.ts +++ b/extensions/qqbot/src/channel-config-shared.ts @@ -1,15 +1,11 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { - applyAccountNameToChannelSection, deleteAccountFromConfigSection, setAccountEnabledInConfigSection, -} from "openclaw/plugin-sdk/core"; +} from "openclaw/plugin-sdk/channel-plugin-common"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input"; +import { applyAccountNameToChannelSection } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupInput } from "openclaw/plugin-sdk/setup"; -import { - normalizeLowercaseStringOrEmpty, - normalizeStringifiedOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; import { DEFAULT_ACCOUNT_ID, applyQQBotAccountConfig, @@ -19,6 +15,20 @@ import { } from "./config.js"; import type { ResolvedQQBotAccount } from "./types.js"; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +function normalizeStringifiedOptionalString( + value: string | number | null | undefined, +): string | undefined { + if (value == null) { + return undefined; + } + const normalized = String(value).trim(); + return normalized || undefined; +} + export const qqbotMeta = { id: "qqbot", label: "QQ Bot", diff --git a/extensions/qqbot/src/config.test.ts b/extensions/qqbot/src/config.test.ts index e44711c817c..3a33482679b 100644 --- a/extensions/qqbot/src/config.test.ts +++ b/extensions/qqbot/src/config.test.ts @@ -1,67 +1,10 @@ -import fs from "node:fs"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { describe, expect, it } from "vitest"; -import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js"; -import { qqbotSetupAdapterShared } from "./channel-config-shared.js"; -import { qqbotSetupPlugin } from "./channel.setup.js"; +import { qqbotConfigAdapter, qqbotSetupAdapterShared } from "./channel-config-shared.js"; import { QQBotConfigSchema } from "./config-schema.js"; import { DEFAULT_ACCOUNT_ID, resolveDefaultQQBotAccountId, resolveQQBotAccount } from "./config.js"; describe("qqbot config", () => { - it("accepts top-level speech overrides in the manifest schema", () => { - const manifest = JSON.parse( - fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), - ) as { configSchema: Record }; - - const result = validateJsonSchemaValue({ - schema: manifest.configSchema, - cacheKey: "qqbot.manifest.speech-overrides", - value: { - tts: { - provider: "openai", - baseUrl: "https://example.com/v1", - apiKey: "tts-key", - model: "gpt-4o-mini-tts", - voice: "alloy", - authStyle: "api-key", - queryParams: { - format: "wav", - }, - speed: 1.1, - }, - stt: { - provider: "openai", - baseUrl: "https://example.com/v1", - apiKey: "stt-key", - model: "whisper-1", - }, - }, - }); - - expect(result.ok).toBe(true); - }); - - it("accepts defaultAccount in the manifest schema", () => { - const manifest = JSON.parse( - fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), - ) as { configSchema: Record }; - - const result = validateJsonSchemaValue({ - schema: manifest.configSchema, - cacheKey: "qqbot.manifest.default-account", - value: { - defaultAccount: "bot2", - accounts: { - bot2: { - appId: "654321", - }, - }, - }, - }); - - expect(result.ok).toBe(true); - }); - it("honors configured defaultAccount when resolving the default QQ Bot account id", () => { const cfg = { channels: { @@ -222,8 +165,8 @@ describe("qqbot config", () => { expect(resolved.clientSecret).toBe(""); expect(resolved.secretSource).toBe("config"); - expect(qqbotSetupPlugin.config.isConfigured?.(resolved, cfg)).toBe(true); - expect(qqbotSetupPlugin.config.describeAccount?.(resolved, cfg)?.configured).toBe(true); + expect(qqbotConfigAdapter.isConfigured(resolved)).toBe(true); + expect(qqbotConfigAdapter.describeAccount(resolved).configured).toBe(true); }); it.each([ @@ -238,10 +181,7 @@ describe("qqbot config", () => { expectedPath: ["channels", "qqbot", "accounts", "bot2"], }, ])("splits --token on the first colon for $accountId", ({ inputAccountId, expectedPath }) => { - const setup = qqbotSetupPlugin.setup; - expect(setup).toBeDefined(); - - const next = setup!.applyAccountConfig?.({ + const next = qqbotSetupAdapterShared.applyAccountConfig({ cfg: {} as OpenClawConfig, accountId: inputAccountId, input: { @@ -263,11 +203,9 @@ describe("qqbot config", () => { }); }); - it("rejects malformed --token consistently across setup paths", () => { + it("rejects malformed --token in shared setup config", () => { const runtimeSetup = qqbotSetupAdapterShared; - const lightweightSetup = qqbotSetupPlugin.setup; expect(runtimeSetup).toBeDefined(); - expect(lightweightSetup).toBeDefined(); const input = { token: "broken", name: "Bad" }; @@ -278,13 +216,6 @@ describe("qqbot config", () => { input, } as never), ).toBe("QQBot --token must be in appId:clientSecret format"); - expect( - lightweightSetup!.validateInput?.({ - cfg: {} as OpenClawConfig, - accountId: DEFAULT_ACCOUNT_ID, - input, - } as never), - ).toBe("QQBot --token must be in appId:clientSecret format"); expect( runtimeSetup.applyAccountConfig?.({ cfg: {} as OpenClawConfig, @@ -292,20 +223,11 @@ describe("qqbot config", () => { input, } as never), ).toEqual({}); - expect( - lightweightSetup!.applyAccountConfig?.({ - cfg: {} as OpenClawConfig, - accountId: DEFAULT_ACCOUNT_ID, - input, - } as never), - ).toEqual({}); }); - it("preserves the --use-env add flow across setup paths", () => { + it("preserves the --use-env add flow in shared setup config", () => { const runtimeSetup = qqbotSetupAdapterShared; - const lightweightSetup = qqbotSetupPlugin.setup; expect(runtimeSetup).toBeDefined(); - expect(lightweightSetup).toBeDefined(); const input = { useEnv: true, name: "Env Bot" }; @@ -324,21 +246,6 @@ describe("qqbot config", () => { }, }, }); - expect( - lightweightSetup!.applyAccountConfig?.({ - cfg: {} as OpenClawConfig, - accountId: DEFAULT_ACCOUNT_ID, - input, - } as never), - ).toMatchObject({ - channels: { - qqbot: { - enabled: true, - allowFrom: ["*"], - name: "Env Bot", - }, - }, - }); }); it("uses configured defaultAccount when runtime setup accountId is omitted", () => { @@ -362,11 +269,9 @@ describe("qqbot config", () => { ).toBe("bot2"); }); - it("rejects --use-env for named accounts across setup paths", () => { + it("rejects --use-env for named accounts in shared setup config", () => { const runtimeSetup = qqbotSetupAdapterShared; - const lightweightSetup = qqbotSetupPlugin.setup; expect(runtimeSetup).toBeDefined(); - expect(lightweightSetup).toBeDefined(); const input = { useEnv: true, name: "Env Bot" }; @@ -377,13 +282,6 @@ describe("qqbot config", () => { input, } as never), ).toBe("QQBot --use-env only supports the default account"); - expect( - lightweightSetup!.validateInput?.({ - cfg: {} as OpenClawConfig, - accountId: "bot2", - input, - } as never), - ).toBe("QQBot --use-env only supports the default account"); expect( runtimeSetup.applyAccountConfig?.({ cfg: {} as OpenClawConfig, @@ -391,12 +289,5 @@ describe("qqbot config", () => { input, } as never), ).toEqual({}); - expect( - lightweightSetup!.applyAccountConfig?.({ - cfg: {} as OpenClawConfig, - accountId: "bot2", - input, - } as never), - ).toEqual({}); }); }); diff --git a/extensions/qqbot/src/config.ts b/extensions/qqbot/src/config.ts index 1f09eca5b53..9a9052eabb9 100644 --- a/extensions/qqbot/src/config.ts +++ b/extensions/qqbot/src/config.ts @@ -5,7 +5,6 @@ import { normalizeResolvedSecretInputString, normalizeSecretInputString, } from "openclaw/plugin-sdk/secret-input"; -import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import type { ResolvedQQBotAccount, QQBotAccountConfig } from "./types.js"; export const DEFAULT_ACCOUNT_ID = "default"; @@ -16,7 +15,11 @@ interface QQBotChannelConfig extends QQBotAccountConfig { } function normalizeConfiguredDefaultAccountId(raw: unknown): string | null { - return normalizeOptionalLowercaseString(raw) ?? null; + if (typeof raw !== "string") { + return null; + } + const normalized = raw.trim().toLowerCase(); + return normalized || null; } function normalizeQQBotAccountConfig(account: QQBotAccountConfig | undefined): QQBotAccountConfig { diff --git a/extensions/qqbot/src/manifest-schema.test.ts b/extensions/qqbot/src/manifest-schema.test.ts new file mode 100644 index 00000000000..19657bd3cb9 --- /dev/null +++ b/extensions/qqbot/src/manifest-schema.test.ts @@ -0,0 +1,56 @@ +import fs from "node:fs"; +import { describe, expect, it } from "vitest"; +import { validateJsonSchemaValue } from "../../../src/plugins/schema-validator.js"; + +const manifest = JSON.parse( + fs.readFileSync(new URL("../openclaw.plugin.json", import.meta.url), "utf-8"), +) as { configSchema: Record }; +const manifestConfigSchemaCacheKey = "qqbot.manifest.config-schema"; + +describe("qqbot manifest schema", () => { + it("accepts top-level speech overrides", () => { + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: manifestConfigSchemaCacheKey, + value: { + tts: { + provider: "openai", + baseUrl: "https://example.com/v1", + apiKey: "tts-key", + model: "gpt-4o-mini-tts", + voice: "alloy", + authStyle: "api-key", + queryParams: { + format: "wav", + }, + speed: 1.1, + }, + stt: { + provider: "openai", + baseUrl: "https://example.com/v1", + apiKey: "stt-key", + model: "whisper-1", + }, + }, + }); + + expect(result.ok).toBe(true); + }); + + it("accepts defaultAccount", () => { + const result = validateJsonSchemaValue({ + schema: manifest.configSchema, + cacheKey: manifestConfigSchemaCacheKey, + value: { + defaultAccount: "bot2", + accounts: { + bot2: { + appId: "654321", + }, + }, + }, + }); + + expect(result.ok).toBe(true); + }); +}); diff --git a/extensions/qqbot/src/outbound-deliver.ts b/extensions/qqbot/src/outbound-deliver.ts index e6a829dc006..ff102c02b88 100644 --- a/extensions/qqbot/src/outbound-deliver.ts +++ b/extensions/qqbot/src/outbound-deliver.ts @@ -6,10 +6,6 @@ * 2. `sendPlainReply` handles plain replies, including markdown images and mixed text/media. */ -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; import { sendC2CMessage, sendDmMessage, @@ -36,6 +32,18 @@ import { filterInternalMarkers } from "./utils/text-parsing.js"; // Type definitions. +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return normalizeOptionalString(value)?.toLowerCase() ?? ""; +} + export interface DeliverEventContext { type: "c2c" | "guild" | "dm" | "group"; senderId: string; diff --git a/extensions/qqbot/src/slash-commands.test.ts b/extensions/qqbot/src/slash-commands.test.ts index 1aa26d01e18..c1de7aaef64 100644 --- a/extensions/qqbot/src/slash-commands.test.ts +++ b/extensions/qqbot/src/slash-commands.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from "vitest"; +import fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { getFrameworkCommands, matchSlashCommand, @@ -28,6 +29,18 @@ function buildCtx(overrides: Partial = {}): SlashCommandCon }; } +function stubEmptyLogFilesystem() { + vi.spyOn(fs, "existsSync").mockReturnValue(false); + vi.spyOn(fs, "readdirSync").mockReturnValue([] as never); + vi.spyOn(fs, "statSync").mockImplementation(() => { + throw new Error("missing"); + }); +} + +afterEach(() => { + vi.restoreAllMocks(); +}); + describe("slash command authorization", () => { // ---- /bot-logs (moved to framework registerCommand) ---- // /bot-logs is registered with the framework via registerCommand() so that @@ -151,21 +164,17 @@ describe("/bot-logs framework command hardening", () => { }); it("allows /bot-logs when allowFrom contains numeric sender ids", async () => { + stubEmptyLogFilesystem(); const handler = getBotLogsHandler(); const accountConfig = { allowFrom: [12345] } as unknown as SlashCommandContext["accountConfig"]; const result = await handler(buildCtx({ accountConfig })); - expect(result).not.toBeNull(); - expect(result).not.toBe( - "⛔ 权限不足:请先在 channels.qqbot.allowFrom(或对应账号 allowFrom)中配置明确的发送者列表后再使用 /bot-logs。", - ); + expect(result).toContain("未找到日志文件"); }); it("allows /bot-logs execution when allowFrom is explicit", async () => { + stubEmptyLogFilesystem(); const handler = getBotLogsHandler(); const result = await handler(buildCtx({ accountConfig: { allowFrom: ["qqbot:user-1"] } })); - expect(result).not.toBeNull(); - expect(result).not.toBe( - "⛔ 权限不足:请先在 channels.qqbot.allowFrom(或对应账号 allowFrom)中配置明确的发送者列表后再使用 /bot-logs。", - ); + expect(result).toContain("未找到日志文件"); }); }); diff --git a/extensions/qqbot/src/utils/file-utils-runtime.ts b/extensions/qqbot/src/utils/file-utils-runtime.ts new file mode 100644 index 00000000000..03c99b2b1d7 --- /dev/null +++ b/extensions/qqbot/src/utils/file-utils-runtime.ts @@ -0,0 +1 @@ +export { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; diff --git a/extensions/qqbot/src/utils/file-utils.test.ts b/extensions/qqbot/src/utils/file-utils.test.ts index 302db50627a..3dab5901131 100644 --- a/extensions/qqbot/src/utils/file-utils.test.ts +++ b/extensions/qqbot/src/utils/file-utils.test.ts @@ -7,7 +7,7 @@ const mediaRuntimeMocks = vi.hoisted(() => ({ fetchRemoteMedia: vi.fn(), })); -vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ +vi.mock("./file-utils-runtime.js", () => ({ fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args), })); diff --git a/extensions/qqbot/src/utils/file-utils.ts b/extensions/qqbot/src/utils/file-utils.ts index 27e017fc1a5..27410ba710e 100644 --- a/extensions/qqbot/src/utils/file-utils.ts +++ b/extensions/qqbot/src/utils/file-utils.ts @@ -2,12 +2,20 @@ import crypto from "node:crypto"; import * as fs from "node:fs"; import * as path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime"; import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime"; -import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalString, -} from "openclaw/plugin-sdk/text-runtime"; +import { fetchRemoteMedia } from "./file-utils-runtime.js"; + +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return normalizeOptionalString(value)?.toLowerCase() ?? ""; +} /** Maximum file size accepted by the QQ Bot API. */ export const MAX_UPLOAD_SIZE = 20 * 1024 * 1024; diff --git a/extensions/qqbot/src/utils/media-tags.ts b/extensions/qqbot/src/utils/media-tags.ts index bec8d764c57..329ac0f4f2d 100644 --- a/extensions/qqbot/src/utils/media-tags.ts +++ b/extensions/qqbot/src/utils/media-tags.ts @@ -1,4 +1,3 @@ -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { expandTilde } from "./platform.js"; // Canonical media tags. `qqmedia` is the generic auto-routing tag. @@ -93,7 +92,7 @@ export const FUZZY_MEDIA_TAG_REGEX = new RegExp( /** Normalize a raw tag name into the canonical tag set. */ function resolveTagName(raw: string): (typeof VALID_TAGS)[number] { - const lower = normalizeLowercaseStringOrEmpty(raw); + const lower = raw.trim().toLowerCase(); if ((VALID_TAGS as readonly string[]).includes(lower)) { return lower as (typeof VALID_TAGS)[number]; } diff --git a/extensions/qqbot/src/utils/text-parsing.ts b/extensions/qqbot/src/utils/text-parsing.ts index 5cec13dda9a..67cc9259f63 100644 --- a/extensions/qqbot/src/utils/text-parsing.ts +++ b/extensions/qqbot/src/utils/text-parsing.ts @@ -1,9 +1,41 @@ -import { estimateBase64DecodedBytes } from "openclaw/plugin-sdk/media-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import type { RefAttachmentSummary } from "../ref-index-store.js"; const MAX_FACE_EXT_BYTES = 64 * 1024; +function estimateBase64DecodedBytes(base64: string): number { + let effectiveLen = 0; + for (let i = 0; i < base64.length; i += 1) { + if (base64.charCodeAt(i) > 0x20) { + effectiveLen += 1; + } + } + if (effectiveLen === 0) { + return 0; + } + + let padding = 0; + let end = base64.length - 1; + while (end >= 0 && base64.charCodeAt(end) <= 0x20) { + end -= 1; + } + if (end >= 0 && base64[end] === "=") { + padding = 1; + end -= 1; + while (end >= 0 && base64.charCodeAt(end) <= 0x20) { + end -= 1; + } + if (end >= 0 && base64[end] === "=") { + padding = 2; + } + } + + return Math.max(0, Math.floor((effectiveLen * 3) / 4) - padding); +} + +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + /** Replace QQ face tags with readable text labels. */ export function parseFaceTags(text: string | undefined | null): string { if (!text) { diff --git a/extensions/synology-chat/src/accounts.ts b/extensions/synology-chat/src/accounts.ts index 45b9ef08ccb..859dd2e592c 100644 --- a/extensions/synology-chat/src/accounts.ts +++ b/extensions/synology-chat/src/accounts.ts @@ -9,7 +9,7 @@ import { resolveMergedAccountConfig, type OpenClawConfig, } from "openclaw/plugin-sdk/account-resolution"; -import { resolveDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/config-runtime"; +import { resolveDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; import type { SynologyChatChannelConfig, ResolvedSynologyChatAccount, diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index a68ef3e8100..2f516304ccb 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -35,9 +35,7 @@ vi.mock("./webhook-handler.js", () => ({ createWebhookHandler: vi.fn(() => vi.fn()), })); -const freshChannelModulePath = "./channel.js?channel-test"; -const { createSynologyChatPlugin } = await import(freshChannelModulePath); -const { synologyChatPlugin } = await import("./channel.js"); +const { createSynologyChatPlugin, synologyChatPlugin } = await import("./channel.js"); const getSynologyChatSetupStatus = createPluginSetupWizardStatus(synologyChatPlugin); describe("createSynologyChatPlugin", () => { diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index 2a5a5df4f1f..eec6fba9411 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -20,7 +20,6 @@ import { } from "openclaw/plugin-sdk/channel-policy"; import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result"; import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { synologyChatApprovalAuth } from "./approval-auth.js"; import { sendMessage, sendFileUrl } from "./client.js"; @@ -36,6 +35,10 @@ import type { ResolvedSynologyChatAccount } from "./types.js"; const CHANNEL_ID = "synology-chat"; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver({ channelKey: CHANNEL_ID, resolvePolicy: (account) => account.dmPolicy, diff --git a/extensions/synology-chat/src/client.ts b/extensions/synology-chat/src/client.ts index dc75ed29420..0dc8af611b4 100644 --- a/extensions/synology-chat/src/client.ts +++ b/extensions/synology-chat/src/client.ts @@ -6,12 +6,15 @@ import * as http from "node:http"; import * as https from "node:https"; import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { z } from "zod"; const MIN_SEND_INTERVAL_MS = 500; let lastSendTime = 0; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + // --- Chat user_id resolution --- // Synology Chat uses two different user_id spaces: // - Outgoing webhook user_id: per-integration sequential ID (e.g. 1) diff --git a/extensions/synology-chat/src/inbound-turn.ts b/extensions/synology-chat/src/inbound-turn.ts index 2554eb465cd..e18e1077878 100644 --- a/extensions/synology-chat/src/inbound-turn.ts +++ b/extensions/synology-chat/src/inbound-turn.ts @@ -1,4 +1,4 @@ -import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { sendMessage } from "./client.js"; import { buildSynologyChatInboundContext, type SynologyInboundMessage } from "./inbound-context.js"; import { getSynologyRuntime } from "./runtime.js"; diff --git a/extensions/synology-chat/src/runtime.ts b/extensions/synology-chat/src/runtime.ts index cf4eb5a543f..4376e8d2d9c 100644 --- a/extensions/synology-chat/src/runtime.ts +++ b/extensions/synology-chat/src/runtime.ts @@ -1,5 +1,4 @@ -import type { PluginRuntime } from "openclaw/plugin-sdk/core"; -import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store"; +import { createPluginRuntimeStore, type PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; const { setRuntime: setSynologyRuntime, getRuntime: getSynologyRuntime } = createPluginRuntimeStore({ diff --git a/extensions/synology-chat/src/session-key.ts b/extensions/synology-chat/src/session-key.ts index 98fe31d5ab9..d5d0bf418fc 100644 --- a/extensions/synology-chat/src/session-key.ts +++ b/extensions/synology-chat/src/session-key.ts @@ -1,4 +1,4 @@ -import { buildAgentSessionKey } from "openclaw/plugin-sdk/core"; +import { buildAgentSessionKey } from "openclaw/plugin-sdk/routing"; const CHANNEL_ID = "synology-chat"; diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts index 15464a21107..a933118dccb 100644 --- a/extensions/synology-chat/src/setup-surface.ts +++ b/extensions/synology-chat/src/setup-surface.ts @@ -11,7 +11,6 @@ import { type ChannelSetupWizard, type OpenClawConfig, } from "openclaw/plugin-sdk/setup"; -import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { listAccountIds, resolveAccount } from "./accounts.js"; import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js"; @@ -35,6 +34,14 @@ const SYNOLOGY_ALLOW_FROM_HELP_LINES = [ `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, ]; +function normalizeOptionalString(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + const trimmed = value.trim(); + return trimmed || undefined; +} + function getChannelConfig(cfg: OpenClawConfig): SynologyChatChannelConfig { return (cfg.channels?.[channel] as SynologyChatChannelConfig | undefined) ?? {}; } diff --git a/extensions/synology-chat/src/webhook-handler.ts b/extensions/synology-chat/src/webhook-handler.ts index 00c89b32f80..eaf72b5cb67 100644 --- a/extensions/synology-chat/src/webhook-handler.ts +++ b/extensions/synology-chat/src/webhook-handler.ts @@ -5,7 +5,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import * as querystring from "node:querystring"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { beginWebhookRequestPipelineOrReject, createWebhookInFlightLimiter, @@ -17,6 +16,10 @@ import * as synologyClient from "./client.js"; import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js"; import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js"; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + // One rate limiter per account, created lazily const rateLimiters = new Map(); const invalidTokenRateLimiters = new Map(); diff --git a/extensions/tlon/src/monitor/approval.ts b/extensions/tlon/src/monitor/approval.ts index 700284cc375..8e25d72a089 100644 --- a/extensions/tlon/src/monitor/approval.ts +++ b/extensions/tlon/src/monitor/approval.ts @@ -7,13 +7,16 @@ // Extensions cannot import core internals directly, so use node:crypto here. import { randomBytes } from "node:crypto"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import type { PendingApproval } from "../settings.js"; export type { PendingApproval }; export type ApprovalType = "dm" | "channel" | "group"; +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + export type CreateApprovalParams = { type: ApprovalType; requestingShip: string; diff --git a/extensions/tlon/src/monitor/media.test.ts b/extensions/tlon/src/monitor/media.test.ts index 129aa7b69f5..6475313e849 100644 --- a/extensions/tlon/src/monitor/media.test.ts +++ b/extensions/tlon/src/monitor/media.test.ts @@ -1,28 +1,21 @@ -import { MAX_IMAGE_BYTES } from "openclaw/plugin-sdk/media-runtime"; +import { + fetchRemoteMedia, + MAX_IMAGE_BYTES, + saveMediaBuffer, +} from "openclaw/plugin-sdk/media-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { downloadMedia, extractImageBlocks } from "./media.js"; -vi.mock("openclaw/plugin-sdk/media-runtime", async () => { - const actual = await vi.importActual( - "openclaw/plugin-sdk/media-runtime", - ); - return { - ...actual, - fetchRemoteMedia: vi.fn(), - saveMediaBuffer: vi.fn(), - }; -}); +vi.mock("openclaw/plugin-sdk/media-runtime", () => ({ + MAX_IMAGE_BYTES: 6 * 1024 * 1024, + fetchRemoteMedia: vi.fn(), + saveMediaBuffer: vi.fn(), +})); + +const fetchRemoteMediaMock = vi.mocked(fetchRemoteMedia); +const saveMediaBufferMock = vi.mocked(saveMediaBuffer); describe("tlon monitor media", () => { - async function loadMediaModule() { - const mediaRuntime = await import("openclaw/plugin-sdk/media-runtime"); - const mediaModule = await import("./media.js"); - return { - fetchRemoteMedia: vi.mocked(mediaRuntime.fetchRemoteMedia), - saveMediaBuffer: vi.mocked(mediaRuntime.saveMediaBuffer), - ...mediaModule, - }; - } - beforeEach(() => { vi.clearAllMocks(); vi.spyOn(console, "error").mockImplementation(() => undefined); @@ -34,7 +27,6 @@ describe("tlon monitor media", () => { }); it("caps extracted images at eight per message", async () => { - const { extractImageBlocks } = await loadMediaModule(); const content = Array.from({ length: 10 }, (_, index) => ({ block: { image: { src: `https://example.com/${index}.png`, alt: `image-${index}` } }, })); @@ -48,14 +40,12 @@ describe("tlon monitor media", () => { }); it("stores fetched media through the shared inbound media store with the image cap", async () => { - const { downloadMedia, fetchRemoteMedia, saveMediaBuffer } = await loadMediaModule(); - - fetchRemoteMedia.mockResolvedValue({ + fetchRemoteMediaMock.mockResolvedValue({ buffer: Buffer.from("image-data"), contentType: "image/png", fileName: "photo.png", }); - saveMediaBuffer.mockResolvedValue({ + saveMediaBufferMock.mockResolvedValue({ id: "photo---uuid.png", path: "/tmp/openclaw/media/inbound/photo---uuid.png", size: "image-data".length, @@ -64,7 +54,7 @@ describe("tlon monitor media", () => { const result = await downloadMedia("https://example.com/photo.png"); - expect(fetchRemoteMedia).toHaveBeenCalledWith( + expect(fetchRemoteMediaMock).toHaveBeenCalledWith( expect.objectContaining({ url: "https://example.com/photo.png", maxBytes: MAX_IMAGE_BYTES, @@ -72,7 +62,7 @@ describe("tlon monitor media", () => { requestInit: { method: "GET" }, }), ); - expect(saveMediaBuffer).toHaveBeenCalledWith( + expect(saveMediaBufferMock).toHaveBeenCalledWith( Buffer.from("image-data"), "image/png", "inbound", @@ -87,9 +77,7 @@ describe("tlon monitor media", () => { }); it("returns null when the fetch exceeds the image cap", async () => { - const { downloadMedia, fetchRemoteMedia, saveMediaBuffer } = await loadMediaModule(); - - fetchRemoteMedia.mockRejectedValue( + fetchRemoteMediaMock.mockRejectedValue( new Error( `Failed to fetch media from https://example.com/photo.png: payload exceeds maxBytes ${MAX_IMAGE_BYTES}`, ), @@ -98,6 +86,6 @@ describe("tlon monitor media", () => { const result = await downloadMedia("https://example.com/photo.png"); expect(result).toBeNull(); - expect(saveMediaBuffer).not.toHaveBeenCalled(); + expect(saveMediaBufferMock).not.toHaveBeenCalled(); }); }); diff --git a/extensions/tlon/src/monitor/utils.ts b/extensions/tlon/src/monitor/utils.ts index a64991afd94..c84aa096ad9 100644 --- a/extensions/tlon/src/monitor/utils.ts +++ b/extensions/tlon/src/monitor/utils.ts @@ -1,5 +1,4 @@ import { formatErrorMessage as sharedFormatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { asNullableObjectRecord, readStringField } from "openclaw/plugin-sdk/text-runtime"; import { normalizeShip } from "../targets.js"; // Cite types for message references @@ -187,6 +186,20 @@ export const asRecord = asNullableObjectRecord; export const formatErrorMessage = sharedFormatErrorMessage; export const readString = readStringField; +function asNullableObjectRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : null; +} + +function readStringField( + record: Record | null | undefined, + field: string, +): string | undefined { + const value = record?.[field]; + return typeof value === "string" ? value : undefined; +} + // Helper to recursively extract text from inline content function renderInlineItem( item: unknown, diff --git a/extensions/tlon/src/urbit/auth.ssrf.test.ts b/extensions/tlon/src/urbit/auth.ssrf.test.ts index df75f4640ac..bccbf55efad 100644 --- a/extensions/tlon/src/urbit/auth.ssrf.test.ts +++ b/extensions/tlon/src/urbit/auth.ssrf.test.ts @@ -1,6 +1,6 @@ +import { SsrFBlockedError } from "openclaw/plugin-sdk/browser-security-runtime"; +import type { LookupFn } from "openclaw/plugin-sdk/ssrf-runtime"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { LookupFn } from "../../api.js"; -import { SsrFBlockedError } from "../../api.js"; import { authenticate } from "./auth.js"; describe("tlon urbit auth ssrf", () => { diff --git a/extensions/tlon/src/urbit/base-url.ts b/extensions/tlon/src/urbit/base-url.ts index 9bd084bb734..e24a832f1b3 100644 --- a/extensions/tlon/src/urbit/base-url.ts +++ b/extensions/tlon/src/urbit/base-url.ts @@ -1,5 +1,4 @@ import { isBlockedHostnameOrIp } from "openclaw/plugin-sdk/ssrf-runtime"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; export type UrbitBaseUrlValidation = | { ok: true; baseUrl: string; hostname: string } @@ -10,7 +9,7 @@ function hasScheme(value: string): boolean { } export function normalizeUrbitHostname(hostname: string | undefined): string { - return normalizeLowercaseStringOrEmpty(hostname).replace(/\.$/, ""); + return (hostname ?? "").trim().toLowerCase().replace(/\.$/, ""); } export function validateUrbitBaseUrl(raw: string): UrbitBaseUrlValidation { diff --git a/extensions/tlon/src/urbit/fetch.ts b/extensions/tlon/src/urbit/fetch.ts index 24c301c75aa..53f707b28ea 100644 --- a/extensions/tlon/src/urbit/fetch.ts +++ b/extensions/tlon/src/urbit/fetch.ts @@ -1,4 +1,8 @@ -import { fetchWithSsrFGuard, type LookupFn, type SsrFPolicy } from "../../runtime-api.js"; +import { + fetchWithSsrFGuard, + type LookupFn, + type SsrFPolicy, +} from "openclaw/plugin-sdk/ssrf-runtime"; import { validateUrbitBaseUrl } from "./base-url.js"; import { UrbitUrlError } from "./errors.js"; diff --git a/extensions/tlon/src/urbit/upload.test.ts b/extensions/tlon/src/urbit/upload.test.ts index 452ccac426f..65ecd0eba06 100644 --- a/extensions/tlon/src/urbit/upload.test.ts +++ b/extensions/tlon/src/urbit/upload.test.ts @@ -1,9 +1,9 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { describe, expect, it, vi, beforeEach } from "vitest"; -import { fetchWithSsrFGuard } from "../../runtime-api.js"; import { uploadFile } from "../tlon-api.js"; import { uploadImageFromUrl } from "./upload.js"; -vi.mock("../../runtime-api.js", () => ({ +vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ fetchWithSsrFGuard: vi.fn(), })); diff --git a/extensions/tlon/src/urbit/upload.ts b/extensions/tlon/src/urbit/upload.ts index 30c69aed74f..48040c9fbb1 100644 --- a/extensions/tlon/src/urbit/upload.ts +++ b/extensions/tlon/src/urbit/upload.ts @@ -1,7 +1,7 @@ /** * Upload an image from a URL to Tlon storage. */ -import { fetchWithSsrFGuard } from "../../runtime-api.js"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { uploadFile } from "../tlon-api.js"; import { getDefaultSsrFPolicy } from "./context.js"; diff --git a/extensions/twitch/src/probe.ts b/extensions/twitch/src/probe.ts index 04bbd5883ad..caff2f68b0d 100644 --- a/extensions/twitch/src/probe.ts +++ b/extensions/twitch/src/probe.ts @@ -83,12 +83,22 @@ export async function probeTwitch( }); }); + let timeoutHandle: ReturnType | undefined; const timeout = new Promise((_, reject) => { - setTimeout(() => reject(new Error(`timeout after ${timeoutMs}ms`)), timeoutMs); + timeoutHandle = setTimeout( + () => reject(new Error(`timeout after ${timeoutMs}ms`)), + timeoutMs, + ); }); client.connect(); - await Promise.race([connectionPromise, timeout]); + try { + await Promise.race([connectionPromise, timeout]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } client.quit(); client = undefined; diff --git a/extensions/twitch/src/token.ts b/extensions/twitch/src/token.ts index 3b8a04c08cf..40ba98e6da0 100644 --- a/extensions/twitch/src/token.ts +++ b/extensions/twitch/src/token.ts @@ -10,7 +10,7 @@ */ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/core"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; export type TwitchTokenSource = "env" | "config" | "none"; diff --git a/extensions/twitch/src/utils/twitch.ts b/extensions/twitch/src/utils/twitch.ts index 0d317428dc1..6d52ee7b1fa 100644 --- a/extensions/twitch/src/utils/twitch.ts +++ b/extensions/twitch/src/utils/twitch.ts @@ -1,10 +1,13 @@ import { randomUUID } from "node:crypto"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; /** * Twitch-specific utility functions */ +function normalizeLowercaseStringOrEmpty(value: unknown): string { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + /** * Normalize Twitch channel names. * diff --git a/extensions/whatsapp/src/session.ts b/extensions/whatsapp/src/session.ts index fd8ea3101b5..2cf568d6130 100644 --- a/extensions/whatsapp/src/session.ts +++ b/extensions/whatsapp/src/session.ts @@ -44,10 +44,7 @@ async function loadQrTerminal() { return mod.default ?? mod; } -export async function writeCredsJsonAtomically( - authDir: string, - creds: unknown, -): Promise { +export async function writeCredsJsonAtomically(authDir: string, creds: unknown): Promise { const credsPath = resolveWebCredsPath(authDir); const tempPath = path.join(authDir, `.creds.${process.pid}.${Date.now()}.tmp`); try {