diff --git a/.oxlintrc.json b/.oxlintrc.json index f9acdbea83..4097a58f2d 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -24,6 +24,7 @@ "assets/", "dist/", "docs/_layouts/", + "extensions/", "node_modules/", "patches/", "pnpm-lock.yaml/", diff --git a/extensions/bluebubbles/src/attachments.ts b/extensions/bluebubbles/src/attachments.ts index 6ce8342d8a..1d18126e9a 100644 --- a/extensions/bluebubbles/src/attachments.ts +++ b/extensions/bluebubbles/src/attachments.ts @@ -26,7 +26,9 @@ const AUDIO_MIME_CAF = new Set(["audio/x-caf", "audio/caf"]); function sanitizeFilename(input: string | undefined, fallback: string): string { const trimmed = input?.trim() ?? ""; const base = trimmed ? path.basename(trimmed) : ""; - return base || fallback; + const name = base || fallback; + // Strip characters that could enable multipart header injection (CWE-93) + return name.replace(/[\r\n"\\]/g, "_"); } function ensureExtension(filename: string, extension: string, fallbackBase: string): string { diff --git a/extensions/bluebubbles/src/chat.ts b/extensions/bluebubbles/src/chat.ts index 374c5a896e..115dc06aae 100644 --- a/extensions/bluebubbles/src/chat.ts +++ b/extensions/bluebubbles/src/chat.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import crypto from "node:crypto"; +import path from "node:path"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js"; @@ -336,10 +337,13 @@ export async function setGroupIconBlueBubbles( const parts: Uint8Array[] = []; const encoder = new TextEncoder(); + // Sanitize filename to prevent multipart header injection (CWE-93) + const safeFilename = path.basename(filename).replace(/[\r\n"\\]/g, "_") || "icon.png"; + // Add file field named "icon" as per API spec parts.push(encoder.encode(`--${boundary}\r\n`)); parts.push( - encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${filename}"\r\n`), + encoder.encode(`Content-Disposition: form-data; name="icon"; filename="${safeFilename}"\r\n`), ); parts.push( encoder.encode(`Content-Type: ${opts.contentType ?? "application/octet-stream"}\r\n\r\n`), diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index aafe29f12e..b72a492bd4 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -393,6 +393,48 @@ describe("BlueBubbles webhook monitor", () => { expect(res.statusCode).toBe(400); }); + it("returns 400 when request body times out (Slow-Loris protection)", async () => { + vi.useFakeTimers(); + try { + const account = createMockAccount(); + const config: OpenClawConfig = {}; + const core = createMockRuntime(); + setBlueBubblesRuntime(core); + + unregister = registerBlueBubblesWebhookTarget({ + account, + config, + runtime: { log: vi.fn(), error: vi.fn() }, + core, + path: "/bluebubbles-webhook", + }); + + // Create a request that never sends data or ends (simulates slow-loris) + const req = new EventEmitter() as IncomingMessage; + req.method = "POST"; + req.url = "/bluebubbles-webhook"; + req.headers = {}; + (req as unknown as { socket: { remoteAddress: string } }).socket = { + remoteAddress: "127.0.0.1", + }; + req.destroy = vi.fn(); + + const res = createMockResponse(); + + const handledPromise = handleBlueBubblesWebhookRequest(req, res); + + // Advance past the 30s timeout + await vi.advanceTimersByTimeAsync(31_000); + + const handled = await handledPromise; + expect(handled).toBe(true); + expect(res.statusCode).toBe(400); + expect(req.destroy).toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("authenticates via password query parameter", async () => { const account = createMockAccount({ password: "secret-token" }); const config: OpenClawConfig = {}; diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 0d87cbfea8..8738f64304 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -508,14 +508,29 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v }; } -async function readJsonBody(req: IncomingMessage, maxBytes: number) { +async function readJsonBody(req: IncomingMessage, maxBytes: number, timeoutMs = 30_000) { const chunks: Buffer[] = []; let total = 0; return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => { + let done = false; + const finish = (result: { ok: boolean; value?: unknown; error?: string }) => { + if (done) { + return; + } + done = true; + clearTimeout(timer); + resolve(result); + }; + + const timer = setTimeout(() => { + finish({ ok: false, error: "request body timeout" }); + req.destroy(); + }, timeoutMs); + req.on("data", (chunk: Buffer) => { total += chunk.length; if (total > maxBytes) { - resolve({ ok: false, error: "payload too large" }); + finish({ ok: false, error: "payload too large" }); req.destroy(); return; } @@ -525,27 +540,30 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) { try { const raw = Buffer.concat(chunks).toString("utf8"); if (!raw.trim()) { - resolve({ ok: false, error: "empty payload" }); + finish({ ok: false, error: "empty payload" }); return; } try { - resolve({ ok: true, value: JSON.parse(raw) as unknown }); + finish({ ok: true, value: JSON.parse(raw) as unknown }); return; } catch { const params = new URLSearchParams(raw); const payload = params.get("payload") ?? params.get("data") ?? params.get("message"); if (payload) { - resolve({ ok: true, value: JSON.parse(payload) as unknown }); + finish({ ok: true, value: JSON.parse(payload) as unknown }); return; } throw new Error("invalid json"); } } catch (err) { - resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + finish({ ok: false, error: err instanceof Error ? err.message : String(err) }); } }); req.on("error", (err) => { - resolve({ ok: false, error: err instanceof Error ? err.message : String(err) }); + finish({ ok: false, error: err instanceof Error ? err.message : String(err) }); + }); + req.on("close", () => { + finish({ ok: false, error: "connection closed" }); }); }); } diff --git a/extensions/bluebubbles/src/probe.ts b/extensions/bluebubbles/src/probe.ts index 76e3b330e9..d87a6d4471 100644 --- a/extensions/bluebubbles/src/probe.ts +++ b/extensions/bluebubbles/src/probe.ts @@ -16,7 +16,9 @@ export type BlueBubblesServerInfo = { computer_id?: string; }; -/** Cache server info by account ID to avoid repeated API calls */ +/** Cache server info by account ID to avoid repeated API calls. + * Size-capped to prevent unbounded growth (#4948). */ +const MAX_SERVER_INFO_CACHE_SIZE = 64; const serverInfoCache = new Map(); const CACHE_TTL_MS = 10 * 60 * 1000; // 10 minutes @@ -56,6 +58,13 @@ export async function fetchBlueBubblesServerInfo(params: { const data = payload?.data as BlueBubblesServerInfo | undefined; if (data) { serverInfoCache.set(cacheKey, { info: data, expires: Date.now() + CACHE_TTL_MS }); + // Evict oldest entries if cache exceeds max size + if (serverInfoCache.size > MAX_SERVER_INFO_CACHE_SIZE) { + const oldest = serverInfoCache.keys().next().value; + if (oldest !== undefined) { + serverInfoCache.delete(oldest); + } + } } return data ?? null; } catch { diff --git a/extensions/bluebubbles/src/send.test.ts b/extensions/bluebubbles/src/send.test.ts index c2ee393c7f..c10266068f 100644 --- a/extensions/bluebubbles/src/send.test.ts +++ b/extensions/bluebubbles/src/send.test.ts @@ -370,6 +370,16 @@ describe("send", () => { ).rejects.toThrow("requires text"); }); + it("throws when text becomes empty after markdown stripping", async () => { + // Edge case: input like "***" or "---" passes initial check but becomes empty after stripMarkdown + await expect( + sendMessageBlueBubbles("+15551234567", "***", { + serverUrl: "http://localhost:1234", + password: "test", + }), + ).rejects.toThrow("empty after markdown removal"); + }); + it("throws when serverUrl is missing", async () => { await expect(sendMessageBlueBubbles("+15551234567", "Hello", {})).rejects.toThrow( "serverUrl is required", @@ -438,6 +448,77 @@ describe("send", () => { expect(body.method).toBeUndefined(); }); + it("strips markdown formatting from outbound messages", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve({ + data: [ + { + guid: "iMessage;-;+15551234567", + participants: [{ address: "+15551234567" }], + }, + ], + }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid: "msg-uuid-stripped" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles( + "+15551234567", + "**Bold** and *italic* with `code`\n## Header", + { + serverUrl: "http://localhost:1234", + password: "test", + }, + ); + + expect(result.messageId).toBe("msg-uuid-stripped"); + + const sendCall = mockFetch.mock.calls[1]; + const body = JSON.parse(sendCall[1].body); + // Markdown should be stripped: no asterisks, backticks, or hashes + expect(body.message).toBe("Bold and italic with code\nHeader"); + }); + + it("strips markdown when creating a new chat", async () => { + mockFetch + .mockResolvedValueOnce({ + ok: true, + json: () => Promise.resolve({ data: [] }), + }) + .mockResolvedValueOnce({ + ok: true, + text: () => + Promise.resolve( + JSON.stringify({ + data: { guid: "new-msg-stripped" }, + }), + ), + }); + + const result = await sendMessageBlueBubbles("+15550009999", "**Welcome** to the _chat_!", { + serverUrl: "http://localhost:1234", + password: "test", + }); + + expect(result.messageId).toBe("new-msg-stripped"); + + const createCall = mockFetch.mock.calls[1]; + expect(createCall[0]).toContain("/api/v1/chat/new"); + const body = JSON.parse(createCall[1].body); + // Markdown should be stripped + expect(body.message).toBe("Welcome to the chat!"); + }); + it("creates a new chat when handle target is missing", async () => { mockFetch .mockResolvedValueOnce({ diff --git a/extensions/bluebubbles/src/send.ts b/extensions/bluebubbles/src/send.ts index 63333556f0..4a6a369dd5 100644 --- a/extensions/bluebubbles/src/send.ts +++ b/extensions/bluebubbles/src/send.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk"; import crypto from "node:crypto"; +import { stripMarkdown } from "openclaw/plugin-sdk"; import { resolveBlueBubblesAccount } from "./accounts.js"; import { extractHandleFromChatGuid, @@ -332,6 +333,7 @@ async function createNewChatWithMessage(params: { const payload = { addresses: [params.address], message: params.message, + tempGuid: `temp-${crypto.randomUUID()}`, }; const res = await blueBubblesFetchWithTimeout( url, @@ -377,6 +379,11 @@ export async function sendMessageBlueBubbles( if (!trimmedText.trim()) { throw new Error("BlueBubbles send requires text"); } + // Strip markdown early and validate - ensures messages like "***" or "---" don't become empty + const strippedText = stripMarkdown(trimmedText); + if (!strippedText.trim()) { + throw new Error("BlueBubbles send requires text (message was empty after markdown removal)"); + } const account = resolveBlueBubblesAccount({ cfg: opts.cfg ?? {}, @@ -406,7 +413,7 @@ export async function sendMessageBlueBubbles( baseUrl, password, address: target.address, - message: trimmedText, + message: strippedText, timeoutMs: opts.timeoutMs, }); } @@ -419,7 +426,7 @@ export async function sendMessageBlueBubbles( const payload: Record = { chatGuid, tempGuid: crypto.randomUUID(), - message: trimmedText, + message: strippedText, }; if (needsPrivateApi) { payload.method = "private-api"; diff --git a/extensions/googlechat/src/auth.ts b/extensions/googlechat/src/auth.ts index bee093315c..6870ea8ec0 100644 --- a/extensions/googlechat/src/auth.ts +++ b/extensions/googlechat/src/auth.ts @@ -8,6 +8,8 @@ const ADDON_ISSUER_PATTERN = /^service-\d+@gcp-sa-gsuiteaddons\.iam\.gserviceacc const CHAT_CERTS_URL = "https://www.googleapis.com/service_accounts/v1/metadata/x509/chat@system.gserviceaccount.com"; +// Size-capped to prevent unbounded growth in long-running deployments (#4948) +const MAX_AUTH_CACHE_SIZE = 32; const authCache = new Map(); const verifyClient = new OAuth2Client(); @@ -30,20 +32,32 @@ function getAuthInstance(account: ResolvedGoogleChatAccount): GoogleAuth { return cached.auth; } + const evictOldest = () => { + if (authCache.size > MAX_AUTH_CACHE_SIZE) { + const oldest = authCache.keys().next().value; + if (oldest !== undefined) { + authCache.delete(oldest); + } + } + }; + if (account.credentialsFile) { const auth = new GoogleAuth({ keyFile: account.credentialsFile, scopes: [CHAT_SCOPE] }); authCache.set(account.accountId, { key, auth }); + evictOldest(); return auth; } if (account.credentials) { const auth = new GoogleAuth({ credentials: account.credentials, scopes: [CHAT_SCOPE] }); authCache.set(account.accountId, { key, auth }); + evictOldest(); return auth; } const auth = new GoogleAuth({ scopes: [CHAT_SCOPE] }); authCache.set(account.accountId, { key, auth }); + evictOldest(); return auth; } diff --git a/extensions/matrix/src/matrix/send/targets.ts b/extensions/matrix/src/matrix/send/targets.ts index b3de224eb6..460559798f 100644 --- a/extensions/matrix/src/matrix/send/targets.ts +++ b/extensions/matrix/src/matrix/send/targets.ts @@ -17,7 +17,18 @@ export function normalizeThreadId(raw?: string | number | null): string | null { return trimmed ? trimmed : null; } +// Size-capped to prevent unbounded growth (#4948) +const MAX_DIRECT_ROOM_CACHE_SIZE = 1024; const directRoomCache = new Map(); +function setDirectRoomCached(key: string, value: string): void { + directRoomCache.set(key, value); + if (directRoomCache.size > MAX_DIRECT_ROOM_CACHE_SIZE) { + const oldest = directRoomCache.keys().next().value; + if (oldest !== undefined) { + directRoomCache.delete(oldest); + } + } +} async function persistDirectRoom( client: MatrixClient, @@ -62,7 +73,7 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis const directContent = await client.getAccountData(EventType.Direct); const list = Array.isArray(directContent?.[trimmed]) ? directContent[trimmed] : []; if (list.length > 0) { - directRoomCache.set(trimmed, list[0]); + setDirectRoomCached(trimmed, list[0]); return list[0]; } } catch { @@ -86,7 +97,7 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis } // Prefer classic 1:1 rooms, but allow larger rooms if requested. if (members.length === 2) { - directRoomCache.set(trimmed, roomId); + setDirectRoomCached(trimmed, roomId); await persistDirectRoom(client, trimmed, roomId); return roomId; } @@ -99,7 +110,7 @@ async function resolveDirectRoomId(client: MatrixClient, userId: string): Promis } if (fallbackRoom) { - directRoomCache.set(trimmed, fallbackRoom); + setDirectRoomCached(trimmed, fallbackRoom); await persistDirectRoom(client, trimmed, fallbackRoom); return fallbackRoom; } diff --git a/extensions/nostr/src/nostr-profile-http.ts b/extensions/nostr/src/nostr-profile-http.ts index 499c4c8a90..ebb98e885d 100644 --- a/extensions/nostr/src/nostr-profile-http.ts +++ b/extensions/nostr/src/nostr-profile-http.ts @@ -229,31 +229,58 @@ function sendJson(res: ServerResponse, status: number, body: unknown): void { res.end(JSON.stringify(body)); } -async function readJsonBody(req: IncomingMessage, maxBytes = 64 * 1024): Promise { +async function readJsonBody( + req: IncomingMessage, + maxBytes = 64 * 1024, + timeoutMs = 30_000, +): Promise { return new Promise((resolve, reject) => { + let done = false; + const finish = (fn: () => void) => { + if (done) { + return; + } + done = true; + clearTimeout(timer); + fn(); + }; + + const timer = setTimeout(() => { + finish(() => { + const err = new Error("Request body timeout"); + req.destroy(err); + reject(err); + }); + }, timeoutMs); + const chunks: Buffer[] = []; let totalBytes = 0; req.on("data", (chunk: Buffer) => { totalBytes += chunk.length; if (totalBytes > maxBytes) { - reject(new Error("Request body too large")); - req.destroy(); + finish(() => { + reject(new Error("Request body too large")); + req.destroy(); + }); return; } chunks.push(chunk); }); req.on("end", () => { - try { - const body = Buffer.concat(chunks).toString("utf-8"); - resolve(body ? JSON.parse(body) : {}); - } catch { - reject(new Error("Invalid JSON")); - } + finish(() => { + try { + const body = Buffer.concat(chunks).toString("utf-8"); + resolve(body ? JSON.parse(body) : {}); + } catch { + reject(new Error("Invalid JSON")); + } + }); }); - req.on("error", reject); + req.on("error", (err) => finish(() => reject(err))); + req.on("close", () => finish(() => reject(new Error("Connection closed")))); }); } diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index f67ff23738..99f14a4680 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -296,23 +296,48 @@ export class VoiceCallWebhookServer { } /** - * Read request body as string. + * Read request body as string with timeout protection. */ - private readBody(req: http.IncomingMessage, maxBytes: number): Promise { + private readBody( + req: http.IncomingMessage, + maxBytes: number, + timeoutMs = 30_000, + ): Promise { return new Promise((resolve, reject) => { + let done = false; + const finish = (fn: () => void) => { + if (done) { + return; + } + done = true; + clearTimeout(timer); + fn(); + }; + + const timer = setTimeout(() => { + finish(() => { + const err = new Error("Request body timeout"); + req.destroy(err); + reject(err); + }); + }, timeoutMs); + const chunks: Buffer[] = []; let totalBytes = 0; req.on("data", (chunk: Buffer) => { totalBytes += chunk.length; if (totalBytes > maxBytes) { - req.destroy(); - reject(new Error("PayloadTooLarge")); + finish(() => { + req.destroy(); + reject(new Error("PayloadTooLarge")); + }); return; } chunks.push(chunk); }); - req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); - req.on("error", reject); + req.on("end", () => finish(() => resolve(Buffer.concat(chunks).toString("utf-8")))); + req.on("error", (err) => finish(() => reject(err))); + req.on("close", () => finish(() => reject(new Error("Connection closed")))); }); } diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index 7066b4538b..af8c75cb68 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -107,6 +107,7 @@ export async function getReplyFromConfig( typeof configuredTypingSeconds === "number" ? configuredTypingSeconds : 6; const typing = createTypingController({ onReplyStart: opts?.onReplyStart, + onCleanup: opts?.onTypingCleanup, typingIntervalSeconds, silentToken: SILENT_REPLY_TOKEN, log: defaultRuntime.log, diff --git a/src/auto-reply/reply/reply-dispatcher.ts b/src/auto-reply/reply/reply-dispatcher.ts index be505a8bc0..270efb001e 100644 --- a/src/auto-reply/reply/reply-dispatcher.ts +++ b/src/auto-reply/reply/reply-dispatcher.ts @@ -58,11 +58,13 @@ export type ReplyDispatcherOptions = { export type ReplyDispatcherWithTypingOptions = Omit & { onReplyStart?: () => Promise | void; onIdle?: () => void; + /** Called when the typing controller is cleaned up (e.g., on NO_REPLY). */ + onCleanup?: () => void; }; type ReplyDispatcherWithTypingResult = { dispatcher: ReplyDispatcher; - replyOptions: Pick; + replyOptions: Pick; markDispatchIdle: () => void; }; @@ -164,7 +166,7 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis export function createReplyDispatcherWithTyping( options: ReplyDispatcherWithTypingOptions, ): ReplyDispatcherWithTypingResult { - const { onReplyStart, onIdle, ...dispatcherOptions } = options; + const { onReplyStart, onIdle, onCleanup, ...dispatcherOptions } = options; let typingController: TypingController | undefined; const dispatcher = createReplyDispatcher({ ...dispatcherOptions, @@ -178,6 +180,7 @@ export function createReplyDispatcherWithTyping( dispatcher, replyOptions: { onReplyStart, + onTypingCleanup: onCleanup, onTypingController: (typing) => { typingController = typing; }, diff --git a/src/auto-reply/reply/typing.ts b/src/auto-reply/reply/typing.ts index fbfab5b479..ececcc2fb8 100644 --- a/src/auto-reply/reply/typing.ts +++ b/src/auto-reply/reply/typing.ts @@ -13,6 +13,7 @@ export type TypingController = { export function createTypingController(params: { onReplyStart?: () => Promise | void; + onCleanup?: () => void; typingIntervalSeconds?: number; typingTtlMs?: number; silentToken?: string; @@ -20,6 +21,7 @@ export function createTypingController(params: { }): TypingController { const { onReplyStart, + onCleanup, typingIntervalSeconds = 6, typingTtlMs = 2 * 60_000, silentToken = SILENT_REPLY_TOKEN, @@ -63,6 +65,11 @@ export function createTypingController(params: { clearInterval(typingTimer); typingTimer = undefined; } + // Notify the channel to stop its typing indicator (e.g., on NO_REPLY). + // This fires only once (sealed prevents re-entry). + if (active) { + onCleanup?.(); + } resetCycle(); sealed = true; }; diff --git a/src/auto-reply/skill-commands.ts b/src/auto-reply/skill-commands.ts index 16ba7b8705..6b1bd8a924 100644 --- a/src/auto-reply/skill-commands.ts +++ b/src/auto-reply/skill-commands.ts @@ -42,11 +42,20 @@ export function listSkillCommandsForAgents(params: { const used = resolveReservedCommandNames(); const entries: SkillCommandSpec[] = []; const agentIds = params.agentIds ?? listAgentIds(params.cfg); + // Track visited workspace dirs to avoid registering duplicate commands + // when multiple agents share the same workspace directory (#5717). + const visitedDirs = new Set(); for (const agentId of agentIds) { const workspaceDir = resolveAgentWorkspaceDir(params.cfg, agentId); if (!fs.existsSync(workspaceDir)) { continue; } + // Resolve to canonical path to handle symlinks and relative paths + const canonicalDir = fs.realpathSync(workspaceDir); + if (visitedDirs.has(canonicalDir)) { + continue; + } + visitedDirs.add(canonicalDir); const commands = buildWorkspaceSkillCommandSpecs(workspaceDir, { config: params.cfg, eligibility: { remote: getRemoteSkillEligibility() }, diff --git a/src/auto-reply/types.ts b/src/auto-reply/types.ts index 1aa0fe0671..406bd8d033 100644 --- a/src/auto-reply/types.ts +++ b/src/auto-reply/types.ts @@ -23,6 +23,8 @@ export type GetReplyOptions = { /** Notifies when an agent run actually starts (useful for webchat command handling). */ onAgentRunStart?: (runId: string) => void; onReplyStart?: () => Promise | void; + /** Called when the typing controller cleans up (e.g., run ended with NO_REPLY). */ + onTypingCleanup?: () => void; onTypingController?: (typing: TypingController) => void; isHeartbeat?: boolean; onPartialReply?: (payload: ReplyPayload) => Promise | void; diff --git a/src/channels/typing.ts b/src/channels/typing.ts index f24c7f1885..6ab2a97536 100644 --- a/src/channels/typing.ts +++ b/src/channels/typing.ts @@ -1,6 +1,8 @@ export type TypingCallbacks = { onReplyStart: () => Promise; onIdle?: () => void; + /** Called when the typing controller is cleaned up (e.g., on NO_REPLY). */ + onCleanup?: () => void; }; export function createTypingCallbacks(params: { @@ -18,11 +20,11 @@ export function createTypingCallbacks(params: { } }; - const onIdle = stop + const fireStop = stop ? () => { void stop().catch((err) => (params.onStopError ?? params.onStartError)(err)); } : undefined; - return { onReplyStart, onIdle }; + return { onReplyStart, onIdle: fireStop, onCleanup: fireStop }; } diff --git a/src/config/plugin-auto-enable.test.ts b/src/config/plugin-auto-enable.test.ts index 8399389e3b..f84900d446 100644 --- a/src/config/plugin-auto-enable.test.ts +++ b/src/config/plugin-auto-enable.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { applyPluginAutoEnable } from "./plugin-auto-enable.js"; describe("applyPluginAutoEnable", () => { - it("enables configured channel plugins and updates allowlist", () => { + it("configures channel plugins with disabled state and updates allowlist", () => { const result = applyPluginAutoEnable({ config: { channels: { slack: { botToken: "x" } }, @@ -11,7 +11,7 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.slack?.enabled).toBe(true); + expect(result.config.plugins?.entries?.slack?.enabled).toBe(false); expect(result.config.plugins?.allow).toEqual(["telegram", "slack"]); expect(result.changes.join("\n")).toContain("Slack configured, not enabled yet."); }); @@ -29,7 +29,7 @@ describe("applyPluginAutoEnable", () => { expect(result.changes).toEqual([]); }); - it("enables provider auth plugins when profiles exist", () => { + it("configures provider auth plugins as disabled when profiles exist", () => { const result = applyPluginAutoEnable({ config: { auth: { @@ -44,7 +44,7 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(true); + expect(result.config.plugins?.entries?.["google-antigravity-auth"]?.enabled).toBe(false); }); it("skips when plugins are globally disabled", () => { @@ -61,7 +61,7 @@ describe("applyPluginAutoEnable", () => { }); describe("preferOver channel prioritization", () => { - it("prefers bluebubbles: skips imessage auto-enable when both are configured", () => { + it("prefers bluebubbles: skips imessage auto-configure when both are configured", () => { const result = applyPluginAutoEnable({ config: { channels: { @@ -72,7 +72,7 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true); + expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false); expect(result.config.plugins?.entries?.imessage?.enabled).toBeUndefined(); expect(result.changes.join("\n")).toContain("bluebubbles configured, not enabled yet."); expect(result.changes.join("\n")).not.toContain("iMessage configured, not enabled yet."); @@ -90,11 +90,11 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(true); + expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false); expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); }); - it("allows imessage auto-enable when bluebubbles is explicitly disabled", () => { + it("allows imessage auto-configure when bluebubbles is explicitly disabled", () => { const result = applyPluginAutoEnable({ config: { channels: { @@ -107,11 +107,11 @@ describe("applyPluginAutoEnable", () => { }); expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBe(false); - expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(false); expect(result.changes.join("\n")).toContain("iMessage configured, not enabled yet."); }); - it("allows imessage auto-enable when bluebubbles is in deny list", () => { + it("allows imessage auto-configure when bluebubbles is in deny list", () => { const result = applyPluginAutoEnable({ config: { channels: { @@ -124,10 +124,10 @@ describe("applyPluginAutoEnable", () => { }); expect(result.config.plugins?.entries?.bluebubbles?.enabled).toBeUndefined(); - expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(false); }); - it("enables imessage normally when only imessage is configured", () => { + it("configures imessage as disabled when only imessage is configured", () => { const result = applyPluginAutoEnable({ config: { channels: { imessage: { cliPath: "/usr/local/bin/imsg" } }, @@ -135,7 +135,7 @@ describe("applyPluginAutoEnable", () => { env: {}, }); - expect(result.config.plugins?.entries?.imessage?.enabled).toBe(true); + expect(result.config.plugins?.entries?.imessage?.enabled).toBe(false); expect(result.changes.join("\n")).toContain("iMessage configured, not enabled yet."); }); }); diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 800e7634d5..32944cea3a 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -386,12 +386,12 @@ function ensureAllowlisted(cfg: OpenClawConfig, pluginId: string): OpenClawConfi }; } -function enablePluginEntry(cfg: OpenClawConfig, pluginId: string): OpenClawConfig { +function registerPluginEntry(cfg: OpenClawConfig, pluginId: string): OpenClawConfig { const entries = { ...cfg.plugins?.entries, [pluginId]: { ...(cfg.plugins?.entries?.[pluginId] as Record | undefined), - enabled: true, + enabled: false, }, }; return { @@ -399,7 +399,6 @@ function enablePluginEntry(cfg: OpenClawConfig, pluginId: string): OpenClawConfi plugins: { ...cfg.plugins, entries, - ...(cfg.plugins?.enabled === false ? { enabled: true } : {}), }, }; } @@ -447,7 +446,7 @@ export function applyPluginAutoEnable(params: { if (alreadyEnabled && !allowMissing) { continue; } - next = enablePluginEntry(next, entry.pluginId); + next = registerPluginEntry(next, entry.pluginId); next = ensureAllowlisted(next, entry.pluginId); changes.push(formatAutoEnableChange(entry)); } diff --git a/src/discord/monitor/presence-cache.ts b/src/discord/monitor/presence-cache.ts index e112297e8c..8989a2de37 100644 --- a/src/discord/monitor/presence-cache.ts +++ b/src/discord/monitor/presence-cache.ts @@ -3,7 +3,9 @@ import type { GatewayPresenceUpdate } from "discord-api-types/v10"; /** * In-memory cache of Discord user presence data. * Populated by PRESENCE_UPDATE gateway events when the GuildPresences intent is enabled. + * Per-account maps are capped to prevent unbounded growth (#4948). */ +const MAX_PRESENCE_PER_ACCOUNT = 5000; const presenceCache = new Map>(); function resolveAccountKey(accountId?: string): string { @@ -23,6 +25,13 @@ export function setPresence( presenceCache.set(accountKey, accountCache); } accountCache.set(userId, data); + // Evict oldest entries if cache exceeds limit + if (accountCache.size > MAX_PRESENCE_PER_ACCOUNT) { + const oldest = accountCache.keys().next().value; + if (oldest !== undefined) { + accountCache.delete(oldest); + } + } } /** Get cached presence for a user. Returns undefined if not cached. */ diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index d032d60b49..452c76bfa7 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -279,12 +279,8 @@ function resolveAttachmentMaxBytes(params: { channel: ChannelId; accountId?: string | null; }): number | undefined { - const fallback = params.cfg.agents?.defaults?.mediaMaxMb; - if (params.channel !== "bluebubbles") { - return typeof fallback === "number" ? fallback * 1024 * 1024 : undefined; - } const accountId = typeof params.accountId === "string" ? params.accountId.trim() : ""; - const channelCfg = params.cfg.channels?.bluebubbles; + const channelCfg = params.cfg.channels?.[params.channel]; const channelObj = channelCfg && typeof channelCfg === "object" ? (channelCfg as Record) @@ -300,6 +296,7 @@ function resolveAttachmentMaxBytes(params: { accountCfg && typeof accountCfg === "object" ? (accountCfg as Record).mediaMaxMb : undefined; + // Priority: account-specific > channel-level > global default const limitMb = (typeof accountMediaMax === "number" ? accountMediaMax : undefined) ?? channelMediaMax ?? diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 61ed2e535e..8f6c7b2b30 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -367,13 +367,32 @@ export const registerTelegramNativeCommands = ({ ...customCommands, ]; - if (allCommands.length > 0) { + // Clear stale commands before registering new ones to prevent + // leftover commands from deleted skills persisting across restarts (#5717). + // Chain delete → set so a late-resolving delete cannot wipe newly registered commands. + const registerCommands = () => { + if (allCommands.length > 0) { + withTelegramApiErrorLogging({ + operation: "setMyCommands", + runtime, + fn: () => bot.api.setMyCommands(allCommands), + }).catch(() => {}); + } + }; + if (typeof bot.api.deleteMyCommands === "function") { withTelegramApiErrorLogging({ - operation: "setMyCommands", + operation: "deleteMyCommands", runtime, - fn: () => bot.api.setMyCommands(allCommands), - }).catch(() => {}); + fn: () => bot.api.deleteMyCommands(), + }) + .catch(() => {}) + .then(registerCommands) + .catch(() => {}); + } else { + registerCommands(); + } + if (allCommands.length > 0) { if (typeof (bot as unknown as { command?: unknown }).command !== "function") { logVerbose("telegram: bot.command unavailable; skipping native handlers"); } else {