From dd6bc5382da3747611e3308592b1fecfe1e8f4c3 Mon Sep 17 00:00:00 2001 From: Alg0rix Date: Sun, 25 Jan 2026 13:35:32 +0000 Subject: [PATCH 01/43] fix(msteams): correct typing indicator sendActivity call --- extensions/msteams/src/reply-dispatcher.ts | 66 +++++++++++----------- 1 file changed, 33 insertions(+), 33 deletions(-) diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index c83867a65..7b50b0629 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -42,7 +42,7 @@ export function createMSTeamsReplyDispatcher(params: { }) { const core = getMSTeamsRuntime(); const sendTypingIndicator = async () => { - await params.context.sendActivities([{ type: "typing" }]); + await params.context.sendActivity([{ type: "typing" }]); }; const typingCallbacks = createTypingCallbacks({ start: sendTypingIndicator, @@ -70,38 +70,38 @@ export function createMSTeamsReplyDispatcher(params: { const tableMode = core.channel.text.resolveMarkdownTableMode({ cfg: params.cfg, channel: "msteams", - }); - const messages = renderReplyPayloadsToMessages([payload], { - textChunkLimit: params.textLimit, - chunkText: true, - mediaMode: "split", - tableMode, - chunkMode, - }); - const mediaMaxBytes = resolveChannelMediaMaxBytes({ - cfg: params.cfg, - resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb, - }); - const ids = await sendMSTeamsMessages({ - replyStyle: params.replyStyle, - adapter: params.adapter, - appId: params.appId, - conversationRef: params.conversationRef, - context: params.context, - messages, - // Enable default retry/backoff for throttling/transient failures. - retry: {}, - onRetry: (event) => { - params.log.debug("retrying send", { - replyStyle: params.replyStyle, - ...event, - }); - }, - tokenProvider: params.tokenProvider, - sharePointSiteId: params.sharePointSiteId, - mediaMaxBytes, - }); - if (ids.length > 0) params.onSentMessageIds?.(ids); + }); + const messages = renderReplyPayloadsToMessages([payload], { + textChunkLimit: params.textLimit, + chunkText: true, + mediaMode: "split", + tableMode, + chunkMode, + }); + const mediaMaxBytes = resolveChannelMediaMaxBytes({ + cfg: params.cfg, + resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb, + }); + const ids = await sendMSTeamsMessages({ + replyStyle: params.replyStyle, + adapter: params.adapter, + appId: params.appId, + conversationRef: params.conversationRef, + context: params.context, + messages, + // Enable default retry/backoff for throttling/transient failures. + retry: {}, + onRetry: (event) => { + params.log.debug("retrying send", { + replyStyle: params.replyStyle, + ...event, + }); + }, + tokenProvider: params.tokenProvider, + sharePointSiteId: params.sharePointSiteId, + mediaMaxBytes, + }); + if (ids.length > 0) params.onSentMessageIds?.(ids); }, onError: (err, info) => { const errMsg = formatUnknownError(err); From 6d269710518c68b6264bc0c9d4bf4da691e5aba5 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Sun, 25 Jan 2026 14:14:41 -0800 Subject: [PATCH 02/43] fix(bluebubbles): add inbound message debouncing to coalesce URL link previews When users send iMessages containing URLs, BlueBubbles sends separate webhook events for the text message and the URL balloon/link preview. This caused Clawdbot to receive them as separate queued messages. This fix adds inbound debouncing (following the pattern from WhatsApp/MS Teams): - Uses the existing createInboundDebouncer utility from plugin-sdk - Adds debounceMs config option to BlueBubblesAccountConfig (default: 500ms) - Routes inbound messages through debouncer before processing - Combines messages from same sender/chat within the debounce window - Handles URLBalloonProvider messages by coalescing with preceding text - Skips debouncing for messages with attachments or control commands Config example: channels.bluebubbles.debounceMs: 500 # milliseconds (0 to disable) Fixes inbound URL message splitting issue. --- extensions/bluebubbles/src/monitor.test.ts | 10 +- extensions/bluebubbles/src/monitor.ts | 184 ++++++++++++++++++++- 2 files changed, 191 insertions(+), 3 deletions(-) diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 12aef679c..76c9eebf6 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -146,8 +146,14 @@ function createMockRuntime(): PluginRuntime { resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"], }, debounce: { - createInboundDebouncer: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"], - resolveInboundDebounceMs: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"], + // Create a pass-through debouncer that immediately calls onFlush + createInboundDebouncer: vi.fn((params: { onFlush: (items: unknown[]) => Promise }) => ({ + enqueue: async (item: unknown) => { + await params.onFlush([item]); + }, + flushKey: vi.fn(), + })) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"], + resolveInboundDebounceMs: vi.fn(() => 0) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"], }, commands: { resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"], diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 8635b183e..b754558bb 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -250,8 +250,185 @@ type WebhookTarget = { statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; }; +/** + * Entry type for debouncing inbound messages. + * Captures the normalized message and its target for later combined processing. + */ +type BlueBubblesDebounceEntry = { + message: NormalizedWebhookMessage; + target: WebhookTarget; +}; + +/** + * Default debounce window for inbound message coalescing (ms). + * This helps combine URL text + link preview balloon messages that BlueBubbles + * sends as separate webhook events. + */ +const DEFAULT_INBOUND_DEBOUNCE_MS = 100; + +/** + * Known URLBalloonProvider bundle IDs that indicate a rich link preview message. + */ +const URL_BALLOON_BUNDLE_IDS = new Set([ + "com.apple.messages.URLBalloonProvider", + "com.apple.messages.richLinkProvider", +]); + +/** + * Checks if a message is a URL balloon/link preview message. + */ +function isUrlBalloonMessage(message: NormalizedWebhookMessage): boolean { + const bundleId = message.balloonBundleId?.trim(); + if (!bundleId) return false; + return URL_BALLOON_BUNDLE_IDS.has(bundleId); +} + +/** + * Combines multiple debounced messages into a single message for processing. + * Used when multiple webhook events arrive within the debounce window. + */ +function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage { + if (entries.length === 0) { + throw new Error("Cannot combine empty entries"); + } + if (entries.length === 1) { + return entries[0].message; + } + + // Use the first message as the base (typically the text message) + const first = entries[0].message; + const rest = entries.slice(1); + + // Combine text from all entries, filtering out duplicates and empty strings + const seenTexts = new Set(); + const textParts: string[] = []; + + for (const entry of entries) { + const text = entry.message.text.trim(); + if (!text) continue; + // Skip duplicate text (URL might be in both text message and balloon) + const normalizedText = text.toLowerCase(); + if (seenTexts.has(normalizedText)) continue; + seenTexts.add(normalizedText); + textParts.push(text); + } + + // Merge attachments from all entries + const allAttachments = entries.flatMap((e) => e.message.attachments ?? []); + + // Use the latest timestamp + const timestamps = entries + .map((e) => e.message.timestamp) + .filter((t): t is number => typeof t === "number"); + const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp; + + // Collect all message IDs for reference + const messageIds = entries + .map((e) => e.message.messageId) + .filter((id): id is string => Boolean(id)); + + // Prefer reply context from any entry that has it + const entryWithReply = entries.find((e) => e.message.replyToId); + + return { + ...first, + text: textParts.join(" "), + attachments: allAttachments.length > 0 ? allAttachments : first.attachments, + timestamp: latestTimestamp, + // Use first message's ID as primary (for reply reference), but we've coalesced others + messageId: messageIds[0] ?? first.messageId, + // Preserve reply context if present + replyToId: entryWithReply?.message.replyToId ?? first.replyToId, + replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody, + replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender, + // Clear balloonBundleId since we've combined (the combined message is no longer just a balloon) + balloonBundleId: undefined, + }; +} + const webhookTargets = new Map(); +/** + * Maps webhook targets to their inbound debouncers. + * Each target gets its own debouncer keyed by a unique identifier. + */ +const targetDebouncers = new Map< + WebhookTarget, + ReturnType +>(); + +/** + * Creates or retrieves a debouncer for a webhook target. + */ +function getOrCreateDebouncer(target: WebhookTarget) { + const existing = targetDebouncers.get(target); + if (existing) return existing; + + const { account, config, runtime, core } = target; + + const debouncer = core.channel.debounce.createInboundDebouncer({ + debounceMs: DEFAULT_INBOUND_DEBOUNCE_MS, + buildKey: (entry) => { + const msg = entry.message; + // Build key from account + chat + sender to coalesce messages from same source + const chatKey = + msg.chatGuid?.trim() ?? + msg.chatIdentifier?.trim() ?? + (msg.chatId ? String(msg.chatId) : "dm"); + return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`; + }, + shouldDebounce: (entry) => { + const msg = entry.message; + // Skip debouncing for messages with attachments - process immediately + if (msg.attachments && msg.attachments.length > 0) return false; + // Skip debouncing for from-me messages (they're just cached, not processed) + if (msg.fromMe) return false; + // Skip debouncing for control commands - process immediately + if (core.channel.text.hasControlCommand(msg.text, config)) return false; + // Debounce normal text messages and URL balloon messages + return true; + }, + onFlush: async (entries) => { + if (entries.length === 0) return; + + // Use target from first entry (all entries have same target due to key structure) + const flushTarget = entries[0].target; + + if (entries.length === 1) { + // Single message - process normally + await processMessage(entries[0].message, flushTarget); + return; + } + + // Multiple messages - combine and process + const combined = combineDebounceEntries(entries); + + if (core.logging.shouldLogVerbose()) { + const count = entries.length; + const preview = combined.text.slice(0, 50); + runtime.log?.( + `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`, + ); + } + + await processMessage(combined, flushTarget); + }, + onError: (err) => { + runtime.error?.(`[bluebubbles] debounce flush failed: ${String(err)}`); + }, + }); + + targetDebouncers.set(target, debouncer); + return debouncer; +} + +/** + * Removes a debouncer for a target (called during unregistration). + */ +function removeDebouncer(target: WebhookTarget): void { + targetDebouncers.delete(target); +} + function normalizeWebhookPath(raw: string): string { const trimmed = raw.trim(); if (!trimmed) return "/"; @@ -275,6 +452,8 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v } else { webhookTargets.delete(key); } + // Clean up debouncer when target is unregistered + removeDebouncer(normalizedTarget); }; } @@ -1205,7 +1384,10 @@ export async function handleBlueBubblesWebhookRequest( ); }); } else if (message) { - processMessage(message, target).catch((err) => { + // Route messages through debouncer to coalesce rapid-fire events + // (e.g., text message + URL balloon arriving as separate webhooks) + const debouncer = getOrCreateDebouncer(target); + debouncer.enqueue({ message, target }).catch((err) => { target.runtime.error?.( `[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`, ); From 420e5299d259199fa7887d159abe39f159e47a51 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Mon, 26 Jan 2026 13:23:56 -0800 Subject: [PATCH 03/43] fix(bluebubbles): increase inbound message debounce time for URL previews --- extensions/bluebubbles/src/monitor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index b754558bb..9015cf54e 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -264,7 +264,7 @@ type BlueBubblesDebounceEntry = { * This helps combine URL text + link preview balloon messages that BlueBubbles * sends as separate webhook events. */ -const DEFAULT_INBOUND_DEBOUNCE_MS = 100; +const DEFAULT_INBOUND_DEBOUNCE_MS = 350; /** * Known URLBalloonProvider bundle IDs that indicate a rich link preview message. From 147842fadc318d7ce9c81ef726b432d0b5547860 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Mon, 26 Jan 2026 14:04:03 -0800 Subject: [PATCH 04/43] refactor(bluebubbles): remove URL balloon message handling and improve error logging This commit removes the URL balloon message handling logic from the monitor, simplifying the message processing flow. Additionally, it enhances error logging by including the account ID in the error messages for better traceability. --- extensions/bluebubbles/src/monitor.ts | 20 +------------------- 1 file changed, 1 insertion(+), 19 deletions(-) diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index 9015cf54e..ac248f5a7 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -266,23 +266,6 @@ type BlueBubblesDebounceEntry = { */ const DEFAULT_INBOUND_DEBOUNCE_MS = 350; -/** - * Known URLBalloonProvider bundle IDs that indicate a rich link preview message. - */ -const URL_BALLOON_BUNDLE_IDS = new Set([ - "com.apple.messages.URLBalloonProvider", - "com.apple.messages.richLinkProvider", -]); - -/** - * Checks if a message is a URL balloon/link preview message. - */ -function isUrlBalloonMessage(message: NormalizedWebhookMessage): boolean { - const bundleId = message.balloonBundleId?.trim(); - if (!bundleId) return false; - return URL_BALLOON_BUNDLE_IDS.has(bundleId); -} - /** * Combines multiple debounced messages into a single message for processing. * Used when multiple webhook events arrive within the debounce window. @@ -297,7 +280,6 @@ function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): Normalized // Use the first message as the base (typically the text message) const first = entries[0].message; - const rest = entries.slice(1); // Combine text from all entries, filtering out duplicates and empty strings const seenTexts = new Set(); @@ -414,7 +396,7 @@ function getOrCreateDebouncer(target: WebhookTarget) { await processMessage(combined, flushTarget); }, onError: (err) => { - runtime.error?.(`[bluebubbles] debounce flush failed: ${String(err)}`); + runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`); }, }); From 9c0c5866dbf3b1e43f05bb0852196685008ca608 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Mon, 26 Jan 2026 14:11:37 -0800 Subject: [PATCH 05/43] fix: coalesce BlueBubbles link previews (#1981) (thanks @tyler6204) --- CHANGELOG.md | 1 + extensions/bluebubbles/src/monitor.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f1330931..2587a57b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ Status: unreleased. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index ac248f5a7..98431775a 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -262,7 +262,7 @@ type BlueBubblesDebounceEntry = { /** * Default debounce window for inbound message coalescing (ms). * This helps combine URL text + link preview balloon messages that BlueBubbles - * sends as separate webhook events. + * sends as separate webhook events when no explicit inbound debounce config exists. */ const DEFAULT_INBOUND_DEBOUNCE_MS = 350; @@ -339,6 +339,17 @@ const targetDebouncers = new Map< ReturnType >(); +function resolveBlueBubblesDebounceMs( + config: ClawdbotConfig, + core: BlueBubblesCoreRuntime, +): number { + const inbound = config.messages?.inbound; + const hasExplicitDebounce = + typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number"; + if (!hasExplicitDebounce) return DEFAULT_INBOUND_DEBOUNCE_MS; + return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" }); +} + /** * Creates or retrieves a debouncer for a webhook target. */ @@ -349,7 +360,7 @@ function getOrCreateDebouncer(target: WebhookTarget) { const { account, config, runtime, core } = target; const debouncer = core.channel.debounce.createInboundDebouncer({ - debounceMs: DEFAULT_INBOUND_DEBOUNCE_MS, + debounceMs: resolveBlueBubblesDebounceMs(config, core), buildKey: (entry) => { const msg = entry.message; // Build key from account + chat + sender to coalesce messages from same source From 0f8f0fb9d75170d0d9d5bc95e481a599faec50a0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 22:18:36 +0000 Subject: [PATCH 06/43] docs: clarify command authorization for exec directives --- docs/gateway/configuration.md | 2 ++ docs/gateway/sandbox-vs-tool-policy-vs-elevated.md | 3 +++ docs/gateway/sandboxing.md | 2 ++ docs/gateway/security.md | 10 ++++++++++ docs/tools/elevated.md | 1 + docs/tools/exec-approvals.md | 3 +++ docs/tools/exec.md | 7 +++++++ docs/tools/slash-commands.md | 2 ++ 8 files changed, 30 insertions(+) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index eaba866b1..31dd1602b 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -954,6 +954,8 @@ Notes: - `commands.debug: true` enables `/debug` (runtime-only overrides). - `commands.restart: true` enables `/restart` and the gateway tool restart action. - `commands.useAccessGroups: false` allows commands to bypass access-group allowlists/policies. +- Slash commands and directives are only honored for **authorized senders**. Authorization is derived from + channel allowlists/pairing plus `commands.useAccessGroups`. ### `web` (WhatsApp web channel runtime) diff --git a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md index d28481ebb..d7fd921e7 100644 --- a/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md +++ b/docs/gateway/sandbox-vs-tool-policy-vs-elevated.md @@ -59,6 +59,8 @@ Two layers matter: Rules of thumb: - `deny` always wins. - If `allow` is non-empty, everything else is treated as blocked. +- Tool policy is the hard stop: `/exec` cannot override a denied `exec` tool. +- `/exec` only changes session defaults for authorized senders; it does not grant tool access. Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`). ### Tool groups (shorthands) @@ -95,6 +97,7 @@ Elevated does **not** grant extra tools; it only affects `exec`. - Use `/elevated full` to skip exec approvals for the session. - If you’re already running direct, elevated is effectively a no-op (still gated). - Elevated is **not** skill-scoped and does **not** override tool allow/deny. +- `/exec` is separate from elevated. It only adjusts per-session exec defaults for authorized senders. Gates: - Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`) diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index b9b1bd8fe..fcbc46b9b 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -142,6 +142,8 @@ Tool allow/deny policies still apply before sandbox rules. If a tool is denied globally or per-agent, sandboxing doesn’t bring it back. `tools.elevated` is an explicit escape hatch that runs `exec` on the host. +`/exec` directives only apply for authorized senders and persist per session; to hard-disable +`exec`, use tool policy deny (see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)). Debugging: - Use `clawdbot sandbox explain` to inspect effective sandbox mode, tool policy, and fix-it config keys. diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 700e6fdaf..52671d864 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -142,6 +142,16 @@ Clawdbot’s stance: - **Scope next:** decide where the bot is allowed to act (group allowlists + mention gating, tools, sandboxing, device permissions). - **Model last:** assume the model can be manipulated; design so manipulation has limited blast radius. +## Command authorization model + +Slash commands and directives are only honored for **authorized senders**. Authorization is derived from +channel allowlists/pairing plus `commands.useAccessGroups` (see [Configuration](/gateway/configuration) +and [Slash commands](/tools/slash-commands)). If a channel allowlist is empty or includes `"*"`, +commands are effectively open for that channel. + +`/exec` is a session-only convenience for authorized operators. It does **not** write config or +change other sessions. + ## Plugins/extensions Plugins run **in-process** with the Gateway. Treat them as trusted code: diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md index 863c53a1f..7635bbbee 100644 --- a/docs/tools/elevated.md +++ b/docs/tools/elevated.md @@ -23,6 +23,7 @@ read_when: - **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require. - **Unsandboxed agents**: no-op for location; only affects gating, logging, and status. - **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used. +- **Separate from `/exec`**: `/exec` adjusts per-session defaults for authorized senders and does not require elevated. ## Resolution order 1. Inline directive on the message (applies only to that message). diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index ec350f9d9..2ec8ec191 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -216,6 +216,9 @@ Approval-gated execs reuse the approval id as the `runId` in these messages for - **full** is powerful; prefer allowlists when possible. - **ask** keeps you in the loop while still allowing fast approvals. - Per-agent allowlists prevent one agent’s approvals from leaking into others. +- Approvals only apply to host exec requests from **authorized senders**. Unauthorized senders cannot issue `/exec`. +- `/exec security=full` is a session-level convenience for authorized operators and skips approvals by design. + To hard-block host exec, set approvals security to `deny` or deny the `exec` tool via tool policy. Related: - [Exec tool](/tools/exec) diff --git a/docs/tools/exec.md b/docs/tools/exec.md index 9579a5c27..2524c3665 100644 --- a/docs/tools/exec.md +++ b/docs/tools/exec.md @@ -91,6 +91,13 @@ Example: /exec host=gateway security=allowlist ask=on-miss node=mac-1 ``` +## Authorization model + +`/exec` is only honored for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`). +It updates **session state only** and does not write config. To hard-disable exec, deny it via tool +policy (`tools.deny: ["exec"]` or per-agent). Host approvals still apply unless you explicitly set +`security=full` and `ask=off`. + ## Exec approvals (companion app / node host) Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host. diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md index 93b51d5ae..138ede9d0 100644 --- a/docs/tools/slash-commands.md +++ b/docs/tools/slash-commands.md @@ -16,6 +16,8 @@ There are two related systems: - Directives are stripped from the message before the model sees it. - In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings. - In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement. + - Directives are only applied for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`). + Unauthorized senders see directives treated as plain text. There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`). They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow. From 3888f1edc6ca943f587a3cd45f6875906a898156 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Sun, 25 Jan 2026 13:28:21 -0800 Subject: [PATCH 07/43] docs: update SKILL.md and generate_image.py to support multi-image editing and improve input handling --- skills/nano-banana-pro/SKILL.md | 9 ++- .../nano-banana-pro/scripts/generate_image.py | 67 ++++++++++++------- 2 files changed, 48 insertions(+), 28 deletions(-) diff --git a/skills/nano-banana-pro/SKILL.md b/skills/nano-banana-pro/SKILL.md index a36c21f64..469576ec7 100644 --- a/skills/nano-banana-pro/SKILL.md +++ b/skills/nano-banana-pro/SKILL.md @@ -14,9 +14,14 @@ Generate uv run {baseDir}/scripts/generate_image.py --prompt "your image description" --filename "output.png" --resolution 1K ``` -Edit +Edit (single image) ```bash -uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" --input-image "/path/in.png" --resolution 2K +uv run {baseDir}/scripts/generate_image.py --prompt "edit instructions" --filename "output.png" -i "/path/in.png" --resolution 2K +``` + +Multi-image composition (up to 14 images) +```bash +uv run {baseDir}/scripts/generate_image.py --prompt "combine these into one scene" --filename "output.png" -i img1.png -i img2.png -i img3.png ``` API key diff --git a/skills/nano-banana-pro/scripts/generate_image.py b/skills/nano-banana-pro/scripts/generate_image.py index 48dd9e9e5..32fc1fc32 100755 --- a/skills/nano-banana-pro/scripts/generate_image.py +++ b/skills/nano-banana-pro/scripts/generate_image.py @@ -11,6 +11,9 @@ Generate images using Google's Nano Banana Pro (Gemini 3 Pro Image) API. Usage: uv run generate_image.py --prompt "your image description" --filename "output.png" [--resolution 1K|2K|4K] [--api-key KEY] + +Multi-image editing (up to 14 images): + uv run generate_image.py --prompt "combine these images" --filename "output.png" -i img1.png -i img2.png -i img3.png """ import argparse @@ -42,7 +45,10 @@ def main(): ) parser.add_argument( "--input-image", "-i", - help="Optional input image path for editing/modification" + action="append", + dest="input_images", + metavar="IMAGE", + help="Input image path(s) for editing/composition. Can be specified multiple times (up to 14 images)." ) parser.add_argument( "--resolution", "-r", @@ -78,34 +84,43 @@ def main(): output_path = Path(args.filename) output_path.parent.mkdir(parents=True, exist_ok=True) - # Load input image if provided - input_image = None + # Load input images if provided (up to 14 supported by Nano Banana Pro) + input_images = [] output_resolution = args.resolution - if args.input_image: - try: - input_image = PILImage.open(args.input_image) - print(f"Loaded input image: {args.input_image}") - - # Auto-detect resolution if not explicitly set by user - if args.resolution == "1K": # Default value - # Map input image size to resolution - width, height = input_image.size - max_dim = max(width, height) - if max_dim >= 3000: - output_resolution = "4K" - elif max_dim >= 1500: - output_resolution = "2K" - else: - output_resolution = "1K" - print(f"Auto-detected resolution: {output_resolution} (from input {width}x{height})") - except Exception as e: - print(f"Error loading input image: {e}", file=sys.stderr) + if args.input_images: + if len(args.input_images) > 14: + print(f"Error: Too many input images ({len(args.input_images)}). Maximum is 14.", file=sys.stderr) sys.exit(1) - # Build contents (image first if editing, prompt only if generating) - if input_image: - contents = [input_image, args.prompt] - print(f"Editing image with resolution {output_resolution}...") + max_input_dim = 0 + for img_path in args.input_images: + try: + img = PILImage.open(img_path) + input_images.append(img) + print(f"Loaded input image: {img_path}") + + # Track largest dimension for auto-resolution + width, height = img.size + max_input_dim = max(max_input_dim, width, height) + except Exception as e: + print(f"Error loading input image '{img_path}': {e}", file=sys.stderr) + sys.exit(1) + + # Auto-detect resolution from largest input if not explicitly set + if args.resolution == "1K" and max_input_dim > 0: # Default value + if max_input_dim >= 3000: + output_resolution = "4K" + elif max_input_dim >= 1500: + output_resolution = "2K" + else: + output_resolution = "1K" + print(f"Auto-detected resolution: {output_resolution} (from max input dimension {max_input_dim})") + + # Build contents (images first if editing, prompt only if generating) + if input_images: + contents = [*input_images, args.prompt] + img_count = len(input_images) + print(f"Processing {img_count} image{'s' if img_count > 1 else ''} with resolution {output_resolution}...") else: contents = args.prompt print(f"Generating image with resolution {output_resolution}...") From fe1f2d971ab399366b205a9889f8ef485ff21dec Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Mon, 26 Jan 2026 14:22:24 -0800 Subject: [PATCH 08/43] fix: add multi-image input support to nano-banana-pro skill (#1958) (thanks @tyler6204) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2587a57b5..1edda7aab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot Status: unreleased. ### Changes +- Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204. - Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) - Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. - Docs: add migration guide for moving to a new machine. (#2381) From b3a60af71c0343843662187f53130d34431c9d65 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 22:26:22 +0000 Subject: [PATCH 09/43] fix: gate ngrok free-tier bypass to loopback --- docs/plugins/voice-call.md | 1 + extensions/voice-call/CHANGELOG.md | 1 + extensions/voice-call/README.md | 1 + extensions/voice-call/clawdbot.plugin.json | 6 ++-- extensions/voice-call/index.ts | 4 +-- extensions/voice-call/src/config.test.ts | 2 +- extensions/voice-call/src/config.ts | 17 ++++++++--- extensions/voice-call/src/providers/twilio.ts | 4 +-- .../src/providers/twilio/webhook.ts | 3 +- extensions/voice-call/src/runtime.ts | 14 ++++++++- extensions/voice-call/src/types.ts | 1 + .../voice-call/src/webhook-security.test.ts | 29 ++++++++++++++++++- extensions/voice-call/src/webhook-security.ts | 27 +++++++++++++++-- extensions/voice-call/src/webhook.ts | 1 + 14 files changed, 94 insertions(+), 17 deletions(-) diff --git a/docs/plugins/voice-call.md b/docs/plugins/voice-call.md index cd574b26e..46713c939 100644 --- a/docs/plugins/voice-call.md +++ b/docs/plugins/voice-call.md @@ -104,6 +104,7 @@ Notes: - `mock` is a local dev provider (no network calls). - `skipSignatureVerification` is for local testing only. - If you use ngrok free tier, set `publicUrl` to the exact ngrok URL; signature verification is always enforced. +- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only. - Ngrok free tier URLs can change or add interstitial behavior; if `publicUrl` drifts, Twilio signatures will fail. For production, prefer a stable domain or Tailscale funnel. ## TTS for calls diff --git a/extensions/voice-call/CHANGELOG.md b/extensions/voice-call/CHANGELOG.md index a8721d47d..588817858 100644 --- a/extensions/voice-call/CHANGELOG.md +++ b/extensions/voice-call/CHANGELOG.md @@ -6,6 +6,7 @@ - Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core). - Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls. - Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields. +- Ngrok free-tier bypass renamed to `tunnel.allowNgrokFreeTierLoopbackBypass` and gated to loopback + `tunnel.provider="ngrok"`. ## 2026.1.23 diff --git a/extensions/voice-call/README.md b/extensions/voice-call/README.md index d96f90392..5f009aa28 100644 --- a/extensions/voice-call/README.md +++ b/extensions/voice-call/README.md @@ -74,6 +74,7 @@ Put under `plugins.entries.voice-call.config`: Notes: - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL. - `mock` is a local dev provider (no network calls). +- `tunnel.allowNgrokFreeTierLoopbackBypass: true` allows Twilio webhooks with invalid signatures **only** when `tunnel.provider="ngrok"` and `serve.bind` is loopback (ngrok local agent). Use for local dev only. ## TTS for calls diff --git a/extensions/voice-call/clawdbot.plugin.json b/extensions/voice-call/clawdbot.plugin.json index 2a4f04466..cfac7ad9d 100644 --- a/extensions/voice-call/clawdbot.plugin.json +++ b/extensions/voice-call/clawdbot.plugin.json @@ -78,8 +78,8 @@ "label": "ngrok Domain", "advanced": true }, - "tunnel.allowNgrokFreeTier": { - "label": "Allow ngrok Free Tier", + "tunnel.allowNgrokFreeTierLoopbackBypass": { + "label": "Allow ngrok Free Tier (Loopback Bypass)", "advanced": true }, "streaming.enabled": { @@ -330,7 +330,7 @@ "ngrokDomain": { "type": "string" }, - "allowNgrokFreeTier": { + "allowNgrokFreeTierLoopbackBypass": { "type": "boolean" } } diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 60076bbe2..60cb64eb2 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -62,8 +62,8 @@ const voiceCallConfigSchema = { advanced: true, }, "tunnel.ngrokDomain": { label: "ngrok Domain", advanced: true }, - "tunnel.allowNgrokFreeTier": { - label: "Allow ngrok Free Tier", + "tunnel.allowNgrokFreeTierLoopbackBypass": { + label: "Allow ngrok Free Tier (Loopback Bypass)", advanced: true, }, "streaming.enabled": { label: "Enable Streaming", advanced: true }, diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts index aac9fe44c..dde17e122 100644 --- a/extensions/voice-call/src/config.test.ts +++ b/extensions/voice-call/src/config.test.ts @@ -19,7 +19,7 @@ function createBaseConfig( maxConcurrentCalls: 1, serve: { port: 3334, bind: "127.0.0.1", path: "/voice/webhook" }, tailscale: { mode: "off", path: "/voice/webhook" }, - tunnel: { provider: "none", allowNgrokFreeTier: false }, + tunnel: { provider: "none", allowNgrokFreeTierLoopbackBypass: false }, streaming: { enabled: false, sttProvider: "openai-realtime", diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts index 99916e49d..7784406e7 100644 --- a/extensions/voice-call/src/config.ts +++ b/extensions/voice-call/src/config.ts @@ -217,12 +217,17 @@ export const VoiceCallTunnelConfigSchema = z /** * Allow ngrok free tier compatibility mode. * When true, signature verification failures on ngrok-free.app URLs - * will include extra diagnostics. Signature verification is still required. + * will be allowed only for loopback requests (ngrok local agent). */ - allowNgrokFreeTier: z.boolean().default(false), + allowNgrokFreeTierLoopbackBypass: z.boolean().default(false), + /** + * Legacy ngrok free tier compatibility mode (deprecated). + * Use allowNgrokFreeTierLoopbackBypass instead. + */ + allowNgrokFreeTier: z.boolean().optional(), }) .strict() - .default({ provider: "none", allowNgrokFreeTier: false }); + .default({ provider: "none", allowNgrokFreeTierLoopbackBypass: false }); export type VoiceCallTunnelConfig = z.infer; // ----------------------------------------------------------------------------- @@ -419,8 +424,12 @@ export function resolveVoiceCallConfig(config: VoiceCallConfig): VoiceCallConfig // Tunnel Config resolved.tunnel = resolved.tunnel ?? { provider: "none", - allowNgrokFreeTier: false, + allowNgrokFreeTierLoopbackBypass: false, }; + resolved.tunnel.allowNgrokFreeTierLoopbackBypass = + resolved.tunnel.allowNgrokFreeTierLoopbackBypass || + resolved.tunnel.allowNgrokFreeTier || + false; resolved.tunnel.ngrokAuthToken = resolved.tunnel.ngrokAuthToken ?? process.env.NGROK_AUTHTOKEN; resolved.tunnel.ngrokDomain = diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index be9dd6eda..87c0f244d 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -31,8 +31,8 @@ import { verifyTwilioProviderWebhook } from "./twilio/webhook.js"; * @see https://www.twilio.com/docs/voice/media-streams */ export interface TwilioProviderOptions { - /** Allow ngrok free tier compatibility mode (less secure) */ - allowNgrokFreeTier?: boolean; + /** Allow ngrok free tier compatibility mode (loopback only, less secure) */ + allowNgrokFreeTierLoopbackBypass?: boolean; /** Override public URL for signature verification */ publicUrl?: string; /** Path for media stream WebSocket (e.g., /voice/stream) */ diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts index 1cddcb164..d5c3abb95 100644 --- a/extensions/voice-call/src/providers/twilio/webhook.ts +++ b/extensions/voice-call/src/providers/twilio/webhook.ts @@ -11,7 +11,8 @@ export function verifyTwilioProviderWebhook(params: { }): WebhookVerificationResult { const result = verifyTwilioWebhook(params.ctx, params.authToken, { publicUrl: params.currentPublicUrl || undefined, - allowNgrokFreeTier: params.options.allowNgrokFreeTier ?? false, + allowNgrokFreeTierLoopbackBypass: + params.options.allowNgrokFreeTierLoopbackBypass ?? false, skipVerification: params.options.skipVerification, }); diff --git a/extensions/voice-call/src/runtime.ts b/extensions/voice-call/src/runtime.ts index ffa95ddff..6f638ab5b 100644 --- a/extensions/voice-call/src/runtime.ts +++ b/extensions/voice-call/src/runtime.ts @@ -33,7 +33,19 @@ type Logger = { debug: (message: string) => void; }; +function isLoopbackBind(bind: string | undefined): boolean { + if (!bind) return false; + return bind === "127.0.0.1" || bind === "::1" || bind === "localhost"; +} + function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { + const allowNgrokFreeTierLoopbackBypass = + config.tunnel?.provider === "ngrok" && + isLoopbackBind(config.serve?.bind) && + (config.tunnel?.allowNgrokFreeTierLoopbackBypass || + config.tunnel?.allowNgrokFreeTier || + false); + switch (config.provider) { case "telnyx": return new TelnyxProvider({ @@ -48,7 +60,7 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider { authToken: config.twilio?.authToken, }, { - allowNgrokFreeTier: config.tunnel?.allowNgrokFreeTier ?? false, + allowNgrokFreeTierLoopbackBypass, publicUrl: config.publicUrl, skipVerification: config.skipSignatureVerification, streamPath: config.streaming?.enabled diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts index 7f3928778..68cca11e6 100644 --- a/extensions/voice-call/src/types.ts +++ b/extensions/voice-call/src/types.ts @@ -180,6 +180,7 @@ export type WebhookContext = { url: string; method: "GET" | "POST" | "PUT" | "DELETE" | "PATCH"; query?: Record; + remoteAddress?: string; }; export type ProviderWebhookParseResult = { diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index 98d8a451c..3db2983ec 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -221,13 +221,40 @@ describe("verifyTwilioWebhook", () => { rawBody: postBody, url: "http://127.0.0.1:3334/voice/webhook", method: "POST", + remoteAddress: "203.0.113.10", }, authToken, - { allowNgrokFreeTier: true }, + { allowNgrokFreeTierLoopbackBypass: true }, ); expect(result.ok).toBe(false); expect(result.isNgrokFreeTier).toBe(true); expect(result.reason).toMatch(/Invalid signature/); }); + + it("allows invalid signatures for ngrok free tier only on loopback", () => { + const authToken = "test-auth-token"; + const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000"; + + const result = verifyTwilioWebhook( + { + headers: { + host: "127.0.0.1:3334", + "x-forwarded-proto": "https", + "x-forwarded-host": "local.ngrok-free.app", + "x-twilio-signature": "invalid", + }, + rawBody: postBody, + url: "http://127.0.0.1:3334/voice/webhook", + method: "POST", + remoteAddress: "127.0.0.1", + }, + authToken, + { allowNgrokFreeTierLoopbackBypass: true }, + ); + + expect(result.ok).toBe(true); + expect(result.isNgrokFreeTier).toBe(true); + expect(result.reason).toMatch(/compatibility mode/); + }); }); diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index 98b1d9837..6c7d4d9ab 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -131,6 +131,13 @@ function getHeader( return value; } +function isLoopbackAddress(address?: string): boolean { + if (!address) return false; + if (address === "127.0.0.1" || address === "::1") return true; + if (address.startsWith("::ffff:127.")) return true; + return false; +} + /** * Result of Twilio webhook verification with detailed info. */ @@ -155,8 +162,8 @@ export function verifyTwilioWebhook( options?: { /** Override the public URL (e.g., from config) */ publicUrl?: string; - /** Allow ngrok free tier compatibility mode (less secure) */ - allowNgrokFreeTier?: boolean; + /** Allow ngrok free tier compatibility mode (loopback only, less secure) */ + allowNgrokFreeTierLoopbackBypass?: boolean; /** Skip verification entirely (only for development) */ skipVerification?: boolean; }, @@ -195,6 +202,22 @@ export function verifyTwilioWebhook( verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io"); + if ( + isNgrokFreeTier && + options?.allowNgrokFreeTierLoopbackBypass && + isLoopbackAddress(ctx.remoteAddress) + ) { + console.warn( + "[voice-call] Twilio signature validation failed (ngrok free tier compatibility, loopback only)", + ); + return { + ok: true, + reason: "ngrok free tier compatibility mode (loopback only)", + verificationUrl, + isNgrokFreeTier: true, + }; + } + return { ok: false, reason: `Invalid signature for URL: ${verificationUrl}`, diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 6ab4d0eed..09e96ffed 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -252,6 +252,7 @@ export class VoiceCallWebhookServer { url: `http://${req.headers.host}${req.url}`, method: "POST", query: Object.fromEntries(url.searchParams), + remoteAddress: req.socket.remoteAddress ?? undefined, }; // Verify signature From 2807f5afbce27173bd1b935f99ce73a8d48c1798 Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Mon, 26 Jan 2026 16:03:59 -0500 Subject: [PATCH 10/43] feat: add heartbeat visibility filtering for webchat - Add isHeartbeat to AgentRunContext to track heartbeat runs - Pass isHeartbeat flag through agent runner execution - Suppress webchat broadcast (deltas + final) for heartbeat runs when showOk is false - Webchat uses channels.defaults.heartbeat settings (no per-channel config) - Default behavior: hide HEARTBEAT_OK from webchat (matches other channels) This allows users to control whether heartbeat responses appear in the webchat UI via channels.defaults.heartbeat.showOk (defaults to false). --- .../reply/agent-runner-execution.ts | 1 + src/gateway/server-chat.ts | 30 ++++++++++- src/infra/agent-events.ts | 4 ++ src/infra/heartbeat-visibility.test.ts | 54 +++++++++++++++++++ src/infra/heartbeat-visibility.ts | 19 ++++++- 5 files changed, 104 insertions(+), 4 deletions(-) diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 939fa92f0..3537972e4 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -89,6 +89,7 @@ export async function runAgentTurnWithFallback(params: { registerAgentRunContext(runId, { sessionKey: params.sessionKey, verboseLevel: params.resolvedVerboseLevel, + isHeartbeat: params.isHeartbeat, }); } let runResult: Awaited>; diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 9ef62e688..8c67767a6 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -1,8 +1,28 @@ import { normalizeVerboseLevel } from "../auto-reply/thinking.js"; +import { loadConfig } from "../config/config.js"; import { type AgentEventPayload, getAgentRunContext } from "../infra/agent-events.js"; +import { resolveHeartbeatVisibility } from "../infra/heartbeat-visibility.js"; import { loadSessionEntry } from "./session-utils.js"; import { formatForLog } from "./ws-log.js"; +/** + * Check if webchat broadcasts should be suppressed for heartbeat runs. + * Returns true if the run is a heartbeat and showOk is false. + */ +function shouldSuppressHeartbeatBroadcast(runId: string): boolean { + const runContext = getAgentRunContext(runId); + if (!runContext?.isHeartbeat) return false; + + try { + const cfg = loadConfig(); + const visibility = resolveHeartbeatVisibility({ cfg, channel: "webchat" }); + return !visibility.showOk; + } catch { + // Default to suppressing if we can't load config + return true; + } +} + export type ChatRunEntry = { sessionKey: string; clientRunId: string; @@ -130,7 +150,10 @@ export function createAgentEventHandler({ timestamp: now, }, }; - broadcast("chat", payload, { dropIfSlow: true }); + // Suppress webchat broadcast for heartbeat runs when showOk is false + if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { + broadcast("chat", payload, { dropIfSlow: true }); + } nodeSendToSession(sessionKey, "chat", payload); }; @@ -158,7 +181,10 @@ export function createAgentEventHandler({ } : undefined, }; - broadcast("chat", payload); + // Suppress webchat broadcast for heartbeat runs when showOk is false + if (!shouldSuppressHeartbeatBroadcast(clientRunId)) { + broadcast("chat", payload); + } nodeSendToSession(sessionKey, "chat", payload); return; } diff --git a/src/infra/agent-events.ts b/src/infra/agent-events.ts index c11dff8ab..5c41c3c95 100644 --- a/src/infra/agent-events.ts +++ b/src/infra/agent-events.ts @@ -14,6 +14,7 @@ export type AgentEventPayload = { export type AgentRunContext = { sessionKey?: string; verboseLevel?: VerboseLevel; + isHeartbeat?: boolean; }; // Keep per-run counters so streams stay strictly monotonic per runId. @@ -34,6 +35,9 @@ export function registerAgentRunContext(runId: string, context: AgentRunContext) if (context.verboseLevel && existing.verboseLevel !== context.verboseLevel) { existing.verboseLevel = context.verboseLevel; } + if (context.isHeartbeat !== undefined && existing.isHeartbeat !== context.isHeartbeat) { + existing.isHeartbeat = context.isHeartbeat; + } } export function getAgentRunContext(runId: string) { diff --git a/src/infra/heartbeat-visibility.test.ts b/src/infra/heartbeat-visibility.test.ts index 17a7dc128..e98054bbb 100644 --- a/src/infra/heartbeat-visibility.test.ts +++ b/src/infra/heartbeat-visibility.test.ts @@ -247,4 +247,58 @@ describe("resolveHeartbeatVisibility", () => { useIndicator: true, }); }); + + it("webchat uses channel defaults only (no per-channel config)", () => { + const cfg = { + channels: { + defaults: { + heartbeat: { + showOk: true, + showAlerts: false, + useIndicator: false, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" }); + + expect(result).toEqual({ + showOk: true, + showAlerts: false, + useIndicator: false, + }); + }); + + it("webchat returns defaults when no channel defaults configured", () => { + const cfg = {} as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ cfg, channel: "webchat" }); + + expect(result).toEqual({ + showOk: false, + showAlerts: true, + useIndicator: true, + }); + }); + + it("webchat ignores accountId (only uses defaults)", () => { + const cfg = { + channels: { + defaults: { + heartbeat: { + showOk: true, + }, + }, + }, + } as ClawdbotConfig; + + const result = resolveHeartbeatVisibility({ + cfg, + channel: "webchat", + accountId: "some-account", + }); + + expect(result.showOk).toBe(true); + }); }); diff --git a/src/infra/heartbeat-visibility.ts b/src/infra/heartbeat-visibility.ts index 75555b878..e4943464c 100644 --- a/src/infra/heartbeat-visibility.ts +++ b/src/infra/heartbeat-visibility.ts @@ -1,6 +1,6 @@ import type { ClawdbotConfig } from "../config/config.js"; import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js"; -import type { DeliverableMessageChannel } from "../utils/message-channel.js"; +import type { DeliverableMessageChannel, GatewayMessageChannel } from "../utils/message-channel.js"; export type ResolvedHeartbeatVisibility = { showOk: boolean; @@ -14,13 +14,28 @@ const DEFAULT_VISIBILITY: ResolvedHeartbeatVisibility = { useIndicator: true, // Emit indicator events }; +/** + * Resolve heartbeat visibility settings for a channel. + * Supports both deliverable channels (telegram, signal, etc.) and webchat. + * For webchat, uses channels.defaults.heartbeat since webchat doesn't have per-channel config. + */ export function resolveHeartbeatVisibility(params: { cfg: ClawdbotConfig; - channel: DeliverableMessageChannel; + channel: GatewayMessageChannel; accountId?: string; }): ResolvedHeartbeatVisibility { const { cfg, channel, accountId } = params; + // Webchat uses channel defaults only (no per-channel or per-account config) + if (channel === "webchat") { + const channelDefaults = cfg.channels?.defaults?.heartbeat; + return { + showOk: channelDefaults?.showOk ?? DEFAULT_VISIBILITY.showOk, + showAlerts: channelDefaults?.showAlerts ?? DEFAULT_VISIBILITY.showAlerts, + useIndicator: channelDefaults?.useIndicator ?? DEFAULT_VISIBILITY.useIndicator, + }; + } + // Layer 1: Global channel defaults const channelDefaults = cfg.channels?.defaults?.heartbeat; From 6cbdd767afc2fe5179b555995e3ff0153f49eba4 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 22:58:05 +0000 Subject: [PATCH 11/43] fix: pin tar override for npm installs --- CHANGELOG.md | 1 + package.json | 3 +++ 2 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1edda7aab..fbe151592 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ Status: unreleased. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Security: pin npm overrides to keep tar@7.5.4 for install toolchains. - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. diff --git a/package.json b/package.json index 0c63d5d69..1299d72d5 100644 --- a/package.json +++ b/package.json @@ -237,6 +237,9 @@ "vitest": "^4.0.18", "wireit": "^0.14.12" }, + "overrides": { + "tar": "7.5.4" + }, "pnpm": { "minimumReleaseAge": 2880, "overrides": { From 0aa48a26d1545aca659251b84d7401229612a3fc Mon Sep 17 00:00:00 2001 From: adeboyedn Date: Mon, 26 Jan 2026 08:07:33 +0100 Subject: [PATCH 12/43] docs: add Northflank deployment guide for Clawdbot --- docs/docs.json | 4 ++++ docs/help/faq.md | 2 ++ docs/northflank.mdx | 51 +++++++++++++++++++++++++++++++++++++++++ docs/platforms/index.md | 2 ++ docs/vps.md | 2 ++ 5 files changed, 61 insertions(+) create mode 100644 docs/northflank.mdx diff --git a/docs/docs.json b/docs/docs.json index 2cc5ae78b..12c6cc36b 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -805,6 +805,10 @@ "source": "/install/railway/", "destination": "/railway" }, + { + "source": "/install/northflank/", + "destination": "/northflank" + }, { "source": "/gcp", "destination": "/platforms/gcp" diff --git a/docs/help/faq.md b/docs/help/faq.md index 336b324c9..165895e53 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -566,6 +566,8 @@ Remote access: [Gateway remote](/gateway/remote). We keep a **hosting hub** with the common providers. Pick one and follow the guide: - [VPS hosting](/vps) (all providers in one place) +- [Railway](/railway) (one‑click, browser‑based setup) +- [Northflank](/northflank) (one‑click, browser‑based setup) - [Fly.io](/platforms/fly) - [Hetzner](/platforms/hetzner) - [exe.dev](/platforms/exe-dev) diff --git a/docs/northflank.mdx b/docs/northflank.mdx new file mode 100644 index 000000000..1a53bf2fe --- /dev/null +++ b/docs/northflank.mdx @@ -0,0 +1,51 @@ +--- +title: Deploy on Northflank +--- + +Deploy Clawdbot on Northflank with a one-click template and finish setup in your browser. +This is the easiest “no terminal on the server” path: Northflank runs the Gateway for you, +and you configure everything via the `/setup` web wizard. + +## How to get started + +1. Click [Deploy Clawdbot](https://northflank.com/stacks/deploy-clawdbot) to open the template. +2. Create an [account on Northflank](https://app.northflank.com/signup) if you don’t already have one. +3. Click **Deploy Clawdbot now**. +4. Set the required environment variable: `SETUP_PASSWORD` +5. Click **Deploy stack** to build and run the Clawdbot template. +6. Wait for the deployment to complete, then click **View resources**. +7. Open the Clawdbot service. +8. Open the public Clawdbot URL and complete setup at `/setup`. + +## What you get + +- Hosted Clawdbot Gateway + Control UI +- Web setup wizard at `/setup` (no terminal commands) +- Persistent storage via Northflank Volume (`/data`) so config/credentials/workspace survive redeploys + +## Setup flow + +1) Visit `https:///setup` and enter your `SETUP_PASSWORD`. +2) Choose a model/auth provider and paste your key. +3) (Optional) Add Telegram/Discord/Slack tokens. +4) Click **Run setup**. + +If Telegram DMs are set to pairing, the setup wizard can approve the pairing code. + +## Getting chat tokens + +### Telegram bot token + +1) Message `@BotFather` in Telegram +2) Run `/newbot` +3) Copy the token (looks like `123456789:AA...`) +4) Paste it into `/setup` + +### Discord bot token + +1) Go to https://discord.com/developers/applications +2) **New Application** → choose a name +3) **Bot** → **Add Bot** +4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup) +5) Copy the **Bot Token** and paste into `/setup` +6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`) diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 3a1e87267..69b37090e 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -24,6 +24,8 @@ Native companion apps for Windows are also planned; the Gateway is recommended v ## VPS & hosting - VPS hub: [VPS hosting](/vps) +- Railway (one-click): [Railway](/railway) +- Northflank (one-click): [Northflank](/northflank) - Fly.io: [Fly.io](/platforms/fly) - Hetzner (Docker): [Hetzner](/platforms/hetzner) - GCP (Compute Engine): [GCP](/platforms/gcp) diff --git a/docs/vps.md b/docs/vps.md index 192ab830e..08910733f 100644 --- a/docs/vps.md +++ b/docs/vps.md @@ -11,6 +11,8 @@ deployments work at a high level. ## Pick a provider +- **Railway** (one‑click + browser setup): [Railway](/railway) +- **Northflank** (one‑click + browser setup): [Northflank](/northflank) - **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky) - **Fly.io**: [Fly.io](/platforms/fly) - **Hetzner (Docker)**: [Hetzner](/platforms/hetzner) From 2a709385f8c74b5c503c58983df8bc5fee253cea Mon Sep 17 00:00:00 2001 From: adeboyedn Date: Mon, 26 Jan 2026 15:05:42 +0100 Subject: [PATCH 13/43] cleanup --- docs/help/faq.md | 2 -- docs/platforms/index.md | 2 -- 2 files changed, 4 deletions(-) diff --git a/docs/help/faq.md b/docs/help/faq.md index 165895e53..336b324c9 100644 --- a/docs/help/faq.md +++ b/docs/help/faq.md @@ -566,8 +566,6 @@ Remote access: [Gateway remote](/gateway/remote). We keep a **hosting hub** with the common providers. Pick one and follow the guide: - [VPS hosting](/vps) (all providers in one place) -- [Railway](/railway) (one‑click, browser‑based setup) -- [Northflank](/northflank) (one‑click, browser‑based setup) - [Fly.io](/platforms/fly) - [Hetzner](/platforms/hetzner) - [exe.dev](/platforms/exe-dev) diff --git a/docs/platforms/index.md b/docs/platforms/index.md index 69b37090e..3a1e87267 100644 --- a/docs/platforms/index.md +++ b/docs/platforms/index.md @@ -24,8 +24,6 @@ Native companion apps for Windows are also planned; the Gateway is recommended v ## VPS & hosting - VPS hub: [VPS hosting](/vps) -- Railway (one-click): [Railway](/railway) -- Northflank (one-click): [Northflank](/northflank) - Fly.io: [Fly.io](/platforms/fly) - Hetzner (Docker): [Hetzner](/platforms/hetzner) - GCP (Compute Engine): [GCP](/platforms/gcp) From 99ce47e86af55b524883f674feee1b05e43dcc0a Mon Sep 17 00:00:00 2001 From: adeboyedn Date: Mon, 26 Jan 2026 15:41:19 +0100 Subject: [PATCH 14/43] minor update --- docs/northflank.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/northflank.mdx b/docs/northflank.mdx index 1a53bf2fe..7108278fe 100644 --- a/docs/northflank.mdx +++ b/docs/northflank.mdx @@ -16,6 +16,7 @@ and you configure everything via the `/setup` web wizard. 6. Wait for the deployment to complete, then click **View resources**. 7. Open the Clawdbot service. 8. Open the public Clawdbot URL and complete setup at `/setup`. +9. Open the Control UI at `/clawdbot` ## What you get @@ -29,6 +30,7 @@ and you configure everything via the `/setup` web wizard. 2) Choose a model/auth provider and paste your key. 3) (Optional) Add Telegram/Discord/Slack tokens. 4) Click **Run setup**. +5) Open the Control UI at `https:///clawdbot` If Telegram DMs are set to pairing, the setup wizard can approve the pairing code. From 107f07ad69335c8a877cc0770396dc8c48f1563b Mon Sep 17 00:00:00 2001 From: Clawdbot Maintainers Date: Mon, 26 Jan 2026 15:01:10 -0800 Subject: [PATCH 15/43] docs: add Northflank page to nav + polish copy --- docs/docs.json | 1 + docs/northflank.mdx | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index 12c6cc36b..c53902451 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -856,6 +856,7 @@ "install/docker", "railway", "render", + "northflank", "install/bun" ] }, diff --git a/docs/northflank.mdx b/docs/northflank.mdx index 7108278fe..aae9c6a22 100644 --- a/docs/northflank.mdx +++ b/docs/northflank.mdx @@ -11,12 +11,12 @@ and you configure everything via the `/setup` web wizard. 1. Click [Deploy Clawdbot](https://northflank.com/stacks/deploy-clawdbot) to open the template. 2. Create an [account on Northflank](https://app.northflank.com/signup) if you don’t already have one. 3. Click **Deploy Clawdbot now**. -4. Set the required environment variable: `SETUP_PASSWORD` -5. Click **Deploy stack** to build and run the Clawdbot template. -6. Wait for the deployment to complete, then click **View resources**. -7. Open the Clawdbot service. +4. Set the required environment variable: `SETUP_PASSWORD`. +5. Click **Deploy stack** to build and run the Clawdbot template. +6. Wait for the deployment to complete, then click **View resources**. +7. Open the Clawdbot service. 8. Open the public Clawdbot URL and complete setup at `/setup`. -9. Open the Control UI at `/clawdbot` +9. Open the Control UI at `/clawdbot`. ## What you get From cd7be58b8ecb03c3249be6f8faefef9083de70e1 Mon Sep 17 00:00:00 2001 From: vignesh07 Date: Mon, 26 Jan 2026 15:10:31 -0800 Subject: [PATCH 16/43] docs: add Northflank deploy guide to changelog (#2167) (thanks @AdeboyeDN) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fbe151592..a45315b20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Status: unreleased. - Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) - Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. - Docs: add migration guide for moving to a new machine. (#2381) +- Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN. - Gateway: warn on hook tokens via query params; document header auth preference. (#2200) Thanks @YuriNachos. - Gateway: add dangerous Control UI device auth bypass flag + audit warnings. (#2248) - Doctor: warn on gateway exposure without auth. (#2016) Thanks @Alex-Alaniz. From 82746973d4fe37ddc727eefdcc408a82697b8eaf Mon Sep 17 00:00:00 2001 From: Dave Lauer Date: Mon, 26 Jan 2026 09:50:26 -0500 Subject: [PATCH 17/43] fix(heartbeat): remove unhandled rejection crash in wake handler The async setTimeout callback re-threw errors without a .catch() handler, causing unhandled promise rejections that crashed the gateway. The error is already logged by the heartbeat runner and a retry is scheduled, so the re-throw served no purpose. Co-Authored-By: Claude Opus 4.5 --- src/infra/heartbeat-wake.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/infra/heartbeat-wake.ts b/src/infra/heartbeat-wake.ts index 9d5a4c4ce..eb26bf499 100644 --- a/src/infra/heartbeat-wake.ts +++ b/src/infra/heartbeat-wake.ts @@ -37,10 +37,10 @@ function schedule(coalesceMs: number) { pendingReason = reason ?? "retry"; schedule(DEFAULT_RETRY_MS); } - } catch (err) { + } catch { + // Error is already logged by the heartbeat runner; schedule a retry. pendingReason = reason ?? "retry"; schedule(DEFAULT_RETRY_MS); - throw err; } finally { running = false; if (pendingReason || scheduled) schedule(coalesceMs); From 91d5ea6e331c89b00ff449c3d6fd11dd3b37042a Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 17:21:29 -0600 Subject: [PATCH 18/43] Fix: allow cron heartbeat payloads through filters (#2219) (thanks @dwfinkelstein) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 1 + README.md | 62 ++++++++++++------------- src/auto-reply/reply/session-updates.ts | 6 ++- 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a45315b20..6d8725030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Status: unreleased. ### Fixes - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. +- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. diff --git a/README.md b/README.md index 535cd1c75..3f8853b93 100644 --- a/README.md +++ b/README.md @@ -477,35 +477,35 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and Thanks to all clawtributors:

- steipete plum-dawg bohdanpodvirnyi iHildy joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg rahthakor - vrknetha radek-paclt Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall xadenryan rodrigouroz - juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub abhisekbasu1 - jamesgroat claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] lc0rp mousberg - vignesh07 mteam88 joeynyc orlyjamie dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest - benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 - stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko - denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 danielz1z emanuelst - KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams sheeek artuskg - Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby buddyh connorshea - kyleok mcinteerj dependabot[bot] John-Rood timkrase uos-status gerardward2007 obviyus roshanasingh4 tosh-hamburg - azade-c JonUleis bjesuiter cheeeee Josh Phillips YuriNachos robbyczgw-cla dlauer pookNast Whoaa512 - chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 antons austinm911 blacksmith-sh[bot] - damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures - Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr - neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Kit koala73 manmal - ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh svkozak VACInc wes-davis zats - 24601 adam91holt ameno- Chris Taylor Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 - oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd - ClawdFx dguido EnzeD erik-agens Evizero fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey - jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess robaxelsen - Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker abhaymundhara alejandro maza Alex-Alaniz andrewting19 - anpoirier arthyn Asleep123 bolismauro conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 - Felix Krause foeken ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis - Jefferson Nunn Kevin Lin kitze levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 - Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro prathamdby ptn1411 reeltimeapps - RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht - snopoke testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai - ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik - hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani - William Stock + steipete plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg + rahthakor vrknetha radek-paclt Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall xadenryan + rodrigouroz juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub + abhisekbasu1 jamesgroat claude JustYannicc vignesh07 Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] + lc0rp mousberg mteam88 hirefrank joeynyc orlyjamie dbhurley Mariano Belinky Eng. Juan Combetto TSavo + julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath + gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan + davidguttman sleontenko denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 + danielz1z emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams + sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby + buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase uos-status gerardward2007 + roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee Josh Phillips YuriNachos robbyczgw-cla dlauer + pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 antons + austinm911 blacksmith-sh[bot] damoahdominic dan-dr dwfinkelstein HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi + mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server + Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) + Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh + svkozak VACInc wes-davis zats 24601 adam91holt ameno- Chris Taylor dguido Django Navarro + evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer + aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe + itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell + odnxe p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite T5-AndyML travisp VAC william arzt + zknicker abhaymundhara alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier arthyn Asleep123 bolismauro + conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim grrowl + gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn kentaro Kevin Lin kitze + levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn MSch + Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim + Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke Suksham-sharma + testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 + Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik hougangdev + latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 970a714d0..0fea27708 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -21,7 +21,11 @@ export async function prependSystemEvents(params: { if (!trimmed) return null; const lower = trimmed.toLowerCase(); if (lower.includes("reason periodic")) return null; - if (lower.includes("heartbeat")) return null; + // Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat" + // The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this + if (lower.startsWith("read heartbeat.md")) return null; + // Also filter heartbeat poll/wake noise + if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) return null; if (trimmed.startsWith("Node:")) { return trimmed.replace(/ · last input [^·]+/i, "").trim(); } From 5aa02cf3f75d358e5e9eb666dd8b355c4aa0437b Mon Sep 17 00:00:00 2001 From: "Robby (AI-assisted)" Date: Mon, 26 Jan 2026 21:03:41 +0000 Subject: [PATCH 19/43] fix(gateway): sanitize error responses to prevent information disclosure Replace raw error messages with generic 'Internal Server Error' to prevent leaking internal error details to unauthenticated HTTP clients. Fixes #2383 --- src/gateway/server-http.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/gateway/server-http.ts b/src/gateway/server-http.ts index 08415f346..b72939c6a 100644 --- a/src/gateway/server-http.ts +++ b/src/gateway/server-http.ts @@ -291,10 +291,10 @@ export function createGatewayHttpServer(opts: { res.statusCode = 404; res.setHeader("Content-Type", "text/plain; charset=utf-8"); res.end("Not Found"); - } catch (err) { + } catch { res.statusCode = 500; res.setHeader("Content-Type", "text/plain; charset=utf-8"); - res.end(String(err)); + res.end("Internal Server Error"); } } From af9606de3675298167d4b73488c2d07ffa24c487 Mon Sep 17 00:00:00 2001 From: "Robby (AI-assisted)" Date: Mon, 26 Jan 2026 21:06:12 +0000 Subject: [PATCH 20/43] fix(history): add LRU eviction for groupHistories to prevent memory leak Add evictOldHistoryKeys() function that removes oldest keys when the history map exceeds MAX_HISTORY_KEYS (1000). Called automatically in appendHistoryEntry() to bound memory growth. The map previously grew unbounded as users interacted with more groups over time. Growth is O(unique groups) not O(messages), but still causes slow memory accumulation on long-running instances. Fixes #2384 --- src/auto-reply/reply/history.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts index bc59b4f2e..8e1478f76 100644 --- a/src/auto-reply/reply/history.ts +++ b/src/auto-reply/reply/history.ts @@ -3,6 +3,26 @@ import { CURRENT_MESSAGE_MARKER } from "./mentions.js"; export const HISTORY_CONTEXT_MARKER = "[Chat messages since your last reply - for context]"; export const DEFAULT_GROUP_HISTORY_LIMIT = 50; +/** Maximum number of group history keys to retain (LRU eviction when exceeded). */ +export const MAX_HISTORY_KEYS = 1000; + +/** + * Evict oldest keys from a history map when it exceeds MAX_HISTORY_KEYS. + * Uses Map's insertion order for LRU-like behavior. + */ +export function evictOldHistoryKeys( + historyMap: Map, + maxKeys: number = MAX_HISTORY_KEYS, +): void { + if (historyMap.size <= maxKeys) return; + const keysToDelete = historyMap.size - maxKeys; + const iterator = historyMap.keys(); + for (let i = 0; i < keysToDelete; i++) { + const key = iterator.next().value; + if (key !== undefined) historyMap.delete(key); + } +} + export type HistoryEntry = { sender: string; body: string; @@ -35,6 +55,8 @@ export function appendHistoryEntry(params: { history.push(entry); while (history.length > params.limit) history.shift(); historyMap.set(historyKey, history); + // Evict oldest keys if map exceeds max size to prevent unbounded memory growth + evictOldHistoryKeys(historyMap); return history; } From 5c35b62a5c41a8de58a90183fc74185b4a307e2a Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 15:23:51 -0600 Subject: [PATCH 21/43] fix: refresh history key order for LRU eviction --- src/auto-reply/reply/history.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts index 8e1478f76..45ad44d5a 100644 --- a/src/auto-reply/reply/history.ts +++ b/src/auto-reply/reply/history.ts @@ -54,6 +54,10 @@ export function appendHistoryEntry(params: { const history = historyMap.get(historyKey) ?? []; history.push(entry); while (history.length > params.limit) history.shift(); + if (historyMap.has(historyKey)) { + // Refresh insertion order so eviction keeps recently used histories. + historyMap.delete(historyKey); + } historyMap.set(historyKey, history); // Evict oldest keys if map exceeds max size to prevent unbounded memory growth evictOldHistoryKeys(historyMap); From 343882d45c6b13c12d2661698005deadb9e191de Mon Sep 17 00:00:00 2001 From: vignesh07 Date: Mon, 26 Jan 2026 15:26:15 -0800 Subject: [PATCH 22/43] feat(telegram): add edit message action (#2394) (thanks @marcelomar21) --- CHANGELOG.md | 1 + src/agents/tools/telegram-actions.ts | 46 +++++++++ src/channels/plugins/actions/telegram.test.ts | 49 ++++++++++ src/channels/plugins/actions/telegram.ts | 31 ++++++- src/config/types.telegram.ts | 1 + src/infra/heartbeat-visibility.ts | 2 +- src/security/audit.test.ts | 2 + src/security/audit.ts | 9 +- src/telegram/send.edit-message.test.ts | 91 ++++++++++++++++++ src/telegram/send.ts | 93 +++++++++++++++++++ 10 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 src/telegram/send.edit-message.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d8725030..e57c7b4b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,7 @@ Status: unreleased. - TUI: avoid width overflow when rendering selection lists. (#1686) Thanks @mossein. - Telegram: keep topic IDs in restart sentinel notifications. (#1807) Thanks @hsrvc. - Telegram: add optional silent send flag (disable notifications). (#2382) Thanks @Suksham-sharma. +- Telegram: support editing sent messages via message(action="edit"). (#2394) Thanks @marcelomar21. - Config: apply config.env before ${VAR} substitution. (#1813) Thanks @spanishflu-est1918. - Slack: clear ack reaction after streamed replies. (#2044) Thanks @fancyboi999. - macOS: keep custom SSH usernames in remote target. (#2046) Thanks @algal. diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts index c167ac32a..891ab2b45 100644 --- a/src/agents/tools/telegram-actions.ts +++ b/src/agents/tools/telegram-actions.ts @@ -3,6 +3,7 @@ import type { ClawdbotConfig } from "../../config/config.js"; import { resolveTelegramReactionLevel } from "../../telegram/reaction-level.js"; import { deleteMessageTelegram, + editMessageTelegram, reactMessageTelegram, sendMessageTelegram, } from "../../telegram/send.js"; @@ -209,5 +210,50 @@ export async function handleTelegramAction( return jsonResult({ ok: true, deleted: true }); } + if (action === "editMessage") { + if (!isActionEnabled("editMessage")) { + throw new Error("Telegram editMessage is disabled."); + } + const chatId = readStringOrNumberParam(params, "chatId", { + required: true, + }); + const messageId = readNumberParam(params, "messageId", { + required: true, + integer: true, + }); + const content = readStringParam(params, "content", { + required: true, + allowEmpty: false, + }); + const buttons = readTelegramButtons(params); + if (buttons) { + const inlineButtonsScope = resolveTelegramInlineButtonsScope({ + cfg, + accountId: accountId ?? undefined, + }); + if (inlineButtonsScope === "off") { + throw new Error( + 'Telegram inline buttons are disabled. Set channels.telegram.capabilities.inlineButtons to "dm", "group", "all", or "allowlist".', + ); + } + } + const token = resolveTelegramToken(cfg, { accountId }).token; + if (!token) { + throw new Error( + "Telegram bot token missing. Set TELEGRAM_BOT_TOKEN or channels.telegram.botToken.", + ); + } + const result = await editMessageTelegram(chatId ?? "", messageId ?? 0, content, { + token, + accountId: accountId ?? undefined, + buttons, + }); + return jsonResult({ + ok: true, + messageId: result.messageId, + chatId: result.chatId, + }); + } + throw new Error(`Unsupported Telegram action: ${action}`); } diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts index 6b79bf5ba..b2673134d 100644 --- a/src/channels/plugins/actions/telegram.test.ts +++ b/src/channels/plugins/actions/telegram.test.ts @@ -62,4 +62,53 @@ describe("telegramMessageActions", () => { cfg, ); }); + + it("maps edit action params into editMessage", async () => { + handleTelegramAction.mockClear(); + const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig; + + await telegramMessageActions.handleAction({ + action: "edit", + params: { + chatId: "123", + messageId: 42, + message: "Updated", + buttons: [], + }, + cfg, + accountId: undefined, + }); + + expect(handleTelegramAction).toHaveBeenCalledWith( + { + action: "editMessage", + chatId: "123", + messageId: 42, + content: "Updated", + buttons: [], + accountId: undefined, + }, + cfg, + ); + }); + + it("rejects non-integer messageId for edit before reaching telegram-actions", async () => { + handleTelegramAction.mockClear(); + const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig; + + await expect( + telegramMessageActions.handleAction({ + action: "edit", + params: { + chatId: "123", + messageId: "nope", + message: "Updated", + }, + cfg, + accountId: undefined, + }), + ).rejects.toThrow(); + + expect(handleTelegramAction).not.toHaveBeenCalled(); + }); }); diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts index e281772bd..364707e0a 100644 --- a/src/channels/plugins/actions/telegram.ts +++ b/src/channels/plugins/actions/telegram.ts @@ -1,5 +1,6 @@ import { createActionGate, + readNumberParam, readStringOrNumberParam, readStringParam, } from "../../../agents/tools/common.js"; @@ -43,6 +44,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { const actions = new Set(["send"]); if (gate("reactions")) actions.add("react"); if (gate("deleteMessage")) actions.add("delete"); + if (gate("editMessage")) actions.add("edit"); return Array.from(actions); }, supportsButtons: ({ cfg }) => { @@ -100,14 +102,39 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { readStringOrNumberParam(params, "chatId") ?? readStringOrNumberParam(params, "channelId") ?? readStringParam(params, "to", { required: true }); - const messageId = readStringParam(params, "messageId", { + const messageId = readNumberParam(params, "messageId", { required: true, + integer: true, }); return await handleTelegramAction( { action: "deleteMessage", chatId, - messageId: Number(messageId), + messageId, + accountId: accountId ?? undefined, + }, + cfg, + ); + } + + if (action === "edit") { + const chatId = + readStringOrNumberParam(params, "chatId") ?? + readStringOrNumberParam(params, "channelId") ?? + readStringParam(params, "to", { required: true }); + const messageId = readNumberParam(params, "messageId", { + required: true, + integer: true, + }); + const message = readStringParam(params, "message", { required: true, allowEmpty: false }); + const buttons = params.buttons; + return await handleTelegramAction( + { + action: "editMessage", + chatId, + messageId, + content: message, + buttons, accountId: accountId ?? undefined, }, cfg, diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 5d0b80e25..f6a7c3db8 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -15,6 +15,7 @@ export type TelegramActionConfig = { reactions?: boolean; sendMessage?: boolean; deleteMessage?: boolean; + editMessage?: boolean; }; export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist"; diff --git a/src/infra/heartbeat-visibility.ts b/src/infra/heartbeat-visibility.ts index e4943464c..c24b10417 100644 --- a/src/infra/heartbeat-visibility.ts +++ b/src/infra/heartbeat-visibility.ts @@ -1,6 +1,6 @@ import type { ClawdbotConfig } from "../config/config.js"; import type { ChannelHeartbeatVisibilityConfig } from "../config/types.channels.js"; -import type { DeliverableMessageChannel, GatewayMessageChannel } from "../utils/message-channel.js"; +import type { GatewayMessageChannel } from "../utils/message-channel.js"; export type ResolvedHeartbeatVisibility = { showOk: boolean; diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index 1006934d3..deebf7c70 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -44,6 +44,7 @@ describe("security audit", () => { const res = await runSecurityAudit({ config: cfg, + env: {}, includeFilesystem: false, includeChannelSecurity: false, }); @@ -88,6 +89,7 @@ describe("security audit", () => { const res = await runSecurityAudit({ config: cfg, + env: {}, includeFilesystem: false, includeChannelSecurity: false, }); diff --git a/src/security/audit.ts b/src/security/audit.ts index 2169f197d..6cac2c37c 100644 --- a/src/security/audit.ts +++ b/src/security/audit.ts @@ -247,12 +247,15 @@ async function collectFilesystemFindings(params: { return findings; } -function collectGatewayConfigFindings(cfg: ClawdbotConfig): SecurityAuditFinding[] { +function collectGatewayConfigFindings( + cfg: ClawdbotConfig, + env: NodeJS.ProcessEnv, +): SecurityAuditFinding[] { const findings: SecurityAuditFinding[] = []; const bind = typeof cfg.gateway?.bind === "string" ? cfg.gateway.bind : "loopback"; const tailscaleMode = cfg.gateway?.tailscale?.mode ?? "off"; - const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode }); + const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, tailscaleMode, env }); const controlUiEnabled = cfg.gateway?.controlUi?.enabled !== false; const trustedProxies = Array.isArray(cfg.gateway?.trustedProxies) ? cfg.gateway.trustedProxies @@ -905,7 +908,7 @@ export async function runSecurityAudit(opts: SecurityAuditOptions): Promise ({ + botApi: { + editMessageText: vi.fn(), + }, + botCtorSpy: vi.fn(), +})); + +vi.mock("grammy", () => ({ + Bot: class { + api = botApi; + constructor(public token: string) { + botCtorSpy(token); + } + }, + InputFile: class {}, +})); + +import { editMessageTelegram } from "./send.js"; + +describe("editMessageTelegram", () => { + beforeEach(() => { + botApi.editMessageText.mockReset(); + botCtorSpy.mockReset(); + }); + + it("keeps existing buttons when buttons is undefined (no reply_markup)", async () => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, "hi", { + token: "tok", + cfg: {}, + }); + + expect(botCtorSpy).toHaveBeenCalledWith("tok"); + expect(botApi.editMessageText).toHaveBeenCalledTimes(1); + const call = botApi.editMessageText.mock.calls[0] ?? []; + const params = call[3] as Record; + expect(params).toEqual(expect.objectContaining({ parse_mode: "HTML" })); + expect(params).not.toHaveProperty("reply_markup"); + }); + + it("removes buttons when buttons is empty (reply_markup.inline_keyboard = [])", async () => { + botApi.editMessageText.mockResolvedValue({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, "hi", { + token: "tok", + cfg: {}, + buttons: [], + }); + + expect(botApi.editMessageText).toHaveBeenCalledTimes(1); + const params = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; + expect(params).toEqual( + expect.objectContaining({ + parse_mode: "HTML", + reply_markup: { inline_keyboard: [] }, + }), + ); + }); + + it("falls back to plain text when Telegram HTML parse fails (and preserves reply_markup)", async () => { + botApi.editMessageText + .mockRejectedValueOnce(new Error("400: Bad Request: can't parse entities")) + .mockResolvedValueOnce({ message_id: 1, chat: { id: "123" } }); + + await editMessageTelegram("123", 1, " html", { + token: "tok", + cfg: {}, + buttons: [], + }); + + expect(botApi.editMessageText).toHaveBeenCalledTimes(2); + + const firstParams = (botApi.editMessageText.mock.calls[0] ?? [])[3] as Record; + expect(firstParams).toEqual( + expect.objectContaining({ + parse_mode: "HTML", + reply_markup: { inline_keyboard: [] }, + }), + ); + + const secondParams = (botApi.editMessageText.mock.calls[1] ?? [])[3] as Record; + expect(secondParams).toEqual( + expect.objectContaining({ + reply_markup: { inline_keyboard: [] }, + }), + ); + }); +}); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index f9557bf1e..43a3a5e8c 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -495,6 +495,99 @@ export async function deleteMessageTelegram( return { ok: true }; } +type TelegramEditOpts = { + token?: string; + accountId?: string; + verbose?: boolean; + api?: Bot["api"]; + retry?: RetryConfig; + textMode?: "markdown" | "html"; + /** Inline keyboard buttons (reply markup). Pass empty array to remove buttons. */ + buttons?: Array>; + /** Optional config injection to avoid global loadConfig() (improves testability). */ + cfg?: ReturnType; +}; + +export async function editMessageTelegram( + chatIdInput: string | number, + messageIdInput: string | number, + text: string, + opts: TelegramEditOpts = {}, +): Promise<{ ok: true; messageId: string; chatId: string }> { + const cfg = opts.cfg ?? loadConfig(); + const account = resolveTelegramAccount({ + cfg, + accountId: opts.accountId, + }); + const token = resolveToken(opts.token, account); + const chatId = normalizeChatId(String(chatIdInput)); + const messageId = normalizeMessageId(messageIdInput); + const client = resolveTelegramClientOptions(account); + const api = opts.api ?? new Bot(token, client ? { client } : undefined).api; + const request = createTelegramRetryRunner({ + retry: opts.retry, + configRetry: account.config.retry, + verbose: opts.verbose, + }); + const logHttpError = createTelegramHttpLogger(cfg); + const requestWithDiag = (fn: () => Promise, label?: string) => + request(fn, label).catch((err) => { + logHttpError(label ?? "request", err); + throw err; + }); + + const textMode = opts.textMode ?? "markdown"; + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: account.accountId, + }); + const htmlText = renderTelegramHtmlText(text, { textMode, tableMode }); + + // Reply markup semantics: + // - buttons === undefined → don't send reply_markup (keep existing) + // - buttons is [] (or filters to empty) → send { inline_keyboard: [] } (remove) + // - otherwise → send built inline keyboard + const shouldTouchButtons = opts.buttons !== undefined; + const builtKeyboard = shouldTouchButtons ? buildInlineKeyboard(opts.buttons) : undefined; + const replyMarkup = shouldTouchButtons ? (builtKeyboard ?? { inline_keyboard: [] }) : undefined; + + const editParams: Record = { + parse_mode: "HTML", + }; + if (replyMarkup !== undefined) { + editParams.reply_markup = replyMarkup; + } + + await requestWithDiag( + () => api.editMessageText(chatId, messageId, htmlText, editParams), + "editMessage", + ).catch(async (err) => { + // Telegram rejects malformed HTML. Fall back to plain text. + const errText = formatErrorMessage(err); + if (PARSE_ERR_RE.test(errText)) { + if (opts.verbose) { + console.warn(`telegram HTML parse failed, retrying as plain text: ${errText}`); + } + const plainParams: Record = {}; + if (replyMarkup !== undefined) { + plainParams.reply_markup = replyMarkup; + } + return await requestWithDiag( + () => + Object.keys(plainParams).length > 0 + ? api.editMessageText(chatId, messageId, text, plainParams) + : api.editMessageText(chatId, messageId, text), + "editMessage-plain", + ); + } + throw err; + }); + + logVerbose(`[telegram] Edited message ${messageId} in chat ${chatId}`); + return { ok: true, messageId: String(messageId), chatId }; +} + function inferFilename(kind: ReturnType) { switch (kind) { case "image": From a8ad242f885de2225455ae00f8564c83c7c4b162 Mon Sep 17 00:00:00 2001 From: Dominic <43616264+dominicnunez@users.noreply.github.com> Date: Mon, 26 Jan 2026 18:27:53 -0600 Subject: [PATCH 23/43] fix(security): properly test Windows ACL audit for config includes (#2403) * fix(security): properly test Windows ACL audit for config includes The test expected fs.config_include.perms_writable on Windows but chmod 0o644 has no effect on Windows ACLs. Use icacls to grant Everyone write access, which properly triggers the security check. Also stubs execIcacls to return proper ACL output so the audit can parse permissions without running actual icacls on the system. Adds cleanup via try/finally to remove temp directory containing world-writable test file. Fixes checks-windows CI failure. * test: isolate heartbeat runner tests from user workspace * docs: update changelog for #2403 --------- Co-authored-by: Tyler Yust --- CHANGELOG.md | 1 + ...tbeat-runner.returns-default-unset.test.ts | 7 +- src/security/audit.test.ts | 79 +++++++++++-------- 3 files changed, 52 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e57c7b4b1..4667e60d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Status: unreleased. ### Fixes - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. +- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez. - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. - Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. diff --git a/src/infra/heartbeat-runner.returns-default-unset.test.ts b/src/infra/heartbeat-runner.returns-default-unset.test.ts index 621f895fa..595cbaed7 100644 --- a/src/infra/heartbeat-runner.returns-default-unset.test.ts +++ b/src/infra/heartbeat-runner.returns-default-unset.test.ts @@ -333,6 +333,7 @@ describe("runHeartbeatOnce", () => { const cfg: ClawdbotConfig = { agents: { defaults: { + workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp" }, }, }, @@ -461,6 +462,7 @@ describe("runHeartbeatOnce", () => { const cfg: ClawdbotConfig = { agents: { defaults: { + workspace: tmpDir, heartbeat: { every: "5m", target: "last", @@ -542,6 +544,7 @@ describe("runHeartbeatOnce", () => { const cfg: ClawdbotConfig = { agents: { defaults: { + workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp" }, }, }, @@ -597,6 +600,7 @@ describe("runHeartbeatOnce", () => { const cfg: ClawdbotConfig = { agents: { defaults: { + workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp", @@ -668,6 +672,7 @@ describe("runHeartbeatOnce", () => { const cfg: ClawdbotConfig = { agents: { defaults: { + workspace: tmpDir, heartbeat: { every: "5m", target: "whatsapp", @@ -737,7 +742,7 @@ describe("runHeartbeatOnce", () => { try { const cfg: ClawdbotConfig = { agents: { - defaults: { heartbeat: { every: "5m" } }, + defaults: { workspace: tmpDir, heartbeat: { every: "5m" } }, list: [{ id: "work", default: true }], }, channels: { whatsapp: { allowFrom: ["*"] } }, diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts index deebf7c70..3a43ff4cc 100644 --- a/src/security/audit.test.ts +++ b/src/security/audit.test.ts @@ -857,51 +857,62 @@ describe("security audit", () => { const includePath = path.join(stateDir, "extra.json5"); await fs.writeFile(includePath, "{ logging: { redactSensitive: 'off' } }\n", "utf-8"); - await fs.chmod(includePath, 0o644); + if (isWindows) { + // Grant "Everyone" write access to trigger the perms_writable check on Windows + const { execSync } = await import("node:child_process"); + execSync(`icacls "${includePath}" /grant Everyone:W`, { stdio: "ignore" }); + } else { + await fs.chmod(includePath, 0o644); + } const configPath = path.join(stateDir, "clawdbot.json"); await fs.writeFile(configPath, `{ "$include": "./extra.json5" }\n`, "utf-8"); await fs.chmod(configPath, 0o600); - const cfg: ClawdbotConfig = { logging: { redactSensitive: "off" } }; - const user = "DESKTOP-TEST\\Tester"; - const execIcacls = isWindows - ? async (_cmd: string, args: string[]) => { - const target = args[0]; - if (target === includePath) { + try { + const cfg: ClawdbotConfig = { logging: { redactSensitive: "off" } }; + const user = "DESKTOP-TEST\\Tester"; + const execIcacls = isWindows + ? async (_cmd: string, args: string[]) => { + const target = args[0]; + if (target === includePath) { + return { + stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(W)\n ${user}:(F)\n`, + stderr: "", + }; + } return { - stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(W)\n ${user}:(F)\n`, + stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`, stderr: "", }; } - return { - stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n ${user}:(F)\n`, - stderr: "", - }; - } - : undefined; - const res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - platform: isWindows ? "win32" : undefined, - env: isWindows - ? { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" } - : undefined, - execIcacls, - }); + : undefined; + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + platform: isWindows ? "win32" : undefined, + env: isWindows + ? { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" } + : undefined, + execIcacls, + }); - const expectedCheckId = isWindows - ? "fs.config_include.perms_writable" - : "fs.config_include.perms_world_readable"; + const expectedCheckId = isWindows + ? "fs.config_include.perms_writable" + : "fs.config_include.perms_world_readable"; - expect(res.findings).toEqual( - expect.arrayContaining([ - expect.objectContaining({ checkId: expectedCheckId, severity: "critical" }), - ]), - ); + expect(res.findings).toEqual( + expect.arrayContaining([ + expect.objectContaining({ checkId: expectedCheckId, severity: "critical" }), + ]), + ); + } finally { + // Clean up temp directory with world-writable file + await fs.rm(tmp, { recursive: true, force: true }); + } }); it("flags extensions without plugins.allow", async () => { From e43f4c0628228bd5ae3a9fe8774881f7c7753f65 Mon Sep 17 00:00:00 2001 From: techboss Date: Mon, 26 Jan 2026 15:25:27 -0700 Subject: [PATCH 24/43] fix(telegram): handle network errors gracefully - Add bot.catch() to prevent unhandled rejections from middleware - Add isRecoverableNetworkError() to retry on transient failures - Add maxRetryTime and exponential backoff to grammY runner - Global unhandled rejection handler now logs recoverable errors instead of crashing (fetch failures, timeouts, connection resets) Fixes crash loop when Telegram API is temporarily unreachable. --- src/infra/unhandled-rejections.ts | 37 +++++++++++++++++++++++++++++++ src/telegram/bot.ts | 6 +++++ src/telegram/fetch.ts | 12 ++++++++++ src/telegram/monitor.ts | 31 ++++++++++++++++++++++++-- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index c444baaa2..c45923c4b 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -13,6 +13,36 @@ export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHan }; } +/** + * Check if an error is a recoverable/transient error that shouldn't crash the process. + * These include network errors and abort signals during shutdown. + */ +function isRecoverableError(reason: unknown): boolean { + if (!reason) return false; + + // Check error name for AbortError + if (reason instanceof Error && reason.name === "AbortError") { + return true; + } + + const message = reason instanceof Error ? reason.message : String(reason); + const lowerMessage = message.toLowerCase(); + return ( + lowerMessage.includes("fetch failed") || + lowerMessage.includes("network request") || + lowerMessage.includes("econnrefused") || + lowerMessage.includes("econnreset") || + lowerMessage.includes("etimedout") || + lowerMessage.includes("socket hang up") || + lowerMessage.includes("enotfound") || + lowerMessage.includes("network error") || + lowerMessage.includes("getaddrinfo") || + lowerMessage.includes("client network socket disconnected") || + lowerMessage.includes("this operation was aborted") || + lowerMessage.includes("aborted") + ); +} + export function isUnhandledRejectionHandled(reason: unknown): boolean { for (const handler of handlers) { try { @@ -30,6 +60,13 @@ export function isUnhandledRejectionHandled(reason: unknown): boolean { export function installUnhandledRejectionHandler(): void { process.on("unhandledRejection", (reason, _promise) => { if (isUnhandledRejectionHandled(reason)) return; + + // Don't crash on recoverable/transient errors - log them and continue + if (isRecoverableError(reason)) { + console.error("[clawdbot] Recoverable error (not crashing):", formatUncaughtError(reason)); + return; + } + console.error("[clawdbot] Unhandled promise rejection:", formatUncaughtError(reason)); process.exit(1); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index d958d5616..d1996bade 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -138,6 +138,12 @@ export function createTelegramBot(opts: TelegramBotOptions) { bot.api.config.use(apiThrottler()); bot.use(sequentialize(getTelegramSequentialKey)); + // Catch all errors from bot middleware to prevent unhandled rejections + bot.catch((err) => { + const message = err instanceof Error ? err.message : String(err); + runtime.error?.(danger(`telegram bot error: ${message}`)); + }); + const recentUpdates = createTelegramUpdateDedupe(); let lastUpdateId = typeof opts.updateOffset?.lastUpdateId === "number" ? opts.updateOffset.lastUpdateId : null; diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index 7fdaef301..00a21be9b 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,5 +1,17 @@ +import { setDefaultAutoSelectFamily } from "net"; import { resolveFetch } from "../infra/fetch.js"; +// Workaround for Node.js 22 "Happy Eyeballs" (autoSelectFamily) bug +// that causes intermittent ETIMEDOUT errors when connecting to Telegram's +// dual-stack servers. Disabling autoSelectFamily forces sequential IPv4/IPv6 +// attempts which works reliably. +// See: https://github.com/nodejs/node/issues/54359 +try { + setDefaultAutoSelectFamily(false); +} catch { + // Ignore if not available (older Node versions) +} + // Prefer wrapped fetch when available to normalize AbortSignal across runtimes. export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined { if (proxyFetch) return resolveFetch(proxyFetch); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index 24c8743df..aeb5aae7c 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -40,6 +40,10 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions { return haystack.includes("getupdates"); }; +const isRecoverableNetworkError = (err: unknown): boolean => { + if (!err) return false; + const message = err instanceof Error ? err.message : String(err); + const lowerMessage = message.toLowerCase(); + // Recoverable network errors that should trigger retry, not crash + return ( + lowerMessage.includes("fetch failed") || + lowerMessage.includes("network request") || + lowerMessage.includes("econnrefused") || + lowerMessage.includes("econnreset") || + lowerMessage.includes("etimedout") || + lowerMessage.includes("socket hang up") || + lowerMessage.includes("enotfound") || + lowerMessage.includes("abort") + ); +}; + export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveTelegramAccount({ @@ -152,12 +173,18 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { if (opts.abortSignal?.aborted) { throw err; } - if (!isGetUpdatesConflict(err)) { + const isConflict = isGetUpdatesConflict(err); + const isNetworkError = isRecoverableNetworkError(err); + if (!isConflict && !isNetworkError) { throw err; } restartAttempts += 1; const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts); - log(`Telegram getUpdates conflict; retrying in ${formatDurationMs(delayMs)}.`); + const reason = isConflict ? "getUpdates conflict" : "network error"; + const errMsg = err instanceof Error ? err.message : String(err); + (opts.runtime?.error ?? console.error)( + `Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`, + ); try { await sleepWithAbort(delayMs, opts.abortSignal); } catch (sleepErr) { From b861a0bd73e6c8dbf7c62a2cb99b400f40e2e4ea Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 26 Jan 2026 19:24:13 -0500 Subject: [PATCH 25/43] Telegram: harden network retries and config Co-authored-by: techboss --- CHANGELOG.md | 1 + README.md | 29 ++--- docs/channels/telegram.md | 1 + docs/gateway/configuration.md | 3 + src/config/schema.ts | 3 + src/config/types.telegram.ts | 7 ++ src/config/zod-schema.providers-core.ts | 6 + src/infra/retry-policy.test.ts | 27 +++++ src/infra/retry-policy.ts | 7 +- ...patterns-match-without-botusername.test.ts | 1 + ...topic-skill-filters-system-prompts.test.ts | 1 + ...-all-group-messages-grouppolicy-is.test.ts | 1 + ...e-callback-query-updates-by-update.test.ts | 1 + ...gram-bot.installs-grammy-throttler.test.ts | 1 + ...lowfrom-entries-case-insensitively.test.ts | 1 + ...-case-insensitively-grouppolicy-is.test.ts | 1 + ...-dms-by-telegram-accountid-binding.test.ts | 1 + ...ies-without-native-reply-threading.test.ts | 1 + ...s-media-file-path-no-file-download.test.ts | 1 + ...udes-location-text-ctx-fields-pins.test.ts | 1 + src/telegram/bot.test.ts | 1 + src/telegram/bot.ts | 8 +- src/telegram/fetch.test.ts | 43 ++++++- src/telegram/fetch.ts | 37 ++++-- src/telegram/monitor.test.ts | 46 ++++++- src/telegram/monitor.ts | 29 +---- src/telegram/network-config.test.ts | 48 ++++++++ src/telegram/network-config.ts | 39 ++++++ src/telegram/network-errors.test.ts | 31 +++++ src/telegram/network-errors.ts | 112 ++++++++++++++++++ src/telegram/send.caption-split.test.ts | 1 + ...-thread-params-plain-text-fallback.test.ts | 1 + src/telegram/send.proxy.test.ts | 7 +- ...send.returns-undefined-empty-input.test.ts | 1 + src/telegram/send.ts | 8 +- src/telegram/webhook-set.ts | 11 +- 36 files changed, 457 insertions(+), 61 deletions(-) create mode 100644 src/infra/retry-policy.test.ts create mode 100644 src/telegram/network-config.test.ts create mode 100644 src/telegram/network-config.ts create mode 100644 src/telegram/network-errors.test.ts create mode 100644 src/telegram/network-errors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4667e60d5..a83519fef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Status: unreleased. - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. - Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. +- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. diff --git a/README.md b/README.md index 3f8853b93..e72fe7e16 100644 --- a/README.md +++ b/README.md @@ -485,12 +485,12 @@ Thanks to all clawtributors: julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 - danielz1z emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams - sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby - buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase uos-status gerardward2007 - roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee Josh Phillips YuriNachos robbyczgw-cla dlauer - pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 antons - austinm911 blacksmith-sh[bot] damoahdominic dan-dr dwfinkelstein HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi + danielz1z AdeboyeDN emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 + CashWilliams sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc + travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase uos-status + gerardward2007 roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee robbyczgw-cla dlauer Josh Phillips + YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 + antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh @@ -500,12 +500,13 @@ Thanks to all clawtributors: itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker abhaymundhara alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier arthyn Asleep123 bolismauro - conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim grrowl - gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn kentaro Kevin Lin kitze - levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn MSch - Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim - Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke Suksham-sharma - testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 - Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik hougangdev - latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock + Clawdbot Maintainers conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim + grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn kentaro Kevin Lin + kitze levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn + MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe + Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke + Suksham-sharma testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai + ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik + hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani + William Stock

diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md index e708e2e64..39f3a2ec3 100644 --- a/docs/channels/telegram.md +++ b/docs/channels/telegram.md @@ -529,6 +529,7 @@ Provider options: - `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). +- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts. - `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP). - `channels.telegram.webhookUrl`: enable webhook mode. - `channels.telegram.webhookSecret`: webhook secret (optional). diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index 31dd1602b..9c850e070 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1029,6 +1029,9 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w maxDelayMs: 30000, jitter: 0.1 }, + network: { // transport overrides + autoSelectFamily: false + }, proxy: "socks5://localhost:9050", webhookUrl: "https://example.com/telegram-webhook", webhookSecret: "secret", diff --git a/src/config/schema.ts b/src/config/schema.ts index 9627d64f3..3261b5170 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -310,6 +310,7 @@ const FIELD_LABELS: Record = { "channels.telegram.retry.minDelayMs": "Telegram Retry Min Delay (ms)", "channels.telegram.retry.maxDelayMs": "Telegram Retry Max Delay (ms)", "channels.telegram.retry.jitter": "Telegram Retry Jitter", + "channels.telegram.network.autoSelectFamily": "Telegram autoSelectFamily", "channels.telegram.timeoutSeconds": "Telegram API Timeout (seconds)", "channels.telegram.capabilities.inlineButtons": "Telegram Inline Buttons", "channels.whatsapp.dmPolicy": "WhatsApp DM Policy", @@ -643,6 +644,8 @@ const FIELD_HELP: Record = { "channels.telegram.retry.maxDelayMs": "Maximum retry delay cap in ms for Telegram outbound calls.", "channels.telegram.retry.jitter": "Jitter factor (0-1) applied to Telegram retry delays.", + "channels.telegram.network.autoSelectFamily": + "Override Node autoSelectFamily for Telegram (true=enable, false=disable).", "channels.telegram.timeoutSeconds": "Max seconds before Telegram API requests are aborted (default: 500 per grammY).", "channels.whatsapp.dmPolicy": diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index f6a7c3db8..fa9e2890a 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -18,6 +18,11 @@ export type TelegramActionConfig = { editMessage?: boolean; }; +export type TelegramNetworkConfig = { + /** Override Node's autoSelectFamily behavior (true = enable, false = disable). */ + autoSelectFamily?: boolean; +}; + export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist"; export type TelegramCapabilitiesConfig = @@ -96,6 +101,8 @@ export type TelegramAccountConfig = { timeoutSeconds?: number; /** Retry policy for outbound Telegram API calls. */ retry?: OutboundRetryConfig; + /** Network transport overrides for Telegram. */ + network?: TelegramNetworkConfig; proxy?: string; webhookUrl?: string; webhookSecret?: string; diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 374e6e8aa..26e279faf 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -110,6 +110,12 @@ export const TelegramAccountSchemaBase = z mediaMaxMb: z.number().positive().optional(), timeoutSeconds: z.number().int().positive().optional(), retry: RetryConfigSchema, + network: z + .object({ + autoSelectFamily: z.boolean().optional(), + }) + .strict() + .optional(), proxy: z.string().optional(), webhookUrl: z.string().optional(), webhookSecret: z.string().optional(), diff --git a/src/infra/retry-policy.test.ts b/src/infra/retry-policy.test.ts new file mode 100644 index 000000000..02aedb087 --- /dev/null +++ b/src/infra/retry-policy.test.ts @@ -0,0 +1,27 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { createTelegramRetryRunner } from "./retry-policy.js"; + +describe("createTelegramRetryRunner", () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it("retries when custom shouldRetry matches non-telegram error", async () => { + vi.useFakeTimers(); + const runner = createTelegramRetryRunner({ + retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 }, + shouldRetry: (err) => err instanceof Error && err.message === "boom", + }); + const fn = vi + .fn<[], Promise>() + .mockRejectedValueOnce(new Error("boom")) + .mockResolvedValue("ok"); + + const promise = runner(fn, "request"); + await vi.runAllTimersAsync(); + + await expect(promise).resolves.toBe("ok"); + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/infra/retry-policy.ts b/src/infra/retry-policy.ts index f5a3c4b33..6d647aa5e 100644 --- a/src/infra/retry-policy.ts +++ b/src/infra/retry-policy.ts @@ -72,16 +72,21 @@ export function createTelegramRetryRunner(params: { retry?: RetryConfig; configRetry?: RetryConfig; verbose?: boolean; + shouldRetry?: (err: unknown) => boolean; }): RetryRunner { const retryConfig = resolveRetryConfig(TELEGRAM_RETRY_DEFAULTS, { ...params.configRetry, ...params.retry, }); + const shouldRetry = params.shouldRetry + ? (err: unknown) => params.shouldRetry?.(err) || TELEGRAM_RETRY_RE.test(formatErrorMessage(err)) + : (err: unknown) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err)); + return (fn: () => Promise, label?: string) => retryAsync(fn, { ...retryConfig, label, - shouldRetry: (err) => TELEGRAM_RETRY_RE.test(formatErrorMessage(err)), + shouldRetry, retryAfterMs: getTelegramRetryAfterMs, onRetry: params.verbose ? (info) => { diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 66e60ecca..0b2f9c9af 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -89,6 +89,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts index 1a7a9d40c..b5d154c42 100644 --- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts +++ b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts index 0aa431d1b..d6c22256b 100644 --- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts index 8ed8e189f..6e04be767 100644 --- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts +++ b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index c30b5e33a..4c7a93529 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -90,6 +90,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts index 805aa34da..4ddb83c02 100644 --- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts index ec81283bb..ba3d802e2 100644 --- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts index 63ddd9bec..514ff1452 100644 --- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts +++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts @@ -88,6 +88,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts index dffe8ee88..1aff63ed3 100644 --- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts +++ b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts @@ -93,6 +93,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts index 2ea914874..b6c1ca419 100644 --- a/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts +++ b/src/telegram/bot.media.downloads-media-file-path-no-file-download.test.ts @@ -32,6 +32,7 @@ vi.mock("grammy", () => ({ on = onSpy; command = vi.fn(); stop = stopSpy; + catch = vi.fn(); constructor(public token: string) {} }, InputFile: class {}, diff --git a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts index 2242941ce..f5ac0a268 100644 --- a/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts +++ b/src/telegram/bot.media.includes-location-text-ctx-fields-pins.test.ts @@ -30,6 +30,7 @@ vi.mock("grammy", () => ({ on = onSpy; command = vi.fn(); stop = stopSpy; + catch = vi.fn(); constructor(public token: string) {} }, InputFile: class {}, diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 8dc52ab57..274f7c6a9 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -126,6 +126,7 @@ vi.mock("grammy", () => ({ on = onSpy; stop = stopSpy; command = commandSpy; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index d1996bade..6705d359f 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -21,6 +21,7 @@ import { import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { formatUncaughtError } from "../infra/errors.js"; import { enqueueSystemEvent } from "../infra/system-events.js"; import { getChildLogger } from "../logging.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; @@ -118,7 +119,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { }); const telegramCfg = account.config; - const fetchImpl = resolveTelegramFetch(opts.proxyFetch); + const fetchImpl = resolveTelegramFetch(opts.proxyFetch, { + network: telegramCfg.network, + }); const shouldProvideFetch = Boolean(fetchImpl); const timeoutSeconds = typeof telegramCfg?.timeoutSeconds === "number" && Number.isFinite(telegramCfg.timeoutSeconds) @@ -137,6 +140,9 @@ export function createTelegramBot(opts: TelegramBotOptions) { const bot = new Bot(opts.token, client ? { client } : undefined); bot.api.config.use(apiThrottler()); bot.use(sequentialize(getTelegramSequentialKey)); + bot.catch((err) => { + runtime.error?.(danger(`telegram bot error: ${formatUncaughtError(err)}`)); + }); // Catch all errors from bot middleware to prevent unhandled rejections bot.catch((err) => { diff --git a/src/telegram/fetch.test.ts b/src/telegram/fetch.test.ts index 4042be60d..17cda1d00 100644 --- a/src/telegram/fetch.test.ts +++ b/src/telegram/fetch.test.ts @@ -1,11 +1,21 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveTelegramFetch } from "./fetch.js"; - describe("resolveTelegramFetch", () => { const originalFetch = globalThis.fetch; + const loadModule = async () => { + const setDefaultAutoSelectFamily = vi.fn(); + vi.resetModules(); + vi.doMock("node:net", () => ({ + setDefaultAutoSelectFamily, + })); + const mod = await import("./fetch.js"); + return { resolveTelegramFetch: mod.resolveTelegramFetch, setDefaultAutoSelectFamily }; + }; + afterEach(() => { + vi.unstubAllEnvs(); + vi.clearAllMocks(); if (originalFetch) { globalThis.fetch = originalFetch; } else { @@ -13,16 +23,41 @@ describe("resolveTelegramFetch", () => { } }); - it("returns wrapped global fetch when available", () => { + it("returns wrapped global fetch when available", async () => { const fetchMock = vi.fn(async () => ({})); globalThis.fetch = fetchMock as unknown as typeof fetch; + const { resolveTelegramFetch } = await loadModule(); const resolved = resolveTelegramFetch(); expect(resolved).toBeTypeOf("function"); }); - it("prefers proxy fetch when provided", () => { + it("prefers proxy fetch when provided", async () => { const fetchMock = vi.fn(async () => ({})); + const { resolveTelegramFetch } = await loadModule(); const resolved = resolveTelegramFetch(fetchMock as unknown as typeof fetch); expect(resolved).toBeTypeOf("function"); }); + + it("honors env enable override", async () => { + vi.stubEnv("CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", "1"); + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule(); + resolveTelegramFetch(); + expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true); + }); + + it("uses config override when provided", async () => { + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule(); + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(true); + }); + + it("env disable override wins over config", async () => { + vi.stubEnv("CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY", "1"); + globalThis.fetch = vi.fn(async () => ({})) as unknown as typeof fetch; + const { resolveTelegramFetch, setDefaultAutoSelectFamily } = await loadModule(); + resolveTelegramFetch(undefined, { network: { autoSelectFamily: true } }); + expect(setDefaultAutoSelectFamily).toHaveBeenCalledWith(false); + }); }); diff --git a/src/telegram/fetch.ts b/src/telegram/fetch.ts index 00a21be9b..ebed468c9 100644 --- a/src/telegram/fetch.ts +++ b/src/telegram/fetch.ts @@ -1,19 +1,36 @@ -import { setDefaultAutoSelectFamily } from "net"; +import * as net from "node:net"; import { resolveFetch } from "../infra/fetch.js"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; +import { createSubsystemLogger } from "../logging/subsystem.js"; +import { resolveTelegramAutoSelectFamilyDecision } from "./network-config.js"; -// Workaround for Node.js 22 "Happy Eyeballs" (autoSelectFamily) bug -// that causes intermittent ETIMEDOUT errors when connecting to Telegram's -// dual-stack servers. Disabling autoSelectFamily forces sequential IPv4/IPv6 -// attempts which works reliably. +let appliedAutoSelectFamily: boolean | null = null; +const log = createSubsystemLogger("telegram/network"); + +// Node 22 workaround: disable autoSelectFamily to avoid Happy Eyeballs timeouts. // See: https://github.com/nodejs/node/issues/54359 -try { - setDefaultAutoSelectFamily(false); -} catch { - // Ignore if not available (older Node versions) +function applyTelegramNetworkWorkarounds(network?: TelegramNetworkConfig): void { + const decision = resolveTelegramAutoSelectFamilyDecision({ network }); + if (decision.value === null || decision.value === appliedAutoSelectFamily) return; + appliedAutoSelectFamily = decision.value; + + if (typeof net.setDefaultAutoSelectFamily === "function") { + try { + net.setDefaultAutoSelectFamily(decision.value); + const label = decision.source ? ` (${decision.source})` : ""; + log.info(`telegram: autoSelectFamily=${decision.value}${label}`); + } catch { + // ignore if unsupported by the runtime + } + } } // Prefer wrapped fetch when available to normalize AbortSignal across runtimes. -export function resolveTelegramFetch(proxyFetch?: typeof fetch): typeof fetch | undefined { +export function resolveTelegramFetch( + proxyFetch?: typeof fetch, + options?: { network?: TelegramNetworkConfig }, +): typeof fetch | undefined { + applyTelegramNetworkWorkarounds(options?.network); if (proxyFetch) return resolveFetch(proxyFetch); const fetchImpl = resolveFetch(); if (!fetchImpl) { diff --git a/src/telegram/monitor.test.ts b/src/telegram/monitor.test.ts index bfd8c83ac..2fc46827b 100644 --- a/src/telegram/monitor.test.ts +++ b/src/telegram/monitor.test.ts @@ -35,6 +35,11 @@ const { initSpy, runSpy, loadConfig } = vi.hoisted(() => ({ })), })); +const { computeBackoff, sleepWithAbort } = vi.hoisted(() => ({ + computeBackoff: vi.fn(() => 0), + sleepWithAbort: vi.fn(async () => undefined), +})); + vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { @@ -70,6 +75,11 @@ vi.mock("@grammyjs/runner", () => ({ run: runSpy, })); +vi.mock("../infra/backoff.js", () => ({ + computeBackoff, + sleepWithAbort, +})); + vi.mock("../auto-reply/reply.js", () => ({ getReplyFromConfig: async (ctx: { Body?: string }) => ({ text: `echo:${ctx.Body}`, @@ -84,6 +94,8 @@ describe("monitorTelegramProvider (grammY)", () => { }); initSpy.mockClear(); runSpy.mockClear(); + computeBackoff.mockClear(); + sleepWithAbort.mockClear(); }); it("processes a DM and sends reply", async () => { @@ -119,7 +131,11 @@ describe("monitorTelegramProvider (grammY)", () => { expect.anything(), expect.objectContaining({ sink: { concurrency: 3 }, - runner: expect.objectContaining({ silent: true }), + runner: expect.objectContaining({ + silent: true, + maxRetryTime: 5 * 60 * 1000, + retryInterval: "exponential", + }), }), ); }); @@ -140,4 +156,32 @@ describe("monitorTelegramProvider (grammY)", () => { }); expect(api.sendMessage).not.toHaveBeenCalled(); }); + + it("retries on recoverable network errors", async () => { + const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + runSpy + .mockImplementationOnce(() => ({ + task: () => Promise.reject(networkError), + stop: vi.fn(), + })) + .mockImplementationOnce(() => ({ + task: () => Promise.resolve(), + stop: vi.fn(), + })); + + await monitorTelegramProvider({ token: "tok" }); + + expect(computeBackoff).toHaveBeenCalled(); + expect(sleepWithAbort).toHaveBeenCalled(); + expect(runSpy).toHaveBeenCalledTimes(2); + }); + + it("surfaces non-recoverable errors", async () => { + runSpy.mockImplementationOnce(() => ({ + task: () => Promise.reject(new Error("bad token")), + stop: vi.fn(), + })); + + await expect(monitorTelegramProvider({ token: "tok" })).rejects.toThrow("bad token"); + }); }); diff --git a/src/telegram/monitor.ts b/src/telegram/monitor.ts index aeb5aae7c..5247c2af3 100644 --- a/src/telegram/monitor.ts +++ b/src/telegram/monitor.ts @@ -3,11 +3,13 @@ import type { ClawdbotConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { resolveAgentMaxConcurrent } from "../config/agent-limits.js"; import { computeBackoff, sleepWithAbort } from "../infra/backoff.js"; +import { formatErrorMessage } from "../infra/errors.js"; import { formatDurationMs } from "../infra/format-duration.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramAllowedUpdates } from "./allowed-updates.js"; import { createTelegramBot } from "./bot.js"; +import { isRecoverableTelegramNetworkError } from "./network-errors.js"; import { makeProxyFetch } from "./proxy.js"; import { readTelegramUpdateOffset, writeTelegramUpdateOffset } from "./update-offset-store.js"; import { startTelegramWebhook } from "./webhook.js"; @@ -40,9 +42,8 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions { return haystack.includes("getupdates"); }; -const isRecoverableNetworkError = (err: unknown): boolean => { - if (!err) return false; - const message = err instanceof Error ? err.message : String(err); - const lowerMessage = message.toLowerCase(); - // Recoverable network errors that should trigger retry, not crash - return ( - lowerMessage.includes("fetch failed") || - lowerMessage.includes("network request") || - lowerMessage.includes("econnrefused") || - lowerMessage.includes("econnreset") || - lowerMessage.includes("etimedout") || - lowerMessage.includes("socket hang up") || - lowerMessage.includes("enotfound") || - lowerMessage.includes("abort") - ); -}; - export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { const cfg = opts.config ?? loadConfig(); const account = resolveTelegramAccount({ @@ -154,7 +138,6 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { } // Use grammyjs/runner for concurrent update processing - const log = opts.runtime?.log ?? console.log; let restartAttempts = 0; while (!opts.abortSignal?.aborted) { @@ -174,14 +157,14 @@ export async function monitorTelegramProvider(opts: MonitorTelegramOpts = {}) { throw err; } const isConflict = isGetUpdatesConflict(err); - const isNetworkError = isRecoverableNetworkError(err); - if (!isConflict && !isNetworkError) { + const isRecoverable = isRecoverableTelegramNetworkError(err, { context: "polling" }); + if (!isConflict && !isRecoverable) { throw err; } restartAttempts += 1; const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, restartAttempts); const reason = isConflict ? "getUpdates conflict" : "network error"; - const errMsg = err instanceof Error ? err.message : String(err); + const errMsg = formatErrorMessage(err); (opts.runtime?.error ?? console.error)( `Telegram ${reason}: ${errMsg}; retrying in ${formatDurationMs(delayMs)}.`, ); diff --git a/src/telegram/network-config.test.ts b/src/telegram/network-config.test.ts new file mode 100644 index 000000000..cb4bc4c6e --- /dev/null +++ b/src/telegram/network-config.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { resolveTelegramAutoSelectFamilyDecision } from "./network-config.js"; + +describe("resolveTelegramAutoSelectFamilyDecision", () => { + it("prefers env enable over env disable", () => { + const decision = resolveTelegramAutoSelectFamilyDecision({ + env: { + CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY: "1", + CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY: "1", + }, + nodeMajor: 22, + }); + expect(decision).toEqual({ + value: true, + source: "env:CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY", + }); + }); + + it("uses env disable when set", () => { + const decision = resolveTelegramAutoSelectFamilyDecision({ + env: { CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY: "1" }, + nodeMajor: 22, + }); + expect(decision).toEqual({ + value: false, + source: "env:CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY", + }); + }); + + it("uses config override when provided", () => { + const decision = resolveTelegramAutoSelectFamilyDecision({ + network: { autoSelectFamily: true }, + nodeMajor: 22, + }); + expect(decision).toEqual({ value: true, source: "config" }); + }); + + it("defaults to disable on Node 22", () => { + const decision = resolveTelegramAutoSelectFamilyDecision({ nodeMajor: 22 }); + expect(decision).toEqual({ value: false, source: "default-node22" }); + }); + + it("returns null when no decision applies", () => { + const decision = resolveTelegramAutoSelectFamilyDecision({ nodeMajor: 20 }); + expect(decision).toEqual({ value: null }); + }); +}); diff --git a/src/telegram/network-config.ts b/src/telegram/network-config.ts new file mode 100644 index 000000000..ac5dd05a7 --- /dev/null +++ b/src/telegram/network-config.ts @@ -0,0 +1,39 @@ +import process from "node:process"; + +import { isTruthyEnvValue } from "../infra/env.js"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; + +export const TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV = + "CLAWDBOT_TELEGRAM_DISABLE_AUTO_SELECT_FAMILY"; +export const TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV = "CLAWDBOT_TELEGRAM_ENABLE_AUTO_SELECT_FAMILY"; + +export type TelegramAutoSelectFamilyDecision = { + value: boolean | null; + source?: string; +}; + +export function resolveTelegramAutoSelectFamilyDecision(params?: { + network?: TelegramNetworkConfig; + env?: NodeJS.ProcessEnv; + nodeMajor?: number; +}): TelegramAutoSelectFamilyDecision { + const env = params?.env ?? process.env; + const nodeMajor = + typeof params?.nodeMajor === "number" + ? params.nodeMajor + : Number(process.versions.node.split(".")[0]); + + if (isTruthyEnvValue(env[TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV])) { + return { value: true, source: `env:${TELEGRAM_ENABLE_AUTO_SELECT_FAMILY_ENV}` }; + } + if (isTruthyEnvValue(env[TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV])) { + return { value: false, source: `env:${TELEGRAM_DISABLE_AUTO_SELECT_FAMILY_ENV}` }; + } + if (typeof params?.network?.autoSelectFamily === "boolean") { + return { value: params.network.autoSelectFamily, source: "config" }; + } + if (Number.isFinite(nodeMajor) && nodeMajor >= 22) { + return { value: false, source: "default-node22" }; + } + return { value: null }; +} diff --git a/src/telegram/network-errors.test.ts b/src/telegram/network-errors.test.ts new file mode 100644 index 000000000..ae42cbb97 --- /dev/null +++ b/src/telegram/network-errors.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; + +import { isRecoverableTelegramNetworkError } from "./network-errors.js"; + +describe("isRecoverableTelegramNetworkError", () => { + it("detects recoverable error codes", () => { + const err = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + expect(isRecoverableTelegramNetworkError(err)).toBe(true); + }); + + it("detects AbortError names", () => { + const err = Object.assign(new Error("The operation was aborted"), { name: "AbortError" }); + expect(isRecoverableTelegramNetworkError(err)).toBe(true); + }); + + it("detects nested causes", () => { + const cause = Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); + const err = Object.assign(new TypeError("fetch failed"), { cause }); + expect(isRecoverableTelegramNetworkError(err)).toBe(true); + }); + + it("skips message matches for send context", () => { + const err = new TypeError("fetch failed"); + expect(isRecoverableTelegramNetworkError(err, { context: "send" })).toBe(false); + expect(isRecoverableTelegramNetworkError(err, { context: "polling" })).toBe(true); + }); + + it("returns false for unrelated errors", () => { + expect(isRecoverableTelegramNetworkError(new Error("invalid token"))).toBe(false); + }); +}); diff --git a/src/telegram/network-errors.ts b/src/telegram/network-errors.ts new file mode 100644 index 000000000..70cd81994 --- /dev/null +++ b/src/telegram/network-errors.ts @@ -0,0 +1,112 @@ +import { extractErrorCode, formatErrorMessage } from "../infra/errors.js"; + +const RECOVERABLE_ERROR_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "EPIPE", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "ENETUNREACH", + "EHOSTUNREACH", + "ENOTFOUND", + "EAI_AGAIN", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", + "UND_ERR_SOCKET", + "UND_ERR_ABORTED", +]); + +const RECOVERABLE_ERROR_NAMES = new Set([ + "AbortError", + "TimeoutError", + "ConnectTimeoutError", + "HeadersTimeoutError", + "BodyTimeoutError", +]); + +const RECOVERABLE_MESSAGE_SNIPPETS = [ + "fetch failed", + "network error", + "network request", + "client network socket disconnected", + "socket hang up", + "getaddrinfo", +]; + +function normalizeCode(code?: string): string { + return code?.trim().toUpperCase() ?? ""; +} + +function getErrorName(err: unknown): string { + if (!err || typeof err !== "object") return ""; + return "name" in err ? String(err.name) : ""; +} + +function getErrorCode(err: unknown): string | undefined { + const direct = extractErrorCode(err); + if (direct) return direct; + if (!err || typeof err !== "object") return undefined; + const errno = (err as { errno?: unknown }).errno; + if (typeof errno === "string") return errno; + if (typeof errno === "number") return String(errno); + return undefined; +} + +function collectErrorCandidates(err: unknown): unknown[] { + const queue = [err]; + const seen = new Set(); + const candidates: unknown[] = []; + + while (queue.length > 0) { + const current = queue.shift(); + if (current == null || seen.has(current)) continue; + seen.add(current); + candidates.push(current); + + if (typeof current === "object") { + const cause = (current as { cause?: unknown }).cause; + if (cause && !seen.has(cause)) queue.push(cause); + const reason = (current as { reason?: unknown }).reason; + if (reason && !seen.has(reason)) queue.push(reason); + const errors = (current as { errors?: unknown }).errors; + if (Array.isArray(errors)) { + for (const nested of errors) { + if (nested && !seen.has(nested)) queue.push(nested); + } + } + } + } + + return candidates; +} + +export type TelegramNetworkErrorContext = "polling" | "send" | "webhook" | "unknown"; + +export function isRecoverableTelegramNetworkError( + err: unknown, + options: { context?: TelegramNetworkErrorContext; allowMessageMatch?: boolean } = {}, +): boolean { + if (!err) return false; + const allowMessageMatch = + typeof options.allowMessageMatch === "boolean" + ? options.allowMessageMatch + : options.context !== "send"; + + for (const candidate of collectErrorCandidates(err)) { + const code = normalizeCode(getErrorCode(candidate)); + if (code && RECOVERABLE_ERROR_CODES.has(code)) return true; + + const name = getErrorName(candidate); + if (name && RECOVERABLE_ERROR_NAMES.has(name)) return true; + + if (allowMessageMatch) { + const message = formatErrorMessage(candidate).toLowerCase(); + if (message && RECOVERABLE_MESSAGE_SNIPPETS.some((snippet) => message.includes(snippet))) { + return true; + } + } + } + + return false; +} diff --git a/src/telegram/send.caption-split.test.ts b/src/telegram/send.caption-split.test.ts index 58e0a921a..7911e2890 100644 --- a/src/telegram/send.caption-split.test.ts +++ b/src/telegram/send.caption-split.test.ts @@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({ vi.mock("grammy", () => ({ Bot: class { api = botApi; + catch = vi.fn(); constructor( public token: string, public options?: { diff --git a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts b/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts index 18176d259..2f9e7d057 100644 --- a/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts +++ b/src/telegram/send.preserves-thread-params-plain-text-fallback.test.ts @@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({ vi.mock("grammy", () => ({ Bot: class { api = botApi; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch } }, diff --git a/src/telegram/send.proxy.test.ts b/src/telegram/send.proxy.test.ts index b395662e4..39ef9e2d0 100644 --- a/src/telegram/send.proxy.test.ts +++ b/src/telegram/send.proxy.test.ts @@ -40,6 +40,7 @@ vi.mock("./fetch.js", () => ({ vi.mock("grammy", () => ({ Bot: class { api = botApi; + catch = vi.fn(); constructor( public token: string, public options?: { client?: { fetch?: typeof fetch; timeoutSeconds?: number } }, @@ -76,7 +77,7 @@ describe("telegram proxy client", () => { await sendMessageTelegram("123", "hi", { token: "tok", accountId: "foo" }); expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); - expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined }); expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ @@ -94,7 +95,7 @@ describe("telegram proxy client", () => { await reactMessageTelegram("123", "456", "✅", { token: "tok", accountId: "foo" }); expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); - expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined }); expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ @@ -112,7 +113,7 @@ describe("telegram proxy client", () => { await deleteMessageTelegram("123", "456", { token: "tok", accountId: "foo" }); expect(makeProxyFetch).toHaveBeenCalledWith(proxyUrl); - expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch); + expect(resolveTelegramFetch).toHaveBeenCalledWith(proxyFetch, { network: undefined }); expect(botCtorSpy).toHaveBeenCalledWith( "tok", expect.objectContaining({ diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts index 6e2ea85d0..d086fe2a3 100644 --- a/src/telegram/send.returns-undefined-empty-input.test.ts +++ b/src/telegram/send.returns-undefined-empty-input.test.ts @@ -19,6 +19,7 @@ vi.mock("../web/media.js", () => ({ vi.mock("grammy", () => ({ Bot: class { api = botApi; + catch = vi.fn(); constructor( public token: string, public options?: { diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 43a3a5e8c..d28cff55e 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -22,6 +22,7 @@ import { resolveTelegramFetch } from "./fetch.js"; import { makeProxyFetch } from "./proxy.js"; import { renderTelegramHtmlText } from "./format.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +import { isRecoverableTelegramNetworkError } from "./network-errors.js"; import { splitTelegramCaption } from "./caption.js"; import { recordSentMessage } from "./sent-message-cache.js"; import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; @@ -84,7 +85,9 @@ function resolveTelegramClientOptions( ): ApiClientOptions | undefined { const proxyUrl = account.config.proxy?.trim(); const proxyFetch = proxyUrl ? makeProxyFetch(proxyUrl) : undefined; - const fetchImpl = resolveTelegramFetch(proxyFetch); + const fetchImpl = resolveTelegramFetch(proxyFetch, { + network: account.config.network, + }); const timeoutSeconds = typeof account.config.timeoutSeconds === "number" && Number.isFinite(account.config.timeoutSeconds) @@ -203,6 +206,7 @@ export async function sendMessageTelegram( retry: opts.retry, configRetry: account.config.retry, verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), }); const logHttpError = createTelegramHttpLogger(cfg); const requestWithDiag = (fn: () => Promise, label?: string) => @@ -434,6 +438,7 @@ export async function reactMessageTelegram( retry: opts.retry, configRetry: account.config.retry, verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), }); const logHttpError = createTelegramHttpLogger(cfg); const requestWithDiag = (fn: () => Promise, label?: string) => @@ -483,6 +488,7 @@ export async function deleteMessageTelegram( retry: opts.retry, configRetry: account.config.retry, verbose: opts.verbose, + shouldRetry: (err) => isRecoverableTelegramNetworkError(err, { context: "send" }), }); const logHttpError = createTelegramHttpLogger(cfg); const requestWithDiag = (fn: () => Promise, label?: string) => diff --git a/src/telegram/webhook-set.ts b/src/telegram/webhook-set.ts index eced660e6..2880c8254 100644 --- a/src/telegram/webhook-set.ts +++ b/src/telegram/webhook-set.ts @@ -1,4 +1,5 @@ import { type ApiClientOptions, Bot } from "grammy"; +import type { TelegramNetworkConfig } from "../config/types.telegram.js"; import { resolveTelegramFetch } from "./fetch.js"; export async function setTelegramWebhook(opts: { @@ -6,8 +7,9 @@ export async function setTelegramWebhook(opts: { url: string; secret?: string; dropPendingUpdates?: boolean; + network?: TelegramNetworkConfig; }) { - const fetchImpl = resolveTelegramFetch(); + const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network }); const client: ApiClientOptions | undefined = fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : undefined; @@ -18,8 +20,11 @@ export async function setTelegramWebhook(opts: { }); } -export async function deleteTelegramWebhook(opts: { token: string }) { - const fetchImpl = resolveTelegramFetch(); +export async function deleteTelegramWebhook(opts: { + token: string; + network?: TelegramNetworkConfig; +}) { + const fetchImpl = resolveTelegramFetch(undefined, { network: opts.network }); const client: ApiClientOptions | undefined = fetchImpl ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } : undefined; From 0c855bd36a68e14441b8640faab1e52b2561c36a Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 26 Jan 2026 19:59:25 -0500 Subject: [PATCH 26/43] Infra: fix recoverable error formatting --- src/infra/unhandled-rejections.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index c45923c4b..ac7ac91d5 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -1,6 +1,6 @@ import process from "node:process"; -import { formatUncaughtError } from "./errors.js"; +import { formatErrorMessage, formatUncaughtError } from "./errors.js"; type UnhandledRejectionHandler = (reason: unknown) => boolean; @@ -25,7 +25,7 @@ function isRecoverableError(reason: unknown): boolean { return true; } - const message = reason instanceof Error ? reason.message : String(reason); + const message = reason instanceof Error ? reason.message : formatErrorMessage(reason); const lowerMessage = message.toLowerCase(); return ( lowerMessage.includes("fetch failed") || From 1506d493ea72a906515cee83f12738c66f7b7ecc Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 27 Jan 2026 01:00:17 +0000 Subject: [PATCH 27/43] fix: switch Matrix plugin SDK --- CHANGELOG.md | 1 + docs/channels/matrix.md | 2 +- extensions/matrix/package.json | 2 +- .../matrix/src/matrix/actions/messages.ts | 2 +- .../matrix/src/matrix/actions/reactions.ts | 2 +- extensions/matrix/src/matrix/actions/room.ts | 6 +- .../matrix/src/matrix/actions/summary.ts | 2 +- extensions/matrix/src/matrix/actions/types.ts | 2 +- extensions/matrix/src/matrix/active-client.ts | 2 +- extensions/matrix/src/matrix/client/config.ts | 2 +- .../matrix/src/matrix/client/create-client.ts | 4 +- .../matrix/src/matrix/client/logging.ts | 2 +- extensions/matrix/src/matrix/client/shared.ts | 6 +- extensions/matrix/src/matrix/deps.ts | 8 +- .../matrix/src/matrix/monitor/auto-join.ts | 4 +- .../matrix/src/matrix/monitor/direct.ts | 2 +- .../matrix/src/matrix/monitor/events.ts | 2 +- .../matrix/src/matrix/monitor/handler.ts | 6 +- extensions/matrix/src/matrix/monitor/index.ts | 2 +- .../matrix/src/matrix/monitor/location.ts | 2 +- .../matrix/src/matrix/monitor/media.test.ts | 4 +- extensions/matrix/src/matrix/monitor/media.ts | 6 +- .../matrix/src/matrix/monitor/replies.ts | 2 +- .../matrix/src/matrix/monitor/room-info.ts | 2 +- .../matrix/src/matrix/monitor/threads.ts | 2 +- extensions/matrix/src/matrix/monitor/types.ts | 2 +- extensions/matrix/src/matrix/probe.ts | 2 +- extensions/matrix/src/matrix/send.test.ts | 4 +- extensions/matrix/src/matrix/send.ts | 6 +- extensions/matrix/src/matrix/send/client.ts | 4 +- extensions/matrix/src/matrix/send/media.ts | 2 +- .../matrix/src/matrix/send/targets.test.ts | 2 +- extensions/matrix/src/matrix/send/targets.ts | 2 +- extensions/matrix/src/matrix/send/types.ts | 4 +- extensions/matrix/src/onboarding.ts | 2 +- extensions/matrix/src/types.ts | 2 +- extensions/memory-core/package.json | 2 +- pnpm-lock.yaml | 196 ++++++++++++++---- 38 files changed, 213 insertions(+), 94 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a83519fef..5d6e6040a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Status: unreleased. ### Changes - Skills: add multi-image input support to Nano Banana Pro skill. (#1958) Thanks @tyler6204. - Agents: honor tools.exec.safeBins in exec allowlist checks. (#2281) +- Matrix: switch plugin SDK to @vector-im/matrix-bot-sdk. - Docs: tighten Fly private deployment steps. (#2289) Thanks @dguido. - Docs: add migration guide for moving to a new machine. (#2381) - Docs: add Northflank one-click deployment guide. (#2167) Thanks @AdeboyeDN. diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md index 2d9025f51..8151bfed1 100644 --- a/docs/channels/matrix.md +++ b/docs/channels/matrix.md @@ -10,7 +10,7 @@ on any homeserver, so you need a Matrix account for the bot. Once it is logged i the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too, but it requires E2EE to be enabled. -Status: supported via plugin (matrix-bot-sdk). Direct messages, rooms, threads, media, reactions, +Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions, polls (send + poll-start as text), location, and E2EE (with crypto support). ## Plugin required diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 7fa12bc74..625c92df0 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -26,7 +26,7 @@ "dependencies": { "@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0", "markdown-it": "14.1.0", - "matrix-bot-sdk": "0.8.0", + "@vector-im/matrix-bot-sdk": "0.8.0-element.3", "music-metadata": "^11.10.6", "zod": "^4.3.6" }, diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts index dae1a0f20..60f69e219 100644 --- a/extensions/matrix/src/matrix/actions/messages.ts +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -95,7 +95,7 @@ export async function readMatrixMessages( : 20; const token = opts.before?.trim() || opts.after?.trim() || undefined; const dir = opts.after ? "f" : "b"; - // matrix-bot-sdk uses doRequest for room messages + // @vector-im/matrix-bot-sdk uses doRequest for room messages const res = await client.doRequest( "GET", `/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`, diff --git a/extensions/matrix/src/matrix/actions/reactions.ts b/extensions/matrix/src/matrix/actions/reactions.ts index 5c3f65305..044ef46c5 100644 --- a/extensions/matrix/src/matrix/actions/reactions.ts +++ b/extensions/matrix/src/matrix/actions/reactions.ts @@ -21,7 +21,7 @@ export async function listMatrixReactions( typeof opts.limit === "number" && Number.isFinite(opts.limit) ? Math.max(1, Math.floor(opts.limit)) : 100; - // matrix-bot-sdk uses doRequest for relations + // @vector-im/matrix-bot-sdk uses doRequest for relations const res = await client.doRequest( "GET", `/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`, diff --git a/extensions/matrix/src/matrix/actions/room.ts b/extensions/matrix/src/matrix/actions/room.ts index 1b52404dc..68cf9b0a0 100644 --- a/extensions/matrix/src/matrix/actions/room.ts +++ b/extensions/matrix/src/matrix/actions/room.ts @@ -9,9 +9,9 @@ export async function getMatrixMemberInfo( const { client, stopOnDone } = await resolveActionClient(opts); try { const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; - // matrix-bot-sdk uses getUserProfile + // @vector-im/matrix-bot-sdk uses getUserProfile const profile = await client.getUserProfile(userId); - // Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk + // Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk // We'd need to fetch room state separately if needed return { userId, @@ -36,7 +36,7 @@ export async function getMatrixRoomInfo( const { client, stopOnDone } = await resolveActionClient(opts); try { const resolvedRoom = await resolveMatrixRoomId(client, roomId); - // matrix-bot-sdk uses getRoomState for state events + // @vector-im/matrix-bot-sdk uses getRoomState for state events let name: string | null = null; let topic: string | null = null; let canonicalAlias: string | null = null; diff --git a/extensions/matrix/src/matrix/actions/summary.ts b/extensions/matrix/src/matrix/actions/summary.ts index f58d6a9b8..2fa2d27b3 100644 --- a/extensions/matrix/src/matrix/actions/summary.ts +++ b/extensions/matrix/src/matrix/actions/summary.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { EventType, diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts index 506e00783..75fddbd9c 100644 --- a/extensions/matrix/src/matrix/actions/types.ts +++ b/extensions/matrix/src/matrix/actions/types.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; export const MsgType = { Text: "m.text", diff --git a/extensions/matrix/src/matrix/active-client.ts b/extensions/matrix/src/matrix/active-client.ts index 9aa0ffdde..5ff540926 100644 --- a/extensions/matrix/src/matrix/active-client.ts +++ b/extensions/matrix/src/matrix/active-client.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; let activeClient: MatrixClient | null = null; diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index bc0729ddb..048c3bef9 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -1,4 +1,4 @@ -import { MatrixClient } from "matrix-bot-sdk"; +import { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { CoreConfig } from "../types.js"; import { getMatrixRuntime } from "../../runtime.js"; diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts index 01dc2e7ad..874da7e92 100644 --- a/extensions/matrix/src/matrix/client/create-client.ts +++ b/extensions/matrix/src/matrix/client/create-client.ts @@ -5,8 +5,8 @@ import { MatrixClient, SimpleFsStorageProvider, RustSdkCryptoStorageProvider, -} from "matrix-bot-sdk"; -import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk"; +} from "@vector-im/matrix-bot-sdk"; +import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk"; import { ensureMatrixSdkLoggingConfigured } from "./logging.js"; import { diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts index 7c4011fc5..5a7180597 100644 --- a/extensions/matrix/src/matrix/client/logging.ts +++ b/extensions/matrix/src/matrix/client/logging.ts @@ -1,4 +1,4 @@ -import { ConsoleLogger, LogService } from "matrix-bot-sdk"; +import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk"; let matrixSdkLoggingConfigured = false; const matrixSdkBaseLogger = new ConsoleLogger(); diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts index fcde28268..da10fc360 100644 --- a/extensions/matrix/src/matrix/client/shared.ts +++ b/extensions/matrix/src/matrix/client/shared.ts @@ -1,5 +1,5 @@ -import { LogService } from "matrix-bot-sdk"; -import type { MatrixClient } from "matrix-bot-sdk"; +import { LogService } from "@vector-im/matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { CoreConfig } from "../types.js"; import { createMatrixClient } from "./create-client.js"; @@ -157,7 +157,7 @@ export async function waitForMatrixSync(_params: { timeoutMs?: number; abortSignal?: AbortSignal; }): Promise { - // matrix-bot-sdk handles sync internally in start() + // @vector-im/matrix-bot-sdk handles sync internally in start() // This is kept for API compatibility but is essentially a no-op now } diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts index df2f58706..5777e43a7 100644 --- a/extensions/matrix/src/matrix/deps.ts +++ b/extensions/matrix/src/matrix/deps.ts @@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url"; import type { RuntimeEnv } from "clawdbot/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js"; -const MATRIX_SDK_PACKAGE = "matrix-bot-sdk"; +const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk"; export function isMatrixSdkAvailable(): boolean { try { @@ -30,9 +30,9 @@ export async function ensureMatrixSdkInstalled(params: { if (isMatrixSdkAvailable()) return; const confirm = params.confirm; if (confirm) { - const ok = await confirm("Matrix requires matrix-bot-sdk. Install now?"); + const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?"); if (!ok) { - throw new Error("Matrix requires matrix-bot-sdk (install dependencies first)."); + throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first)."); } } @@ -52,6 +52,6 @@ export async function ensureMatrixSdkInstalled(params: { ); } if (!isMatrixSdkAvailable()) { - throw new Error("Matrix dependency install completed but matrix-bot-sdk is still missing."); + throw new Error("Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing."); } } diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts index 564c78995..5feb5bc3a 100644 --- a/extensions/matrix/src/matrix/monitor/auto-join.ts +++ b/extensions/matrix/src/matrix/monitor/auto-join.ts @@ -1,5 +1,5 @@ -import type { MatrixClient } from "matrix-bot-sdk"; -import { AutojoinRoomsMixin } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; +import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk"; import type { RuntimeEnv } from "clawdbot/plugin-sdk"; import type { CoreConfig } from "../../types.js"; diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts index fff8383ca..cd2234fdd 100644 --- a/extensions/matrix/src/matrix/monitor/direct.ts +++ b/extensions/matrix/src/matrix/monitor/direct.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; type DirectMessageCheck = { roomId: string; diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts index af49693ff..3705eb356 100644 --- a/extensions/matrix/src/matrix/monitor/events.ts +++ b/extensions/matrix/src/matrix/monitor/events.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PluginRuntime } from "clawdbot/plugin-sdk"; import type { MatrixAuth } from "../client.js"; diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 4542e113a..19f9be38d 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -1,4 +1,4 @@ -import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk"; +import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk"; import { createReplyPrefixContext, @@ -110,7 +110,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam try { const eventType = event.type; if (eventType === EventType.RoomMessageEncrypted) { - // Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled + // Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled return; } @@ -436,7 +436,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam threadReplies, messageId, threadRootId, - isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available + isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available }); const route = core.channel.routing.resolveAgentRoute({ diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts index 35e75c4ed..0a203be41 100644 --- a/extensions/matrix/src/matrix/monitor/index.ts +++ b/extensions/matrix/src/matrix/monitor/index.ts @@ -244,7 +244,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi }); logVerboseMessage("matrix: client started"); - // matrix-bot-sdk client is already started via resolveSharedMatrixClient + // @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient logger.info(`matrix: logged in as ${auth.userId}`); // If E2EE is enabled, trigger device verification diff --git a/extensions/matrix/src/matrix/monitor/location.ts b/extensions/matrix/src/matrix/monitor/location.ts index 22374cad8..0054b6c6b 100644 --- a/extensions/matrix/src/matrix/monitor/location.ts +++ b/extensions/matrix/src/matrix/monitor/location.ts @@ -1,4 +1,4 @@ -import type { LocationMessageEventContent } from "matrix-bot-sdk"; +import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk"; import { formatLocationText, diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts index 10cbd8b47..28ed5046a 100644 --- a/extensions/matrix/src/matrix/monitor/media.test.ts +++ b/extensions/matrix/src/matrix/monitor/media.test.ts @@ -29,7 +29,7 @@ describe("downloadMatrixMedia", () => { const client = { crypto: { decryptMedia }, mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), - } as unknown as import("matrix-bot-sdk").MatrixClient; + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; const file = { url: "mxc://example/file", @@ -70,7 +70,7 @@ describe("downloadMatrixMedia", () => { const client = { crypto: { decryptMedia }, mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"), - } as unknown as import("matrix-bot-sdk").MatrixClient; + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; const file = { url: "mxc://example/file", diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts index 1ade1d19c..0b33cca53 100644 --- a/extensions/matrix/src/matrix/monitor/media.ts +++ b/extensions/matrix/src/matrix/monitor/media.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { getMatrixRuntime } from "../../runtime.js"; @@ -22,7 +22,7 @@ async function fetchMatrixMediaBuffer(params: { mxcUrl: string; maxBytes: number; }): Promise<{ buffer: Buffer; headerType?: string } | null> { - // matrix-bot-sdk provides mxcToHttp helper + // @vector-im/matrix-bot-sdk provides mxcToHttp helper const url = params.client.mxcToHttp(params.mxcUrl); if (!url) return null; @@ -40,7 +40,7 @@ async function fetchMatrixMediaBuffer(params: { /** * Download and decrypt encrypted media from a Matrix room. - * Uses matrix-bot-sdk's decryptMedia which handles both download and decryption. + * Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption. */ async function fetchEncryptedMediaBuffer(params: { client: MatrixClient; diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index f79ef5926..70ac9bacc 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; import { sendMessageMatrix } from "../send.js"; diff --git a/extensions/matrix/src/matrix/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts index e32b5b37a..cad377e1a 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; export type MatrixRoomInfo = { name?: string; diff --git a/extensions/matrix/src/matrix/monitor/threads.ts b/extensions/matrix/src/matrix/monitor/threads.ts index 3378d3b2b..4d618f329 100644 --- a/extensions/matrix/src/matrix/monitor/threads.ts +++ b/extensions/matrix/src/matrix/monitor/threads.ts @@ -1,4 +1,4 @@ -// Type for raw Matrix event from matrix-bot-sdk +// Type for raw Matrix event from @vector-im/matrix-bot-sdk type MatrixRawEvent = { event_id: string; sender: string; diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts index c77cf0282..c910f931f 100644 --- a/extensions/matrix/src/matrix/monitor/types.ts +++ b/extensions/matrix/src/matrix/monitor/types.ts @@ -1,4 +1,4 @@ -import type { EncryptedFile, MessageEventContent } from "matrix-bot-sdk"; +import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk"; export const EventType = { RoomMessage: "m.room.message", diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts index 3bfdd1728..7bd54bdc4 100644 --- a/extensions/matrix/src/matrix/probe.ts +++ b/extensions/matrix/src/matrix/probe.ts @@ -49,7 +49,7 @@ export async function probeMatrix(params: { accessToken: params.accessToken, localTimeoutMs: params.timeoutMs, }); - // matrix-bot-sdk uses getUserId() which calls whoami internally + // @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally const userId = await client.getUserId(); result.ok = true; result.userId = userId ?? null; diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index c647eedb9..e82e18fb0 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { PluginRuntime } from "clawdbot/plugin-sdk"; import { setMatrixRuntime } from "../runtime.js"; -vi.mock("matrix-bot-sdk", () => ({ +vi.mock("@vector-im/matrix-bot-sdk", () => ({ ConsoleLogger: class { trace = vi.fn(); debug = vi.fn(); @@ -60,7 +60,7 @@ const makeClient = () => { sendMessage, uploadContent, getUserId: vi.fn().mockResolvedValue("@bot:example.org"), - } as unknown as import("matrix-bot-sdk").MatrixClient; + } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient; return { client, sendMessage, uploadContent }; }; diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 264bd6429..1fed4198a 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import type { PollInput } from "clawdbot/plugin-sdk"; import { getMatrixRuntime } from "../runtime.js"; @@ -72,7 +72,7 @@ export async function sendMessageMatrix( ? buildThreadRelation(threadId, opts.replyToId) : buildReplyRelation(opts.replyToId); const sendContent = async (content: MatrixOutboundContent) => { - // matrix-bot-sdk uses sendMessage differently + // @vector-im/matrix-bot-sdk uses sendMessage differently const eventId = await client.sendMessage(roomId, content); return eventId; }; @@ -172,7 +172,7 @@ export async function sendPollMatrix( const pollPayload = threadId ? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) } : pollContent; - // matrix-bot-sdk sendEvent returns eventId string directly + // @vector-im/matrix-bot-sdk sendEvent returns eventId string directly const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload); return { diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts index 2faa19091..5b9338054 100644 --- a/extensions/matrix/src/matrix/send/client.ts +++ b/extensions/matrix/src/matrix/send/client.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { getMatrixRuntime } from "../../runtime.js"; import { getActiveMatrixClient } from "../active-client.js"; @@ -57,7 +57,7 @@ export async function resolveMatrixClient(opts: { // Ignore crypto prep failures for one-off sends; normal sync will retry. } } - // matrix-bot-sdk uses start() instead of startClient() + // @vector-im/matrix-bot-sdk uses start() instead of startClient() await client.start(); return { client, stopOnDone: true }; } diff --git a/extensions/matrix/src/matrix/send/media.ts b/extensions/matrix/src/matrix/send/media.ts index d4cf29805..8c564bddb 100644 --- a/extensions/matrix/src/matrix/send/media.ts +++ b/extensions/matrix/src/matrix/send/media.ts @@ -5,7 +5,7 @@ import type { MatrixClient, TimedFileInfo, VideoFileInfo, -} from "matrix-bot-sdk"; +} from "@vector-im/matrix-bot-sdk"; import { parseBuffer, type IFileInfo } from "music-metadata"; import { getMatrixRuntime } from "../../runtime.js"; diff --git a/extensions/matrix/src/matrix/send/targets.test.ts b/extensions/matrix/src/matrix/send/targets.test.ts index 18499f895..7173b1cf6 100644 --- a/extensions/matrix/src/matrix/send/targets.test.ts +++ b/extensions/matrix/src/matrix/send/targets.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { EventType } from "./types.js"; let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId; diff --git a/extensions/matrix/src/matrix/send/targets.ts b/extensions/matrix/src/matrix/send/targets.ts index dde734ba2..6ec6ad6d7 100644 --- a/extensions/matrix/src/matrix/send/targets.ts +++ b/extensions/matrix/src/matrix/send/targets.ts @@ -1,4 +1,4 @@ -import type { MatrixClient } from "matrix-bot-sdk"; +import type { MatrixClient } from "@vector-im/matrix-bot-sdk"; import { EventType, type MatrixDirectAccountData } from "./types.js"; diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index eb59f8a62..2b91327aa 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -6,7 +6,7 @@ import type { TextualMessageEventContent, TimedFileInfo, VideoFileInfo, -} from "matrix-bot-sdk"; +} from "@vector-im/matrix-bot-sdk"; // Message types export const MsgType = { @@ -85,7 +85,7 @@ export type MatrixSendResult = { }; export type MatrixSendOpts = { - client?: import("matrix-bot-sdk").MatrixClient; + client?: import("@vector-im/matrix-bot-sdk").MatrixClient; mediaUrl?: string; accountId?: string; replyToId?: string; diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts index 28f24b788..80c034d44 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/onboarding.ts @@ -185,7 +185,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, ], selectionHint: !sdkReady - ? "install matrix-bot-sdk" + ? "install @vector-im/matrix-bot-sdk" : configured ? "configured" : "needs auth", diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts index f44f1074d..f03734130 100644 --- a/extensions/matrix/src/types.ts +++ b/extensions/matrix/src/types.ts @@ -53,7 +53,7 @@ export type MatrixConfig = { password?: string; /** Optional device name when logging in via password. */ deviceName?: string; - /** Initial sync limit for startup (default: matrix-bot-sdk default). */ + /** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */ initialSyncLimit?: number; /** Enable end-to-end encryption (E2EE). Default: false. */ encryption?: boolean; diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json index c70da1395..af6a3f9cd 100644 --- a/extensions/memory-core/package.json +++ b/extensions/memory-core/package.json @@ -9,6 +9,6 @@ ] }, "peerDependencies": { - "clawdbot": ">=2026.1.25" + "clawdbot": ">=2026.1.24-3" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 223537e85..d1c55dd8d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,13 +172,6 @@ importers: zod: specifier: ^4.3.6 version: 4.3.6 - optionalDependencies: - '@napi-rs/canvas': - specifier: ^0.1.88 - version: 0.1.88 - node-llama-cpp: - specifier: 3.15.0 - version: 3.15.0(typescript@5.9.3) devDependencies: '@grammyjs/types': specifier: ^3.23.0 @@ -261,6 +254,13 @@ importers: wireit: specifier: ^0.14.12 version: 0.14.12 + optionalDependencies: + '@napi-rs/canvas': + specifier: ^0.1.88 + version: 0.1.88 + node-llama-cpp: + specifier: 3.15.0 + version: 3.15.0(typescript@5.9.3) extensions/bluebubbles: {} @@ -335,12 +335,12 @@ importers: '@matrix-org/matrix-sdk-crypto-nodejs': specifier: ^0.4.0 version: 0.4.0 + '@vector-im/matrix-bot-sdk': + specifier: 0.8.0-element.3 + version: 0.8.0-element.3 markdown-it: specifier: 14.1.0 version: 14.1.0 - matrix-bot-sdk: - specifier: 0.8.0 - version: 0.8.0 music-metadata: specifier: ^11.10.6 version: 11.10.6 @@ -357,8 +357,8 @@ importers: extensions/memory-core: dependencies: clawdbot: - specifier: '>=2026.1.25' - version: link:../.. + specifier: '>=2026.1.24-3' + version: 2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3) extensions/memory-lancedb: dependencies: @@ -1316,6 +1316,7 @@ packages: '@lancedb/lancedb@0.23.0': resolution: {integrity: sha512-aYrIoEG24AC+wILCL57Ius/Y4yU+xFHDPKLvmjzzN4byAjzeIGF0TC86S5RBt4Ji+dxS7yIWV5Q/gE5/fybIFQ==} engines: {node: '>= 18'} + cpu: [x64, arm64] os: [darwin, linux, win32] peerDependencies: apache-arrow: '>=15.0.0 <=18.1.0' @@ -2667,6 +2668,9 @@ packages: '@types/bun@1.3.6': resolution: {integrity: sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA==} + '@types/caseless@0.12.5': + resolution: {integrity: sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -2748,6 +2752,9 @@ packages: '@types/range-parser@1.2.7': resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + '@types/request@2.48.13': + resolution: {integrity: sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg==} + '@types/retry@0.12.0': resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} @@ -2766,6 +2773,9 @@ packages: '@types/serve-static@2.2.0': resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + '@types/tough-cookie@4.0.5': + resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2822,6 +2832,10 @@ packages: '@urbit/http-api@3.0.0': resolution: {integrity: sha512-EmyPbWHWXhfYQ/9wWFcLT53VvCn8ct9ljd6QEe+UBjNPEhUPOFBLpDsDp3iPLQgg8ykSU8JMMHxp95LHCorExA==} + '@vector-im/matrix-bot-sdk@0.8.0-element.3': + resolution: {integrity: sha512-2FFo/Kz2vTnOZDv59Q0s803LHf7KzuQ2EwOYYAtO0zUKJ8pV5CPsVC/IHyFb+Fsxl3R9XWFiX529yhslb4v9cQ==} + engines: {node: '>=22.0.0'} + '@vitest/browser-playwright@4.0.18': resolution: {integrity: sha512-gfajTHVCiwpxRj1qh0Sh/5bbGLG4F/ZH/V9xvFVoFddpITfMta9YGow0W6ZpTTORv2vdJuz9TnrNSmjKvpOf4g==} peerDependencies: @@ -3194,6 +3208,11 @@ packages: class-variance-authority@0.7.1: resolution: {integrity: sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==} + clawdbot@2026.1.24-3: + resolution: {integrity: sha512-zt9BzhWXduq8ZZR4rfzQDurQWAgmijTTyPZCQGrn5ew6wCEwhxxEr2/NHG7IlCwcfRsKymsY4se9KMhoNz0JtQ==} + engines: {node: '>=22.12.0'} + hasBin: true + cli-cursor@5.0.0: resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} engines: {node: '>=18'} @@ -3611,6 +3630,10 @@ packages: resolution: {integrity: sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==} engines: {node: '>= 0.12'} + form-data@2.5.5: + resolution: {integrity: sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A==} + engines: {node: '>= 0.12'} + form-data@4.0.5: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} @@ -4235,10 +4258,6 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} - matrix-bot-sdk@0.8.0: - resolution: {integrity: sha512-sCY5UvZfsZhJdCjSc8wZhGhIHOe5cSFSILxx9Zp5a/NEXtmQ6W/bIhefIk4zFAZXetFwXsgvKh1960k1hG5WDw==} - engines: {node: '>=22.0.0'} - mdurl@2.0.0: resolution: {integrity: sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==} @@ -8419,6 +8438,8 @@ snapshots: bun-types: 1.3.6 optional: true + '@types/caseless@0.12.5': {} + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -8511,6 +8532,13 @@ snapshots: '@types/range-parser@1.2.7': {} + '@types/request@2.48.13': + dependencies: + '@types/caseless': 0.12.5 + '@types/node': 25.0.10 + '@types/tough-cookie': 4.0.5 + form-data: 2.5.5 + '@types/retry@0.12.0': {} '@types/retry@0.12.5': {} @@ -8535,6 +8563,8 @@ snapshots: '@types/http-errors': 2.0.5 '@types/node': 25.0.10 + '@types/tough-cookie@4.0.5': {} + '@types/trusted-types@2.0.7': {} '@types/ws@8.18.1': @@ -8588,6 +8618,30 @@ snapshots: browser-or-node: 1.3.0 core-js: 3.48.0 + '@vector-im/matrix-bot-sdk@0.8.0-element.3': + dependencies: + '@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0 + '@types/express': 4.17.25 + '@types/request': 2.48.13 + another-json: 0.2.0 + async-lock: 1.4.1 + chalk: 4.1.2 + express: 4.22.1 + glob-to-regexp: 0.4.1 + hash.js: 1.1.7 + html-to-text: 9.0.5 + htmlencode: 0.0.4 + lowdb: 1.0.0 + lru-cache: 10.4.3 + mkdirp: 3.0.1 + morgan: 1.10.1 + postgres: 3.4.8 + request: 2.88.2 + request-promise: 4.2.6(request@2.88.2) + sanitize-html: 2.17.0 + transitivePeerDependencies: + - supports-color + '@vitest/browser-playwright@4.0.18(playwright@1.58.0)(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18)': dependencies: '@vitest/browser': 4.0.18(vite@7.3.1(@types/node@25.0.10)(jiti@2.6.1)(lightningcss@1.30.2)(tsx@4.21.0)(yaml@2.8.2))(vitest@4.0.18) @@ -9038,6 +9092,84 @@ snapshots: dependencies: clsx: 2.1.1 + clawdbot@2026.1.24-3(@types/express@5.0.6)(audio-decode@2.2.3)(devtools-protocol@0.0.1561482)(typescript@5.9.3): + dependencies: + '@agentclientprotocol/sdk': 0.13.1(zod@4.3.6) + '@aws-sdk/client-bedrock': 3.975.0 + '@buape/carbon': 0.14.0(hono@4.11.4) + '@clack/prompts': 0.11.0 + '@grammyjs/runner': 2.0.3(grammy@1.39.3) + '@grammyjs/transformer-throttler': 1.2.1(grammy@1.39.3) + '@homebridge/ciao': 1.3.4 + '@line/bot-sdk': 10.6.0 + '@lydell/node-pty': 1.2.0-beta.3 + '@mariozechner/pi-agent-core': 0.49.3(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-ai': 0.49.3(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-coding-agent': 0.49.3(ws@8.19.0)(zod@4.3.6) + '@mariozechner/pi-tui': 0.49.3 + '@mozilla/readability': 0.6.0 + '@sinclair/typebox': 0.34.47 + '@slack/bolt': 4.6.0(@types/express@5.0.6) + '@slack/web-api': 7.13.0 + '@whiskeysockets/baileys': 7.0.0-rc.9(audio-decode@2.2.3)(sharp@0.34.5) + ajv: 8.17.1 + body-parser: 2.2.2 + chalk: 5.6.2 + chokidar: 5.0.0 + chromium-bidi: 13.0.1(devtools-protocol@0.0.1561482) + cli-highlight: 2.1.11 + commander: 14.0.2 + croner: 9.1.0 + detect-libc: 2.1.2 + discord-api-types: 0.38.37 + dotenv: 17.2.3 + express: 5.2.1 + file-type: 21.3.0 + grammy: 1.39.3 + hono: 4.11.4 + jiti: 2.6.1 + json5: 2.2.3 + jszip: 3.10.1 + linkedom: 0.18.12 + long: 5.3.2 + markdown-it: 14.1.0 + node-edge-tts: 1.2.9 + osc-progress: 0.3.0 + pdfjs-dist: 5.4.530 + playwright-core: 1.58.0 + proper-lockfile: 4.1.2 + qrcode-terminal: 0.12.0 + sharp: 0.34.5 + sqlite-vec: 0.1.7-alpha.2 + tar: 7.5.4 + tslog: 4.10.2 + undici: 7.19.0 + ws: 8.19.0 + yaml: 2.8.2 + zod: 4.3.6 + optionalDependencies: + '@napi-rs/canvas': 0.1.88 + node-llama-cpp: 3.15.0(typescript@5.9.3) + transitivePeerDependencies: + - '@discordjs/opus' + - '@modelcontextprotocol/sdk' + - '@types/express' + - audio-decode + - aws-crt + - bufferutil + - canvas + - debug + - devtools-protocol + - encoding + - ffmpeg-static + - jimp + - link-preview-js + - node-opus + - opusscript + - supports-color + - typescript + - utf-8-validate + cli-cursor@5.0.0: dependencies: restore-cursor: 5.1.0 @@ -9518,6 +9650,15 @@ snapshots: combined-stream: 1.0.8 mime-types: 2.1.35 + form-data@2.5.5: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + safe-buffer: 5.2.1 + form-data@4.0.5: dependencies: asynckit: 0.4.0 @@ -10197,29 +10338,6 @@ snapshots: math-intrinsics@1.1.0: {} - matrix-bot-sdk@0.8.0: - dependencies: - '@matrix-org/matrix-sdk-crypto-nodejs': 0.4.0 - '@types/express': 4.17.25 - another-json: 0.2.0 - async-lock: 1.4.1 - chalk: 4.1.2 - express: 4.22.1 - glob-to-regexp: 0.4.1 - hash.js: 1.1.7 - html-to-text: 9.0.5 - htmlencode: 0.0.4 - lowdb: 1.0.0 - lru-cache: 10.4.3 - mkdirp: 3.0.1 - morgan: 1.10.1 - postgres: 3.4.8 - request: 2.88.2 - request-promise: 4.2.6(request@2.88.2) - sanitize-html: 2.17.0 - transitivePeerDependencies: - - supports-color - mdurl@2.0.0: {} media-typer@0.3.0: {} From 1e7cb23f00f4232bd8ac31c87a1a6b8881c62d1a Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 15:38:35 -0600 Subject: [PATCH 28/43] Fix: avoid plugin registration on global help/version (#2212) (thanks @dial481) --- CHANGELOG.md | 1 + README.md | 4 +++- src/cli/run-main.ts | 11 ++++++++++- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d6e6040a..7c19ab947 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Status: unreleased. - Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez. - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. - Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. +- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. - Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. diff --git a/README.md b/README.md index e72fe7e16..2fdb6414a 100644 --- a/README.md +++ b/README.md @@ -490,7 +490,8 @@ Thanks to all clawtributors: travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase uos-status gerardward2007 roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee robbyczgw-cla dlauer Josh Phillips YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 - antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi + antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr dial481 HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi + mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh @@ -509,4 +510,5 @@ Thanks to all clawtributors: ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock +

diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index b24e5b456..bb029ae31 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import { formatUncaughtError } from "../infra/errors.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { enableConsoleCapture } from "../logging.js"; -import { getPrimaryCommand } from "./argv.js"; +import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js"; import { tryRouteCli } from "./route.js"; export function rewriteUpdateFlagArgv(argv: string[]): string[] { @@ -56,6 +56,15 @@ export async function runCli(argv: string[] = process.argv) { const { registerSubCliByName } = await import("./program/register.subclis.js"); await registerSubCliByName(program, primary); } + + const shouldSkipPluginRegistration = !primary && hasHelpOrVersion(parseArgv); + if (!shouldSkipPluginRegistration) { + // Register plugin CLI commands before parsing + const { registerPluginCliCommands } = await import("../plugins/cli.js"); + const { loadConfig } = await import("../config/config.js"); + registerPluginCliCommands(program, loadConfig()); + } + await program.parseAsync(parseArgv); } From 3b8792ee29522431e341064a8e55cedb8fafed1e Mon Sep 17 00:00:00 2001 From: Luka Zhang Date: Mon, 26 Jan 2026 16:51:46 -0800 Subject: [PATCH 29/43] Security: fix timing attack vulnerability in LINE webhook signature validation --- src/line/webhook.test.ts | 37 +++++++++++++++++++++++++++++++++++++ src/line/webhook.ts | 11 ++++++++++- 2 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/line/webhook.test.ts b/src/line/webhook.test.ts index af30040b4..731653a09 100644 --- a/src/line/webhook.test.ts +++ b/src/line/webhook.test.ts @@ -70,4 +70,41 @@ describe("createLineWebhookMiddleware", () => { expect(res.status).toHaveBeenCalledWith(400); expect(onEvents).not.toHaveBeenCalled(); }); + + it("rejects webhooks with invalid signatures", async () => { + const onEvents = vi.fn(async () => {}); + const secret = "secret"; + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + const middleware = createLineWebhookMiddleware({ channelSecret: secret, onEvents }); + + const req = { + headers: { "x-line-signature": "invalid-signature" }, + body: rawBody, + } as any; + const res = createRes(); + + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(401); + expect(onEvents).not.toHaveBeenCalled(); + }); + + it("rejects webhooks with signatures computed using wrong secret", async () => { + const onEvents = vi.fn(async () => {}); + const correctSecret = "correct-secret"; + const wrongSecret = "wrong-secret"; + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + const middleware = createLineWebhookMiddleware({ channelSecret: correctSecret, onEvents }); + + const req = { + headers: { "x-line-signature": sign(rawBody, wrongSecret) }, + body: rawBody, + } as any; + const res = createRes(); + + await middleware(req, res, {} as any); + + expect(res.status).toHaveBeenCalledWith(401); + expect(onEvents).not.toHaveBeenCalled(); + }); }); diff --git a/src/line/webhook.ts b/src/line/webhook.ts index 5f5e12441..846d8d796 100644 --- a/src/line/webhook.ts +++ b/src/line/webhook.ts @@ -12,7 +12,16 @@ export interface LineWebhookOptions { function validateSignature(body: string, signature: string, channelSecret: string): boolean { const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64"); - return hash === signature; + const hashBuffer = Buffer.from(hash); + const signatureBuffer = Buffer.from(signature); + + // Use constant-time comparison to prevent timing attacks + // Ensure buffers are same length before comparison to prevent timing leak + if (hashBuffer.length !== signatureBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(hashBuffer, signatureBuffer); } function readRawBody(req: Request): string | null { From e0dc49f28760fd14f1072bc2b7cce15506eed391 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 19:10:11 -0600 Subject: [PATCH 30/43] line: centralize webhook signature validation --- src/line/monitor.ts | 7 +------ src/line/signature.test.ts | 27 +++++++++++++++++++++++++++ src/line/signature.ts | 18 ++++++++++++++++++ src/line/webhook.ts | 18 ++---------------- 4 files changed, 48 insertions(+), 22 deletions(-) create mode 100644 src/line/signature.test.ts create mode 100644 src/line/signature.ts diff --git a/src/line/monitor.ts b/src/line/monitor.ts index 9b40e4460..c6241d97d 100644 --- a/src/line/monitor.ts +++ b/src/line/monitor.ts @@ -1,10 +1,10 @@ import type { WebhookRequestBody } from "@line/bot-sdk"; import type { IncomingMessage, ServerResponse } from "node:http"; -import crypto from "node:crypto"; import type { ClawdbotConfig } from "../config/config.js"; import { danger, logVerbose } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; import { createLineBot } from "./bot.js"; +import { validateLineSignature } from "./signature.js"; import { normalizePluginHttpPath } from "../plugins/http-path.js"; import { registerPluginHttpRoute } from "../plugins/http-registry.js"; import { @@ -85,11 +85,6 @@ export function getLineRuntimeState(accountId: string) { return runtimeState.get(`line:${accountId}`); } -function validateLineSignature(body: string, signature: string, channelSecret: string): boolean { - const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64"); - return hash === signature; -} - async function readRequestBody(req: IncomingMessage): Promise { return new Promise((resolve, reject) => { const chunks: Buffer[] = []; diff --git a/src/line/signature.test.ts b/src/line/signature.test.ts new file mode 100644 index 000000000..8bd9b1f3f --- /dev/null +++ b/src/line/signature.test.ts @@ -0,0 +1,27 @@ +import crypto from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { validateLineSignature } from "./signature.js"; + +const sign = (body: string, secret: string) => + crypto.createHmac("SHA256", secret).update(body).digest("base64"); + +describe("validateLineSignature", () => { + it("accepts valid signatures", () => { + const secret = "secret"; + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + + expect(validateLineSignature(rawBody, sign(rawBody, secret), secret)).toBe(true); + }); + + it("rejects signatures computed with the wrong secret", () => { + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + + expect(validateLineSignature(rawBody, sign(rawBody, "wrong-secret"), "secret")).toBe(false); + }); + + it("rejects signatures with a different length", () => { + const rawBody = JSON.stringify({ events: [{ type: "message" }] }); + + expect(validateLineSignature(rawBody, "short", "secret")).toBe(false); + }); +}); diff --git a/src/line/signature.ts b/src/line/signature.ts new file mode 100644 index 000000000..771a950ff --- /dev/null +++ b/src/line/signature.ts @@ -0,0 +1,18 @@ +import crypto from "node:crypto"; + +export function validateLineSignature( + body: string, + signature: string, + channelSecret: string, +): boolean { + const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64"); + const hashBuffer = Buffer.from(hash); + const signatureBuffer = Buffer.from(signature); + + // Use constant-time comparison to prevent timing attacks. + if (hashBuffer.length !== signatureBuffer.length) { + return false; + } + + return crypto.timingSafeEqual(hashBuffer, signatureBuffer); +} diff --git a/src/line/webhook.ts b/src/line/webhook.ts index 846d8d796..9986617f9 100644 --- a/src/line/webhook.ts +++ b/src/line/webhook.ts @@ -1,8 +1,8 @@ import type { Request, Response, NextFunction } from "express"; -import crypto from "node:crypto"; import type { WebhookRequestBody } from "@line/bot-sdk"; import { logVerbose, danger } from "../globals.js"; import type { RuntimeEnv } from "../runtime.js"; +import { validateLineSignature } from "./signature.js"; export interface LineWebhookOptions { channelSecret: string; @@ -10,20 +10,6 @@ export interface LineWebhookOptions { runtime?: RuntimeEnv; } -function validateSignature(body: string, signature: string, channelSecret: string): boolean { - const hash = crypto.createHmac("SHA256", channelSecret).update(body).digest("base64"); - const hashBuffer = Buffer.from(hash); - const signatureBuffer = Buffer.from(signature); - - // Use constant-time comparison to prevent timing attacks - // Ensure buffers are same length before comparison to prevent timing leak - if (hashBuffer.length !== signatureBuffer.length) { - return false; - } - - return crypto.timingSafeEqual(hashBuffer, signatureBuffer); -} - function readRawBody(req: Request): string | null { const rawBody = (req as { rawBody?: string | Buffer }).rawBody ?? @@ -61,7 +47,7 @@ export function createLineWebhookMiddleware(options: LineWebhookOptions) { return; } - if (!validateSignature(rawBody, signature, channelSecret)) { + if (!validateLineSignature(rawBody, signature, channelSecret)) { logVerbose("line: webhook signature validation failed"); res.status(401).json({ error: "Invalid signature" }); return; From 58b96ca0c0943a377fc866264e4664d70c7b6d6d Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 19:21:17 -0600 Subject: [PATCH 31/43] CI: sync labels on PR updates --- .github/workflows/labeler.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index 8d078774b..2b2f80130 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -21,3 +21,4 @@ jobs: with: configuration-path: .github/labeler.yml repo-token: ${{ steps.app-token.outputs.token }} + sync-labels: true From c95072fc26c9780a8debace8e7d45df0a22720e4 Mon Sep 17 00:00:00 2001 From: David Marsh Date: Mon, 26 Jan 2026 15:29:32 -0800 Subject: [PATCH 32/43] fix: support versioned node binaries (e.g., node-22) Fedora and some other distros install Node.js with a version suffix (e.g., /usr/bin/node-22) and create a symlink from /usr/bin/node. When Node resolves process.execPath, it returns the real binary path, not the symlink, causing buildParseArgv to fail the looksLikeNode check. This adds executable.startsWith('node-') to handle versioned binaries. Fixes #2442 --- src/cli/argv.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/cli/argv.ts b/src/cli/argv.ts index bc7b60ac9..e48d9f91d 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -99,6 +99,7 @@ export function buildParseArgv(params: { normalizedArgv.length >= 2 && (executable === "node" || executable === "node.exe" || + executable.startsWith("node-") || executable === "bun" || executable === "bun.exe"); if (looksLikeNode) return normalizedArgv; From 566c9982b39c1929e0670bf6417df483f4bb773f Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 26 Jan 2026 20:29:15 -0500 Subject: [PATCH 33/43] CLI: expand versioned node argv handling --- src/cli/argv.test.ts | 42 ++++++++++++++++++++++++++++++++++++++++++ src/cli/argv.ts | 23 +++++++++++++++++------ 2 files changed, 59 insertions(+), 6 deletions(-) diff --git a/src/cli/argv.test.ts b/src/cli/argv.test.ts index 244e72241..54b93fcc7 100644 --- a/src/cli/argv.test.ts +++ b/src/cli/argv.test.ts @@ -78,6 +78,48 @@ describe("argv helpers", () => { }); expect(nodeArgv).toEqual(["node", "clawdbot", "status"]); + const versionedNodeArgv = buildParseArgv({ + programName: "clawdbot", + rawArgs: ["node-22", "clawdbot", "status"], + }); + expect(versionedNodeArgv).toEqual(["node-22", "clawdbot", "status"]); + + const versionedNodeWindowsArgv = buildParseArgv({ + programName: "clawdbot", + rawArgs: ["node-22.2.0.exe", "clawdbot", "status"], + }); + expect(versionedNodeWindowsArgv).toEqual(["node-22.2.0.exe", "clawdbot", "status"]); + + const versionedNodePatchlessArgv = buildParseArgv({ + programName: "clawdbot", + rawArgs: ["node-22.2", "clawdbot", "status"], + }); + expect(versionedNodePatchlessArgv).toEqual(["node-22.2", "clawdbot", "status"]); + + const versionedNodeWindowsPatchlessArgv = buildParseArgv({ + programName: "clawdbot", + rawArgs: ["node-22.2.exe", "clawdbot", "status"], + }); + expect(versionedNodeWindowsPatchlessArgv).toEqual(["node-22.2.exe", "clawdbot", "status"]); + + const versionedNodeWithPathArgv = buildParseArgv({ + programName: "clawdbot", + rawArgs: ["/usr/bin/node-22.2.0", "clawdbot", "status"], + }); + expect(versionedNodeWithPathArgv).toEqual(["/usr/bin/node-22.2.0", "clawdbot", "status"]); + + const nodejsArgv = buildParseArgv({ + programName: "clawdbot", + rawArgs: ["nodejs", "clawdbot", "status"], + }); + expect(nodejsArgv).toEqual(["nodejs", "clawdbot", "status"]); + + const nonVersionedNodeArgv = buildParseArgv({ + programName: "clawdbot", + rawArgs: ["node-dev", "clawdbot", "status"], + }); + expect(nonVersionedNodeArgv).toEqual(["node", "clawdbot", "node-dev", "clawdbot", "status"]); + const directArgv = buildParseArgv({ programName: "clawdbot", rawArgs: ["clawdbot", "status"], diff --git a/src/cli/argv.ts b/src/cli/argv.ts index e48d9f91d..4b403c92e 100644 --- a/src/cli/argv.ts +++ b/src/cli/argv.ts @@ -96,16 +96,27 @@ export function buildParseArgv(params: { : baseArgv; const executable = (normalizedArgv[0]?.split(/[/\\]/).pop() ?? "").toLowerCase(); const looksLikeNode = - normalizedArgv.length >= 2 && - (executable === "node" || - executable === "node.exe" || - executable.startsWith("node-") || - executable === "bun" || - executable === "bun.exe"); + normalizedArgv.length >= 2 && (isNodeExecutable(executable) || isBunExecutable(executable)); if (looksLikeNode) return normalizedArgv; return ["node", programName || "clawdbot", ...normalizedArgv]; } +const nodeExecutablePattern = /^node-\d+(?:\.\d+)*(?:\.exe)?$/; + +function isNodeExecutable(executable: string): boolean { + return ( + executable === "node" || + executable === "node.exe" || + executable === "nodejs" || + executable === "nodejs.exe" || + nodeExecutablePattern.test(executable) + ); +} + +function isBunExecutable(executable: string): boolean { + return executable === "bun" || executable === "bun.exe"; +} + export function shouldMigrateStateFromPath(path: string[]): boolean { if (path.length === 0) return true; const [primary, secondary] = path; From 2f7fff8dcdaf4c88eb2c5b7d70ed73bf5500f4d0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 26 Jan 2026 20:29:37 -0500 Subject: [PATCH 34/43] CLI: add changelog for versioned node argv (#2490) (thanks @David-Marsh-Photo) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c19ab947..66c0543fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -56,6 +56,7 @@ Status: unreleased. ### Fixes - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. - Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez. +- CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo. - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. - Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481. From 27174f5d8279e907bbb3d9952ddd7bab487b5f9d Mon Sep 17 00:00:00 2001 From: Yuan Chen Date: Mon, 26 Jan 2026 20:39:10 -0500 Subject: [PATCH 35/43] =?UTF-8?q?bugfix:The=20Mintlify=20navbar=20(logo=20?= =?UTF-8?q?+=20search=20bar=20with=20=E2=8C=98K)=20scrolls=20away=20w?= =?UTF-8?q?=E2=80=A6=20(#2445)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bugfix:The Mintlify navbar (logo + search bar with ⌘K) scrolls away when scrolling down the documentation, so it disappears from view. * fix(docs): keep navbar visible on scroll (#2445) (thanks @chenyuan99) --------- Co-authored-by: vignesh07 --- CHANGELOG.md | 1 + docs/assets/terminal.css | 3 +++ docs/install/node.md | 7 ++++--- src/docs/terminal-css.test.ts | 28 ++++++++++++++++++++++++++++ 4 files changed, 36 insertions(+), 3 deletions(-) create mode 100644 src/docs/terminal-css.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 66c0543fe..ed99095aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ Status: unreleased. - Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra. - Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon. - Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco. +- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99. - Security: use Windows ACLs for permission audits and fixes on Windows. (#1957) - Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla. - Routing: precompile session key regexes. (#1697) Thanks @Ray0907. diff --git a/docs/assets/terminal.css b/docs/assets/terminal.css index 23283d651..e5e51af9e 100644 --- a/docs/assets/terminal.css +++ b/docs/assets/terminal.css @@ -115,6 +115,9 @@ body::after { } .shell { + position: sticky; + top: 0; + z-index: 100; padding: 22px 16px 10px; } diff --git a/docs/install/node.md b/docs/install/node.md index 6a622e198..3075b6207 100644 --- a/docs/install/node.md +++ b/docs/install/node.md @@ -1,9 +1,10 @@ --- +title: "Node.js + npm (PATH sanity)" summary: "Node.js + npm install sanity: versions, PATH, and global installs" read_when: - - You installed Clawdbot but `clawdbot` is “command not found” - - You’re setting up Node.js/npm on a new machine - - `npm install -g ...` fails with permissions or PATH issues + - "You installed Clawdbot but `clawdbot` is “command not found”" + - "You’re setting up Node.js/npm on a new machine" + - "npm install -g ... fails with permissions or PATH issues" --- # Node.js + npm (PATH sanity) diff --git a/src/docs/terminal-css.test.ts b/src/docs/terminal-css.test.ts new file mode 100644 index 000000000..838d387a3 --- /dev/null +++ b/src/docs/terminal-css.test.ts @@ -0,0 +1,28 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, expect, test } from "vitest"; + +function readTerminalCss() { + // This test is intentionally simple: it guards against regressions where the + // docs header stops being sticky because sticky elements live inside an + // overflow-clipped container. + const path = join(process.cwd(), "docs", "assets", "terminal.css"); + return readFileSync(path, "utf8"); +} + +describe("docs terminal.css", () => { + test("keeps the docs header sticky (shell is sticky)", () => { + const css = readTerminalCss(); + expect(css).toMatch(/\.shell\s*\{[^}]*position:\s*sticky;[^}]*top:\s*0;[^}]*\}/s); + }); + + test("does not rely on making body overflow visible", () => { + const css = readTerminalCss(); + expect(css).not.toMatch(/body\s*\{[^}]*overflow-x:\s*visible;[^}]*\}/s); + }); + + test("does not make the terminal frame overflow visible (can break layout)", () => { + const css = readTerminalCss(); + expect(css).not.toMatch(/\.shell__frame\s*\{[^}]*overflow:\s*visible;[^}]*\}/s); + }); +}); From 14f8acdecbc7c6b5d1275d37cbab35199d7972a1 Mon Sep 17 00:00:00 2001 From: Jane Date: Tue, 27 Jan 2026 01:06:16 +0000 Subject: [PATCH 36/43] fix(agents): release session locks on process termination Adds process exit handlers to release all held session locks on: - Normal process.exit() calls - SIGTERM / SIGINT signals This ensures locks are cleaned up even when the process terminates unexpectedly, preventing the 'session file locked' error. --- src/agents/session-write-lock.ts | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 54e61d965..bd4dd5038 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import fsSync from "node:fs"; import path from "node:path"; type LockFilePayload = { @@ -116,3 +117,44 @@ export async function acquireSessionWriteLock(params: { const owner = payload?.pid ? `pid=${payload.pid}` : "unknown"; throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`); } + +/** + * Synchronously release all held locks. + * Used during process exit when async operations aren't reliable. + */ +function releaseAllLocksSync(): void { + for (const [sessionFile, held] of HELD_LOCKS) { + try { + fsSync.rmSync(held.lockPath, { force: true }); + } catch { + // Ignore errors during cleanup - best effort + } + HELD_LOCKS.delete(sessionFile); + } +} + +let cleanupRegistered = false; + +function registerCleanupHandlers(): void { + if (cleanupRegistered) return; + cleanupRegistered = true; + + // Cleanup on normal exit and process.exit() calls + process.on("exit", () => { + releaseAllLocksSync(); + }); + + // Handle SIGINT (Ctrl+C) and SIGTERM + const handleSignal = (signal: NodeJS.Signals) => { + releaseAllLocksSync(); + // Remove our handler and re-raise signal for proper exit code + process.removeAllListeners(signal); + process.kill(process.pid, signal); + }; + + process.on("SIGINT", () => handleSignal("SIGINT")); + process.on("SIGTERM", () => handleSignal("SIGTERM")); +} + +// Register cleanup handlers when module loads +registerCleanupHandlers(); From d8e5dd91bada06e653afc82ce9af77f1c5935cc8 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 19:48:46 -0600 Subject: [PATCH 37/43] fix: clean up session locks on exit (#2483) (thanks @janeexai) --- CHANGELOG.md | 1 + README.md | 56 ++++++++--------- src/agents/session-write-lock.test.ts | 90 +++++++++++++++++++++++++++ src/agents/session-write-lock.ts | 17 +++-- 4 files changed, 131 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed99095aa..74ea7235c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Status: unreleased. - Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. +- Agents: release session locks on process termination. (#2483) Thanks @janeexai. - Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. diff --git a/README.md b/README.md index 2fdb6414a..a5daba163 100644 --- a/README.md +++ b/README.md @@ -479,36 +479,34 @@ Thanks to all clawtributors:

steipete plum-dawg bohdanpodvirnyi iHildy jaydenfyi joaohlisboa mneves75 MatthieuBizien MaudeBot Glucksberg rahthakor vrknetha radek-paclt Tobias Bischoff joshp123 czekaj mukhtharcm sebslight maxsumrall xadenryan - rodrigouroz juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 patelhiren NicholasSpisak jonisjongithub - abhisekbasu1 jamesgroat claude JustYannicc vignesh07 Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] + rodrigouroz juanpablodlc hsrvc magimetal zerone0x meaningfool tyler6204 vignesh07 patelhiren NicholasSpisak + jonisjongithub abhisekbasu1 jamesgroat claude JustYannicc Hyaxia dantelex SocialNerd42069 daveonkels google-labs-jules[bot] lc0rp mousberg mteam88 hirefrank joeynyc orlyjamie dbhurley Mariano Belinky Eng. Juan Combetto TSavo julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan - davidguttman sleontenko denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 - danielz1z AdeboyeDN emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 - CashWilliams sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc - travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase uos-status - gerardward2007 roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee robbyczgw-cla dlauer Josh Phillips - YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 - antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr dial481 HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi - - mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server - Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) - Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh - svkozak VACInc wes-davis zats 24601 adam91holt ameno- Chris Taylor dguido Django Navarro - evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account rmorse Syhids Aaron Konyer - aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx EnzeD erik-agens Evizero fcatuhe - itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell - odnxe p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite T5-AndyML travisp VAC william arzt - zknicker abhaymundhara alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier arthyn Asleep123 bolismauro - Clawdbot Maintainers conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim - grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn kentaro Kevin Lin - kitze levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn - MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe - Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke - Suksham-sharma testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai - ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik - hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani - William Stock - + davidguttman sleontenko denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r dominicnunez ratulsarna + lutr0 danielz1z AdeboyeDN emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz + adityashaw2 CashWilliams sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam + myfunc travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase + uos-status gerardward2007 roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee robbyczgw-cla dlauer + Josh Phillips YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib + grp06 antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic + kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose + L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig + Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain + suminhthanh svkozak VACInc wes-davis zats 24601 adam91holt ameno- Chris Taylor dguido + Django Navarro evalexpr henrino3 humanwritten larlyssa odysseus0 oswalpalash pcty-nextgen-service-account rmorse Syhids + Aaron Konyer aaronveklabs andreabadesso Andrii cash-echo-bot Clawd ClawdFx EnzeD erik-agens Evizero + fcatuhe itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan + mjrussell odnxe p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite T5-AndyML travisp VAC + william arzt zknicker abhaymundhara alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier arthyn Asleep123 + bolismauro chenyuan99 Clawdbot Maintainers conhecendoia dasilva333 David-Marsh-Photo Developer Dimitrios Ploutarchos Drake Thomsen fal3 + Felix Krause foeken ganghyun kim grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis + Jefferson Nunn kentaro Kevin Lin kitze Kiwitwitter levifig Lloyd loukotal louzhixian martinpucik + Matt mini mertcicekci0 Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment + prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical + shiv19 shiyuanhai siraht snopoke Suksham-sharma techboss testingabc321 The Admiral thesash Ubuntu + voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee + atalovesyou Azade carlulsoe ddyo Erik hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik + pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock

diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts index 8f93bface..8eafd6bf4 100644 --- a/src/agents/session-write-lock.test.ts +++ b/src/agents/session-write-lock.test.ts @@ -31,4 +31,94 @@ describe("acquireSessionWriteLock", () => { await fs.rm(root, { recursive: true, force: true }); } }); + + it("keeps the lock file until the last release", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-")); + try { + const sessionFile = path.join(root, "sessions.json"); + const lockPath = `${sessionFile}.lock`; + + const lockA = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); + const lockB = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); + + await expect(fs.access(lockPath)).resolves.toBeUndefined(); + await lockA.release(); + await expect(fs.access(lockPath)).resolves.toBeUndefined(); + await lockB.release(); + await expect(fs.access(lockPath)).rejects.toThrow(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("reclaims stale lock files", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-")); + try { + const sessionFile = path.join(root, "sessions.json"); + const lockPath = `${sessionFile}.lock`; + await fs.writeFile( + lockPath, + JSON.stringify({ pid: 123456, createdAt: new Date(Date.now() - 60_000).toISOString() }), + "utf8", + ); + + const lock = await acquireSessionWriteLock({ sessionFile, timeoutMs: 500, staleMs: 10 }); + const raw = await fs.readFile(lockPath, "utf8"); + const payload = JSON.parse(raw) as { pid: number }; + + expect(payload.pid).toBe(process.pid); + await lock.release(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("cleans up locks on SIGINT without removing other handlers", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-")); + const originalKill = process.kill; + const killCalls: Array = []; + let otherHandlerCalled = false; + + process.kill = ((pid: number, signal?: NodeJS.Signals) => { + killCalls.push(signal); + return true; + }) as typeof process.kill; + + const otherHandler = () => { + otherHandlerCalled = true; + }; + + process.on("SIGINT", otherHandler); + + try { + const sessionFile = path.join(root, "sessions.json"); + const lockPath = `${sessionFile}.lock`; + await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); + + process.emit("SIGINT"); + + await expect(fs.access(lockPath)).rejects.toThrow(); + expect(otherHandlerCalled).toBe(true); + expect(killCalls).toEqual(["SIGINT"]); + } finally { + process.off("SIGINT", otherHandler); + process.kill = originalKill; + await fs.rm(root, { recursive: true, force: true }); + } + }); + + it("cleans up locks on exit", async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-")); + try { + const sessionFile = path.join(root, "sessions.json"); + const lockPath = `${sessionFile}.lock`; + await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); + + process.emit("exit", 0); + + await expect(fs.access(lockPath)).rejects.toThrow(); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + }); }); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index bd4dd5038..d7499eb2a 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -124,6 +124,11 @@ export async function acquireSessionWriteLock(params: { */ function releaseAllLocksSync(): void { for (const [sessionFile, held] of HELD_LOCKS) { + try { + fsSync.closeSync(held.handle.fd); + } catch { + // Ignore close errors during cleanup - best effort + } try { fsSync.rmSync(held.lockPath, { force: true }); } catch { @@ -147,13 +152,17 @@ function registerCleanupHandlers(): void { // Handle SIGINT (Ctrl+C) and SIGTERM const handleSignal = (signal: NodeJS.Signals) => { releaseAllLocksSync(); - // Remove our handler and re-raise signal for proper exit code - process.removeAllListeners(signal); + // Remove only our handlers and re-raise signal for proper exit code. + process.off("SIGINT", onSigInt); + process.off("SIGTERM", onSigTerm); process.kill(process.pid, signal); }; - process.on("SIGINT", () => handleSignal("SIGINT")); - process.on("SIGTERM", () => handleSignal("SIGTERM")); + const onSigInt = () => handleSignal("SIGINT"); + const onSigTerm = () => handleSignal("SIGTERM"); + + process.on("SIGINT", onSigInt); + process.on("SIGTERM", onSigTerm); } // Register cleanup handlers when module loads From 481bd333eb75c84493b68911b8ff1b43b650ea3a Mon Sep 17 00:00:00 2001 From: Glucksberg <80581902+Glucksberg@users.noreply.github.com> Date: Mon, 26 Jan 2026 21:51:53 -0400 Subject: [PATCH 38/43] fix(gateway): gracefully handle AbortError and transient network errors (#2451) * fix(tts): generate audio when block streaming drops final reply When block streaming succeeds, final replies are dropped but TTS was only applied to final replies. Fix by accumulating block text during streaming and generating TTS-only audio after streaming completes. Also: - Change truncate vs skip behavior when summary OFF (now truncates) - Align TTS limits with Telegram max (4096 chars) - Improve /tts command help messages with examples - Add newline separator between accumulated blocks * fix(tts): add error handling for accumulated block TTS * feat(tts): add descriptive inline menu with action descriptions - Add value/label support for command arg choices - TTS menu now shows descriptive title listing each action - Capitalize button labels (On, Off, Status, etc.) - Update Telegram, Discord, and Slack handlers to use labels Co-Authored-By: Claude Opus 4.5 * fix(gateway): gracefully handle AbortError and transient network errors Addresses issues #1851, #1997, and #2034. During config reload (SIGUSR1), in-flight requests are aborted, causing AbortError exceptions. Similarly, transient network errors (fetch failed, ECONNRESET, ETIMEDOUT, etc.) can crash the gateway unnecessarily. This change: - Adds isAbortError() to detect intentional cancellations - Adds isTransientNetworkError() to detect temporary connectivity issues - Logs these errors appropriately instead of crashing - Handles nested cause chains and AggregateError AbortError is logged as a warning (expected during shutdown). Network errors are logged as non-fatal errors (will resolve on their own). Co-Authored-By: Claude Opus 4.5 * fix(test): update commands-registry test expectations Update test expectations to match new ResolvedCommandArgChoice format (choices now return {label, value} objects instead of plain strings). Co-Authored-By: Claude Opus 4.5 * fix: harden unhandled rejection handling and tts menus (#2451) (thanks @Glucksberg) --------- Co-authored-by: Claude Opus 4.5 Co-authored-by: Shadow --- CHANGELOG.md | 2 + src/auto-reply/commands-registry.data.ts | 39 +++++- src/auto-reply/commands-registry.test.ts | 12 +- src/auto-reply/commands-registry.ts | 32 +++-- src/auto-reply/commands-registry.types.ts | 6 +- src/auto-reply/reply/commands-tts.ts | 135 +++++++++---------- src/auto-reply/reply/commands.test.ts | 14 ++ src/auto-reply/reply/dispatch-from-config.ts | 72 +++++++++- src/discord/monitor/native-command.ts | 18 ++- src/infra/unhandled-rejections.test.ts | 129 ++++++++++++++++++ src/infra/unhandled-rejections.ts | 123 ++++++++++++----- src/slack/monitor/slash.ts | 6 +- src/telegram/bot-native-commands.ts | 4 +- src/tts/tts.ts | 54 ++++---- 14 files changed, 487 insertions(+), 159 deletions(-) create mode 100644 src/infra/unhandled-rejections.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 74ea7235c..d1a29c4d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -55,6 +55,8 @@ Status: unreleased. - **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed). ### Fixes +- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg. +- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg. - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. - Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez. - CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo. diff --git a/src/auto-reply/commands-registry.data.ts b/src/auto-reply/commands-registry.data.ts index 12fec300b..5ba6826fe 100644 --- a/src/auto-reply/commands-registry.data.ts +++ b/src/auto-reply/commands-registry.data.ts @@ -181,9 +181,44 @@ function buildChatCommands(): ChatCommandDefinition[] { defineChatCommand({ key: "tts", nativeName: "tts", - description: "Configure text-to-speech.", + description: "Control text-to-speech (TTS).", textAlias: "/tts", - acceptsArgs: true, + args: [ + { + name: "action", + description: "TTS action", + type: "string", + choices: [ + { value: "on", label: "On" }, + { value: "off", label: "Off" }, + { value: "status", label: "Status" }, + { value: "provider", label: "Provider" }, + { value: "limit", label: "Limit" }, + { value: "summary", label: "Summary" }, + { value: "audio", label: "Audio" }, + { value: "help", label: "Help" }, + ], + }, + { + name: "value", + description: "Provider, limit, or text", + type: "string", + captureRemaining: true, + }, + ], + argsMenu: { + arg: "action", + title: + "TTS Actions:\n" + + "• On – Enable TTS for responses\n" + + "• Off – Disable TTS\n" + + "• Status – Show current settings\n" + + "• Provider – Set voice provider (edge, elevenlabs, openai)\n" + + "• Limit – Set max characters for TTS\n" + + "• Summary – Toggle AI summary for long texts\n" + + "• Audio – Generate TTS from custom text\n" + + "• Help – Show usage guide", + }, }), defineChatCommand({ key: "whoami", diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 6a6efbced..69f3ac1ae 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -229,7 +229,12 @@ describe("commands registry args", () => { const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); expect(menu?.arg.name).toBe("mode"); - expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]); + expect(menu?.choices).toEqual([ + { label: "off", value: "off" }, + { label: "tokens", value: "tokens" }, + { label: "full", value: "full" }, + { label: "cost", value: "cost" }, + ]); }); it("does not show menus when arg already provided", () => { @@ -284,7 +289,10 @@ describe("commands registry args", () => { const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never }); expect(menu?.arg.name).toBe("level"); - expect(menu?.choices).toEqual(["low", "high"]); + expect(menu?.choices).toEqual([ + { label: "low", value: "low" }, + { label: "high", value: "high" }, + ]); expect(seen?.commandKey).toBe("think"); expect(seen?.argName).toBe("level"); expect(seen?.provider).toBeTruthy(); diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts index 5bca565f0..f772ac7fc 100644 --- a/src/auto-reply/commands-registry.ts +++ b/src/auto-reply/commands-registry.ts @@ -255,33 +255,41 @@ function resolveDefaultCommandContext(cfg?: ClawdbotConfig): { }; } +export type ResolvedCommandArgChoice = { value: string; label: string }; + export function resolveCommandArgChoices(params: { command: ChatCommandDefinition; arg: CommandArgDefinition; cfg?: ClawdbotConfig; provider?: string; model?: string; -}): string[] { +}): ResolvedCommandArgChoice[] { const { command, arg, cfg } = params; if (!arg.choices) return []; const provided = arg.choices; - if (Array.isArray(provided)) return provided; - const defaults = resolveDefaultCommandContext(cfg); - const context: CommandArgChoiceContext = { - cfg, - provider: params.provider ?? defaults.provider, - model: params.model ?? defaults.model, - command, - arg, - }; - return provided(context); + const raw = Array.isArray(provided) + ? provided + : (() => { + const defaults = resolveDefaultCommandContext(cfg); + const context: CommandArgChoiceContext = { + cfg, + provider: params.provider ?? defaults.provider, + model: params.model ?? defaults.model, + command, + arg, + }; + return provided(context); + })(); + return raw.map((choice) => + typeof choice === "string" ? { value: choice, label: choice } : choice, + ); } export function resolveCommandArgMenu(params: { command: ChatCommandDefinition; args?: CommandArgs; cfg?: ClawdbotConfig; -}): { arg: CommandArgDefinition; choices: string[]; title?: string } | null { +}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null { const { command, args, cfg } = params; if (!command.args || !command.argsMenu) return null; if (command.argsParsing === "none") return null; diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts index c19c9d9a7..5e5bdd8cb 100644 --- a/src/auto-reply/commands-registry.types.ts +++ b/src/auto-reply/commands-registry.types.ts @@ -12,14 +12,16 @@ export type CommandArgChoiceContext = { arg: CommandArgDefinition; }; -export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => string[]; +export type CommandArgChoice = string | { value: string; label: string }; + +export type CommandArgChoicesProvider = (context: CommandArgChoiceContext) => CommandArgChoice[]; export type CommandArgDefinition = { name: string; description: string; type: CommandArgType; required?: boolean; - choices?: string[] | CommandArgChoicesProvider; + choices?: CommandArgChoice[] | CommandArgChoicesProvider; captureRemaining?: boolean; }; diff --git a/src/auto-reply/reply/commands-tts.ts b/src/auto-reply/reply/commands-tts.ts index 5c65fb94c..04b60a4e9 100644 --- a/src/auto-reply/reply/commands-tts.ts +++ b/src/auto-reply/reply/commands-tts.ts @@ -6,20 +6,18 @@ import { getTtsMaxLength, getTtsProvider, isSummarizationEnabled, + isTtsEnabled, isTtsProviderConfigured, - normalizeTtsAutoMode, - resolveTtsAutoMode, resolveTtsApiKey, resolveTtsConfig, resolveTtsPrefsPath, - resolveTtsProviderOrder, setLastTtsAttempt, setSummarizationEnabled, + setTtsEnabled, setTtsMaxLength, setTtsProvider, textToSpeech, } from "../../tts/tts.js"; -import { updateSessionStore } from "../../config/sessions.js"; type ParsedTtsCommand = { action: string; @@ -40,14 +38,27 @@ function ttsUsage(): ReplyPayload { // Keep usage in one place so help/validation stays consistent. return { text: - "⚙️ Usage: /tts [value]" + - "\nExamples:\n" + - "/tts always\n" + - "/tts provider openai\n" + - "/tts provider edge\n" + - "/tts limit 2000\n" + - "/tts summary off\n" + - "/tts audio Hello from Clawdbot", + `🔊 **TTS (Text-to-Speech) Help**\n\n` + + `**Commands:**\n` + + `• /tts on — Enable automatic TTS for replies\n` + + `• /tts off — Disable TTS\n` + + `• /tts status — Show current settings\n` + + `• /tts provider [name] — View/change provider\n` + + `• /tts limit [number] — View/change text limit\n` + + `• /tts summary [on|off] — View/change auto-summary\n` + + `• /tts audio — Generate audio from text\n\n` + + `**Providers:**\n` + + `• edge — Free, fast (default)\n` + + `• openai — High quality (requires API key)\n` + + `• elevenlabs — Premium voices (requires API key)\n\n` + + `**Text Limit (default: 1500, max: 4096):**\n` + + `When text exceeds the limit:\n` + + `• Summary ON: AI summarizes, then generates audio\n` + + `• Summary OFF: Truncates text, then generates audio\n\n` + + `**Examples:**\n` + + `/tts provider edge\n` + + `/tts limit 2000\n` + + `/tts audio Hello, this is a test!`, }; } @@ -72,35 +83,27 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand return { shouldContinue: false, reply: ttsUsage() }; } - const requestedAuto = normalizeTtsAutoMode( - action === "on" ? "always" : action === "off" ? "off" : action, - ); - if (requestedAuto) { - const entry = params.sessionEntry; - const sessionKey = params.sessionKey; - const store = params.sessionStore; - if (entry && store && sessionKey) { - entry.ttsAuto = requestedAuto; - entry.updatedAt = Date.now(); - store[sessionKey] = entry; - if (params.storePath) { - await updateSessionStore(params.storePath, (store) => { - store[sessionKey] = entry; - }); - } - } - const label = requestedAuto === "always" ? "enabled (always)" : requestedAuto; - return { - shouldContinue: false, - reply: { - text: requestedAuto === "off" ? "🔇 TTS disabled." : `🔊 TTS ${label}.`, - }, - }; + if (action === "on") { + setTtsEnabled(prefsPath, true); + return { shouldContinue: false, reply: { text: "🔊 TTS enabled." } }; + } + + if (action === "off") { + setTtsEnabled(prefsPath, false); + return { shouldContinue: false, reply: { text: "🔇 TTS disabled." } }; } if (action === "audio") { if (!args.trim()) { - return { shouldContinue: false, reply: ttsUsage() }; + return { + shouldContinue: false, + reply: { + text: + `🎤 Generate audio from text.\n\n` + + `Usage: /tts audio \n` + + `Example: /tts audio Hello, this is a test!`, + }, + }; } const start = Date.now(); @@ -146,9 +149,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand if (action === "provider") { const currentProvider = getTtsProvider(config, prefsPath); if (!args.trim()) { - const fallback = resolveTtsProviderOrder(currentProvider) - .slice(1) - .filter((provider) => isTtsProviderConfigured(config, provider)); const hasOpenAI = Boolean(resolveTtsApiKey(config, "openai")); const hasElevenLabs = Boolean(resolveTtsApiKey(config, "elevenlabs")); const hasEdge = isTtsProviderConfigured(config, "edge"); @@ -158,7 +158,6 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand text: `🎙️ TTS provider\n` + `Primary: ${currentProvider}\n` + - `Fallbacks: ${fallback.join(", ") || "none"}\n` + `OpenAI key: ${hasOpenAI ? "✅" : "❌"}\n` + `ElevenLabs key: ${hasElevenLabs ? "✅" : "❌"}\n` + `Edge enabled: ${hasEdge ? "✅" : "❌"}\n` + @@ -173,18 +172,9 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand } setTtsProvider(prefsPath, requested); - const fallback = resolveTtsProviderOrder(requested) - .slice(1) - .filter((provider) => isTtsProviderConfigured(config, provider)); return { shouldContinue: false, - reply: { - text: - `✅ TTS provider set to ${requested} (fallbacks: ${fallback.join(", ") || "none"}).` + - (requested === "edge" - ? "\nEnable Edge TTS in config: messages.tts.edge.enabled = true." - : ""), - }, + reply: { text: `✅ TTS provider set to ${requested}.` }, }; } @@ -193,12 +183,22 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand const currentLimit = getTtsMaxLength(prefsPath); return { shouldContinue: false, - reply: { text: `📏 TTS limit: ${currentLimit} characters.` }, + reply: { + text: + `📏 TTS limit: ${currentLimit} characters.\n\n` + + `Text longer than this triggers summary (if enabled).\n` + + `Range: 100-4096 chars (Telegram max).\n\n` + + `To change: /tts limit \n` + + `Example: /tts limit 2000`, + }, }; } const next = Number.parseInt(args.trim(), 10); - if (!Number.isFinite(next) || next < 100 || next > 10_000) { - return { shouldContinue: false, reply: ttsUsage() }; + if (!Number.isFinite(next) || next < 100 || next > 4096) { + return { + shouldContinue: false, + reply: { text: "❌ Limit must be between 100 and 4096 characters." }, + }; } setTtsMaxLength(prefsPath, next); return { @@ -210,9 +210,17 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand if (action === "summary") { if (!args.trim()) { const enabled = isSummarizationEnabled(prefsPath); + const maxLen = getTtsMaxLength(prefsPath); return { shouldContinue: false, - reply: { text: `📝 TTS auto-summary: ${enabled ? "on" : "off"}.` }, + reply: { + text: + `📝 TTS auto-summary: ${enabled ? "on" : "off"}.\n\n` + + `When text exceeds ${maxLen} chars:\n` + + `• ON: summarizes text, then generates audio\n` + + `• OFF: truncates text, then generates audio\n\n` + + `To change: /tts summary on | off`, + }, }; } const requested = args.trim().toLowerCase(); @@ -229,27 +237,16 @@ export const handleTtsCommands: CommandHandler = async (params, allowTextCommand } if (action === "status") { - const sessionAuto = params.sessionEntry?.ttsAuto; - const autoMode = resolveTtsAutoMode({ config, prefsPath, sessionAuto }); - const enabled = autoMode !== "off"; + const enabled = isTtsEnabled(config, prefsPath); const provider = getTtsProvider(config, prefsPath); const hasKey = isTtsProviderConfigured(config, provider); - const providerStatus = - provider === "edge" - ? hasKey - ? "✅ enabled" - : "❌ disabled" - : hasKey - ? "✅ key" - : "❌ no key"; const maxLength = getTtsMaxLength(prefsPath); const summarize = isSummarizationEnabled(prefsPath); const last = getLastTtsAttempt(); - const autoLabel = sessionAuto ? `${autoMode} (session)` : autoMode; const lines = [ "📊 TTS status", - `Auto: ${enabled ? autoLabel : "off"}`, - `Provider: ${provider} (${providerStatus})`, + `State: ${enabled ? "✅ enabled" : "❌ disabled"}`, + `Provider: ${provider} (${hasKey ? "✅ configured" : "❌ not configured"})`, `Text limit: ${maxLength} chars`, `Auto-summary: ${summarize ? "on" : "off"}`, ]; diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 7078c15dc..fd8236c95 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -420,3 +420,17 @@ describe("handleCommands subagents", () => { expect(result.reply?.text).toContain("Status: done"); }); }); + +describe("handleCommands /tts", () => { + it("returns status for bare /tts on text command surfaces", async () => { + const cfg = { + commands: { text: true }, + channels: { whatsapp: { allowFrom: ["*"] } }, + messages: { tts: { prefsPath: path.join(testWorkspaceDir, "tts.json") } }, + } as ClawdbotConfig; + const params = buildParams("/tts", cfg); + const result = await handleCommands(params); + expect(result.shouldContinue).toBe(false); + expect(result.reply?.text).toContain("TTS status"); + }); +}); diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts index f946c05f9..1dcd770bc 100644 --- a/src/auto-reply/reply/dispatch-from-config.ts +++ b/src/auto-reply/reply/dispatch-from-config.ts @@ -16,7 +16,7 @@ import { formatAbortReplyText, tryFastAbortFromMessage } from "./abort.js"; import { shouldSkipDuplicateInbound } from "./inbound-dedupe.js"; import type { ReplyDispatcher, ReplyDispatchKind } from "./reply-dispatcher.js"; import { isRoutableChannel, routeReply } from "./route-reply.js"; -import { maybeApplyTtsToPayload, normalizeTtsAutoMode } from "../../tts/tts.js"; +import { maybeApplyTtsToPayload, normalizeTtsAutoMode, resolveTtsConfig } from "../../tts/tts.js"; const AUDIO_PLACEHOLDER_RE = /^(\s*\([^)]*\))?$/i; const AUDIO_HEADER_RE = /^\[Audio\b/i; @@ -266,12 +266,26 @@ export async function dispatchReplyFromConfig(params: { return { queuedFinal, counts }; } + // Track accumulated block text for TTS generation after streaming completes. + // When block streaming succeeds, there's no final reply, so we need to generate + // TTS audio separately from the accumulated block content. + let accumulatedBlockText = ""; + let blockCount = 0; + const replyResult = await (params.replyResolver ?? getReplyFromConfig)( ctx, { ...params.replyOptions, onBlockReply: (payload: ReplyPayload, context) => { const run = async () => { + // Accumulate block text for TTS generation after streaming + if (payload.text) { + if (accumulatedBlockText.length > 0) { + accumulatedBlockText += "\n"; + } + accumulatedBlockText += payload.text; + blockCount++; + } const ttsPayload = await maybeApplyTtsToPayload({ payload, cfg, @@ -327,6 +341,62 @@ export async function dispatchReplyFromConfig(params: { queuedFinal = dispatcher.sendFinalReply(ttsReply) || queuedFinal; } } + + const ttsMode = resolveTtsConfig(cfg).mode ?? "final"; + // Generate TTS-only reply after block streaming completes (when there's no final reply). + // This handles the case where block streaming succeeds and drops final payloads, + // but we still want TTS audio to be generated from the accumulated block content. + if ( + ttsMode === "final" && + replies.length === 0 && + blockCount > 0 && + accumulatedBlockText.trim() + ) { + try { + const ttsSyntheticReply = await maybeApplyTtsToPayload({ + payload: { text: accumulatedBlockText }, + cfg, + channel: ttsChannel, + kind: "final", + inboundAudio, + ttsAuto: sessionTtsAuto, + }); + // Only send if TTS was actually applied (mediaUrl exists) + if (ttsSyntheticReply.mediaUrl) { + // Send TTS-only payload (no text, just audio) so it doesn't duplicate the block content + const ttsOnlyPayload: ReplyPayload = { + mediaUrl: ttsSyntheticReply.mediaUrl, + audioAsVoice: ttsSyntheticReply.audioAsVoice, + }; + if (shouldRouteToOriginating && originatingChannel && originatingTo) { + const result = await routeReply({ + payload: ttsOnlyPayload, + channel: originatingChannel, + to: originatingTo, + sessionKey: ctx.SessionKey, + accountId: ctx.AccountId, + threadId: ctx.MessageThreadId, + cfg, + }); + queuedFinal = result.ok || queuedFinal; + if (result.ok) routedFinalCount += 1; + if (!result.ok) { + logVerbose( + `dispatch-from-config: route-reply (tts-only) failed: ${result.error ?? "unknown error"}`, + ); + } + } else { + const didQueue = dispatcher.sendFinalReply(ttsOnlyPayload); + queuedFinal = didQueue || queuedFinal; + } + } + } catch (err) { + logVerbose( + `dispatch-from-config: accumulated block TTS failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + await dispatcher.waitForIdle(); const counts = dispatcher.getQueuedCounts(); diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index 75c9b3b2b..2340da2da 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -93,16 +93,18 @@ function buildDiscordCommandOptions(params: { typeof focused?.value === "string" ? focused.value.trim().toLowerCase() : ""; const choices = resolveCommandArgChoices({ command, arg, cfg }); const filtered = focusValue - ? choices.filter((choice) => choice.toLowerCase().includes(focusValue)) + ? choices.filter((choice) => choice.label.toLowerCase().includes(focusValue)) : choices; await interaction.respond( - filtered.slice(0, 25).map((choice) => ({ name: choice, value: choice })), + filtered.slice(0, 25).map((choice) => ({ name: choice.label, value: choice.value })), ); } : undefined; const choices = resolvedChoices.length > 0 && !autocomplete - ? resolvedChoices.slice(0, 25).map((choice) => ({ name: choice, value: choice })) + ? resolvedChoices + .slice(0, 25) + .map((choice) => ({ name: choice.label, value: choice.value })) : undefined; return { name: arg.name, @@ -351,7 +353,11 @@ export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgC function buildDiscordCommandArgMenu(params: { command: ChatCommandDefinition; - menu: { arg: CommandArgDefinition; choices: string[]; title?: string }; + menu: { + arg: CommandArgDefinition; + choices: Array<{ value: string; label: string }>; + title?: string; + }; interaction: CommandInteraction; cfg: ReturnType; discordConfig: DiscordConfig; @@ -365,11 +371,11 @@ function buildDiscordCommandArgMenu(params: { const buttons = choices.map( (choice) => new DiscordCommandArgButton({ - label: choice, + label: choice.label, customId: buildDiscordCommandArgCustomId({ command: commandLabel, arg: menu.arg.name, - value: choice, + value: choice.value, userId, }), cfg: params.cfg, diff --git a/src/infra/unhandled-rejections.test.ts b/src/infra/unhandled-rejections.test.ts new file mode 100644 index 000000000..1ec144ba1 --- /dev/null +++ b/src/infra/unhandled-rejections.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from "vitest"; + +import { isAbortError, isTransientNetworkError } from "./unhandled-rejections.js"; + +describe("isAbortError", () => { + it("returns true for error with name AbortError", () => { + const error = new Error("aborted"); + error.name = "AbortError"; + expect(isAbortError(error)).toBe(true); + }); + + it('returns true for error with "This operation was aborted" message', () => { + const error = new Error("This operation was aborted"); + expect(isAbortError(error)).toBe(true); + }); + + it("returns true for undici-style AbortError", () => { + // Node's undici throws errors with this exact message + const error = Object.assign(new Error("This operation was aborted"), { name: "AbortError" }); + expect(isAbortError(error)).toBe(true); + }); + + it("returns true for object with AbortError name", () => { + expect(isAbortError({ name: "AbortError", message: "test" })).toBe(true); + }); + + it("returns false for regular errors", () => { + expect(isAbortError(new Error("Something went wrong"))).toBe(false); + expect(isAbortError(new TypeError("Cannot read property"))).toBe(false); + expect(isAbortError(new RangeError("Invalid array length"))).toBe(false); + }); + + it("returns false for errors with similar but different messages", () => { + expect(isAbortError(new Error("Operation aborted"))).toBe(false); + expect(isAbortError(new Error("aborted"))).toBe(false); + expect(isAbortError(new Error("Request was aborted"))).toBe(false); + }); + + it("returns false for null and undefined", () => { + expect(isAbortError(null)).toBe(false); + expect(isAbortError(undefined)).toBe(false); + }); + + it("returns false for non-error values", () => { + expect(isAbortError("string error")).toBe(false); + expect(isAbortError(42)).toBe(false); + }); + + it("returns false for plain objects without AbortError name", () => { + expect(isAbortError({ message: "plain object" })).toBe(false); + }); +}); + +describe("isTransientNetworkError", () => { + it("returns true for errors with transient network codes", () => { + const codes = [ + "ECONNRESET", + "ECONNREFUSED", + "ENOTFOUND", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "ECONNABORTED", + "EPIPE", + "EHOSTUNREACH", + "ENETUNREACH", + "EAI_AGAIN", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_SOCKET", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", + ]; + + for (const code of codes) { + const error = Object.assign(new Error("test"), { code }); + expect(isTransientNetworkError(error), `code: ${code}`).toBe(true); + } + }); + + it('returns true for TypeError with "fetch failed" message', () => { + const error = new TypeError("fetch failed"); + expect(isTransientNetworkError(error)).toBe(true); + }); + + it("returns true for fetch failed with network cause", () => { + const cause = Object.assign(new Error("getaddrinfo ENOTFOUND"), { code: "ENOTFOUND" }); + const error = Object.assign(new TypeError("fetch failed"), { cause }); + expect(isTransientNetworkError(error)).toBe(true); + }); + + it("returns true for nested cause chain with network error", () => { + const innerCause = Object.assign(new Error("connection reset"), { code: "ECONNRESET" }); + const outerCause = Object.assign(new Error("wrapper"), { cause: innerCause }); + const error = Object.assign(new TypeError("fetch failed"), { cause: outerCause }); + expect(isTransientNetworkError(error)).toBe(true); + }); + + it("returns true for AggregateError containing network errors", () => { + const networkError = Object.assign(new Error("timeout"), { code: "ETIMEDOUT" }); + const error = new AggregateError([networkError], "Multiple errors"); + expect(isTransientNetworkError(error)).toBe(true); + }); + + it("returns false for regular errors without network codes", () => { + expect(isTransientNetworkError(new Error("Something went wrong"))).toBe(false); + expect(isTransientNetworkError(new TypeError("Cannot read property"))).toBe(false); + expect(isTransientNetworkError(new RangeError("Invalid array length"))).toBe(false); + }); + + it("returns false for errors with non-network codes", () => { + const error = Object.assign(new Error("test"), { code: "INVALID_CONFIG" }); + expect(isTransientNetworkError(error)).toBe(false); + }); + + it("returns false for null and undefined", () => { + expect(isTransientNetworkError(null)).toBe(false); + expect(isTransientNetworkError(undefined)).toBe(false); + }); + + it("returns false for non-error values", () => { + expect(isTransientNetworkError("string error")).toBe(false); + expect(isTransientNetworkError(42)).toBe(false); + expect(isTransientNetworkError({ message: "plain object" })).toBe(false); + }); + + it("returns false for AggregateError with only non-network errors", () => { + const error = new AggregateError([new Error("regular error")], "Multiple errors"); + expect(isTransientNetworkError(error)).toBe(false); + }); +}); diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index ac7ac91d5..86e80e9a3 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -1,11 +1,88 @@ import process from "node:process"; -import { formatErrorMessage, formatUncaughtError } from "./errors.js"; +import { formatUncaughtError } from "./errors.js"; type UnhandledRejectionHandler = (reason: unknown) => boolean; const handlers = new Set(); +/** + * Checks if an error is an AbortError. + * These are typically intentional cancellations (e.g., during shutdown) and shouldn't crash. + */ +export function isAbortError(err: unknown): boolean { + if (!err || typeof err !== "object") return false; + const name = "name" in err ? String(err.name) : ""; + if (name === "AbortError") return true; + // Check for "This operation was aborted" message from Node's undici + const message = "message" in err && typeof err.message === "string" ? err.message : ""; + if (message === "This operation was aborted") return true; + return false; +} + +// Network error codes that indicate transient failures (shouldn't crash the gateway) +const TRANSIENT_NETWORK_CODES = new Set([ + "ECONNRESET", + "ECONNREFUSED", + "ENOTFOUND", + "ETIMEDOUT", + "ESOCKETTIMEDOUT", + "ECONNABORTED", + "EPIPE", + "EHOSTUNREACH", + "ENETUNREACH", + "EAI_AGAIN", + "UND_ERR_CONNECT_TIMEOUT", + "UND_ERR_SOCKET", + "UND_ERR_HEADERS_TIMEOUT", + "UND_ERR_BODY_TIMEOUT", +]); + +function getErrorCode(err: unknown): string | undefined { + if (!err || typeof err !== "object") return undefined; + const code = (err as { code?: unknown }).code; + return typeof code === "string" ? code : undefined; +} + +function getErrorCause(err: unknown): unknown { + if (!err || typeof err !== "object") return undefined; + return (err as { cause?: unknown }).cause; +} + +/** + * Checks if an error is a transient network error that shouldn't crash the gateway. + * These are typically temporary connectivity issues that will resolve on their own. + */ +export function isTransientNetworkError(err: unknown): boolean { + if (!err) return false; + + // Check the error itself + const code = getErrorCode(err); + if (code && TRANSIENT_NETWORK_CODES.has(code)) return true; + + // "fetch failed" TypeError from undici (Node's native fetch) + if (err instanceof TypeError && err.message === "fetch failed") { + const cause = getErrorCause(err); + // The cause often contains the actual network error + if (cause) return isTransientNetworkError(cause); + // Even without a cause, "fetch failed" is typically a network issue + return true; + } + + // Check the cause chain recursively + const cause = getErrorCause(err); + if (cause && cause !== err) { + return isTransientNetworkError(cause); + } + + // AggregateError may wrap multiple causes + if (err instanceof AggregateError && err.errors?.length) { + return err.errors.some((e) => isTransientNetworkError(e)); + } + + return false; +} + export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHandler): () => void { handlers.add(handler); return () => { @@ -13,36 +90,6 @@ export function registerUnhandledRejectionHandler(handler: UnhandledRejectionHan }; } -/** - * Check if an error is a recoverable/transient error that shouldn't crash the process. - * These include network errors and abort signals during shutdown. - */ -function isRecoverableError(reason: unknown): boolean { - if (!reason) return false; - - // Check error name for AbortError - if (reason instanceof Error && reason.name === "AbortError") { - return true; - } - - const message = reason instanceof Error ? reason.message : formatErrorMessage(reason); - const lowerMessage = message.toLowerCase(); - return ( - lowerMessage.includes("fetch failed") || - lowerMessage.includes("network request") || - lowerMessage.includes("econnrefused") || - lowerMessage.includes("econnreset") || - lowerMessage.includes("etimedout") || - lowerMessage.includes("socket hang up") || - lowerMessage.includes("enotfound") || - lowerMessage.includes("network error") || - lowerMessage.includes("getaddrinfo") || - lowerMessage.includes("client network socket disconnected") || - lowerMessage.includes("this operation was aborted") || - lowerMessage.includes("aborted") - ); -} - export function isUnhandledRejectionHandled(reason: unknown): boolean { for (const handler of handlers) { try { @@ -61,9 +108,17 @@ export function installUnhandledRejectionHandler(): void { process.on("unhandledRejection", (reason, _promise) => { if (isUnhandledRejectionHandled(reason)) return; - // Don't crash on recoverable/transient errors - log them and continue - if (isRecoverableError(reason)) { - console.error("[clawdbot] Recoverable error (not crashing):", formatUncaughtError(reason)); + // AbortError is typically an intentional cancellation (e.g., during shutdown) + // Log it but don't crash - these are expected during graceful shutdown + if (isAbortError(reason)) { + console.warn("[clawdbot] Suppressed AbortError:", formatUncaughtError(reason)); + return; + } + + // Transient network errors (fetch failed, connection reset, etc.) shouldn't crash + // These are temporary connectivity issues that will resolve on their own + if (isTransientNetworkError(reason)) { + console.error("[clawdbot] Network error (non-fatal):", formatUncaughtError(reason)); return; } diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index d1c2a00ca..ae6d61106 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -103,7 +103,7 @@ function buildSlackCommandArgMenuBlocks(params: { title: string; command: string; arg: string; - choices: string[]; + choices: Array<{ value: string; label: string }>; userId: string; }) { const rows = chunkItems(params.choices, 5).map((choices) => ({ @@ -111,11 +111,11 @@ function buildSlackCommandArgMenuBlocks(params: { elements: choices.map((choice) => ({ type: "button", action_id: SLACK_COMMAND_ARG_ACTION_ID, - text: { type: "plain_text", text: choice }, + text: { type: "plain_text", text: choice.label }, value: encodeSlackCommandArgValue({ command: params.command, arg: params.arg, - value: choice, + value: choice.value, userId: params.userId, }), })), diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index c33f1e18e..e9d287d0d 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -366,10 +366,10 @@ export const registerTelegramNativeCommands = ({ rows.push( slice.map((choice) => { const args: CommandArgs = { - values: { [menu.arg.name]: choice }, + values: { [menu.arg.name]: choice.value }, }; return { - text: choice, + text: choice.label, callback_data: buildCommandTextFromArgs(commandDefinition, args), }; }), diff --git a/src/tts/tts.ts b/src/tts/tts.ts index 847876d04..9507c5535 100644 --- a/src/tts/tts.ts +++ b/src/tts/tts.ts @@ -40,7 +40,7 @@ import { resolveModel } from "../agents/pi-embedded-runner/model.js"; const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_TTS_MAX_LENGTH = 1500; const DEFAULT_TTS_SUMMARIZE = true; -const DEFAULT_MAX_TEXT_LENGTH = 4000; +const DEFAULT_MAX_TEXT_LENGTH = 4096; const TEMP_FILE_CLEANUP_DELAY_MS = 5 * 60 * 1000; // 5 minutes const DEFAULT_ELEVENLABS_BASE_URL = "https://api.elevenlabs.io"; @@ -1386,32 +1386,34 @@ export async function maybeApplyTtsToPayload(params: { if (textForAudio.length > maxLength) { if (!isSummarizationEnabled(prefsPath)) { + // Truncate text when summarization is disabled logVerbose( - `TTS: skipping long text (${textForAudio.length} > ${maxLength}), summarization disabled.`, + `TTS: truncating long text (${textForAudio.length} > ${maxLength}), summarization disabled.`, ); - return nextPayload; - } - - try { - const summary = await summarizeText({ - text: textForAudio, - targetLength: maxLength, - cfg: params.cfg, - config, - timeoutMs: config.timeoutMs, - }); - textForAudio = summary.summary; - wasSummarized = true; - if (textForAudio.length > config.maxTextLength) { - logVerbose( - `TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`, - ); - textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`; + textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`; + } else { + // Summarize text when enabled + try { + const summary = await summarizeText({ + text: textForAudio, + targetLength: maxLength, + cfg: params.cfg, + config, + timeoutMs: config.timeoutMs, + }); + textForAudio = summary.summary; + wasSummarized = true; + if (textForAudio.length > config.maxTextLength) { + logVerbose( + `TTS: summary exceeded hard limit (${textForAudio.length} > ${config.maxTextLength}); truncating.`, + ); + textForAudio = `${textForAudio.slice(0, config.maxTextLength - 3)}...`; + } + } catch (err) { + const error = err as Error; + logVerbose(`TTS: summarization failed, truncating instead: ${error.message}`); + textForAudio = `${textForAudio.slice(0, maxLength - 3)}...`; } - } catch (err) { - const error = err as Error; - logVerbose(`TTS: summarization failed: ${error.message}`); - return nextPayload; } } @@ -1436,12 +1438,12 @@ export async function maybeApplyTtsToPayload(params: { const channelId = resolveChannelId(params.channel); const shouldVoice = channelId === "telegram" && result.voiceCompatible === true; - - return { + const finalPayload = { ...nextPayload, mediaUrl: result.audioPath, audioAsVoice: shouldVoice || params.payload.audioAsVoice, }; + return finalPayload; } lastTtsAttempt = { From f300875dfe57fd563edd17e667085ca3010804ff Mon Sep 17 00:00:00 2001 From: Shakker Nerd Date: Tue, 27 Jan 2026 01:57:13 +0000 Subject: [PATCH 39/43] Fix: Corrected the `sendActivity` parameter type from an array to a single activity object --- extensions/msteams/src/reply-dispatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index 7b50b0629..f54422d33 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -42,7 +42,7 @@ export function createMSTeamsReplyDispatcher(params: { }) { const core = getMSTeamsRuntime(); const sendTypingIndicator = async () => { - await params.context.sendActivity([{ type: "typing" }]); + await params.context.sendActivity({ type: "typing" }); }; const typingCallbacks = createTypingCallbacks({ start: sendTypingIndicator, From 260f6e2c00d2c2ededc63e4eaaef2bd8dc0510e1 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 19:57:49 -0600 Subject: [PATCH 40/43] Docs: fix /scripts redirect loop --- docs/docs.json | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/docs.json b/docs/docs.json index c53902451..01a338a18 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -345,10 +345,6 @@ "source": "/auth-monitoring", "destination": "/automation/auth-monitoring" }, - { - "source": "/scripts", - "destination": "/scripts" - }, { "source": "/camera", "destination": "/nodes/camera" From 241436a52572145af1df9b7b1ea36ade8a60e0d5 Mon Sep 17 00:00:00 2001 From: wolfred Date: Mon, 26 Jan 2026 18:31:18 -0700 Subject: [PATCH 41/43] fix: handle fetch/API errors in telegram delivery to prevent gateway crashes Wrap all bot.api.sendXxx() media calls in delivery.ts with error handler that logs failures before re-throwing. This ensures network failures are properly logged with context instead of causing unhandled promise rejections that crash the gateway. Also wrap the fetch() call in telegram onboarding with try/catch to gracefully handle network errors during username lookup. Fixes #2487 Co-Authored-By: Claude Opus 4.5 --- src/channels/plugins/onboarding/telegram.ts | 22 ++++++--- src/telegram/bot/delivery.ts | 54 ++++++++++++++------- 2 files changed, 50 insertions(+), 26 deletions(-) diff --git a/src/channels/plugins/onboarding/telegram.ts b/src/channels/plugins/onboarding/telegram.ts index 0356acd33..fdbc044c5 100644 --- a/src/channels/plugins/onboarding/telegram.ts +++ b/src/channels/plugins/onboarding/telegram.ts @@ -80,14 +80,20 @@ async function promptTelegramAllowFrom(params: { if (!token) return null; const username = stripped.startsWith("@") ? stripped : `@${stripped}`; const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`; - const res = await fetch(url); - const data = (await res.json().catch(() => null)) as { - ok?: boolean; - result?: { id?: number | string }; - } | null; - const id = data?.ok ? data?.result?.id : undefined; - if (typeof id === "number" || typeof id === "string") return String(id); - return null; + try { + const res = await fetch(url); + if (!res.ok) return null; + const data = (await res.json().catch(() => null)) as { + ok?: boolean; + result?: { id?: number | string }; + } | null; + const id = data?.ok ? data?.result?.id : undefined; + if (typeof id === "number" || typeof id === "string") return String(id); + return null; + } catch { + // Network error during username lookup - return null to prompt user for numeric ID + return null; + } }; const parseInput = (value: string) => diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 36a680227..7a3748e5b 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -25,6 +25,24 @@ import type { TelegramContext } from "./types.js"; const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/; +/** + * Wraps a Telegram API call with error logging. Ensures network failures are + * logged with context before propagating, preventing silent unhandled rejections. + */ +async function withMediaErrorHandler( + operation: string, + runtime: RuntimeEnv, + fn: () => Promise, +): Promise { + try { + return await fn(); + } catch (err) { + const errText = formatErrorMessage(err); + runtime.error?.(danger(`telegram ${operation} failed: ${errText}`)); + throw err; + } +} + export async function deliverReplies(params: { replies: ReplyPayload[]; chatId: string; @@ -146,17 +164,17 @@ export async function deliverReplies(params: { mediaParams.message_thread_id = threadParams.message_thread_id; } if (isGif) { - await bot.api.sendAnimation(chatId, file, { - ...mediaParams, - }); + await withMediaErrorHandler("sendAnimation", runtime, () => + bot.api.sendAnimation(chatId, file, { ...mediaParams }), + ); } else if (kind === "image") { - await bot.api.sendPhoto(chatId, file, { - ...mediaParams, - }); + await withMediaErrorHandler("sendPhoto", runtime, () => + bot.api.sendPhoto(chatId, file, { ...mediaParams }), + ); } else if (kind === "video") { - await bot.api.sendVideo(chatId, file, { - ...mediaParams, - }); + await withMediaErrorHandler("sendVideo", runtime, () => + bot.api.sendVideo(chatId, file, { ...mediaParams }), + ); } else if (kind === "audio") { const { useVoice } = resolveTelegramVoiceSend({ wantsVoice: reply.audioAsVoice === true, // default false (backward compatible) @@ -169,9 +187,9 @@ export async function deliverReplies(params: { // Switch typing indicator to record_voice before sending. await params.onVoiceRecording?.(); try { - await bot.api.sendVoice(chatId, file, { - ...mediaParams, - }); + await withMediaErrorHandler("sendVoice", runtime, () => + bot.api.sendVoice(chatId, file, { ...mediaParams }), + ); } catch (voiceErr) { // Fall back to text if voice messages are forbidden in this chat. // This happens when the recipient has Telegram Premium privacy settings @@ -204,14 +222,14 @@ export async function deliverReplies(params: { } } else { // Audio file - displays with metadata (title, duration) - DEFAULT - await bot.api.sendAudio(chatId, file, { - ...mediaParams, - }); + await withMediaErrorHandler("sendAudio", runtime, () => + bot.api.sendAudio(chatId, file, { ...mediaParams }), + ); } } else { - await bot.api.sendDocument(chatId, file, { - ...mediaParams, - }); + await withMediaErrorHandler("sendDocument", runtime, () => + bot.api.sendDocument(chatId, file, { ...mediaParams }), + ); } if (replyToId && !hasReplied) { hasReplied = true; From 5796a92231edcbcf4380b0ecdd9481a1bdf2d787 Mon Sep 17 00:00:00 2001 From: Shadow Date: Mon, 26 Jan 2026 20:03:33 -0600 Subject: [PATCH 42/43] fix: log telegram API fetch errors (#2492) (thanks @altryne) --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d1a29c4d0..45426e61a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ Status: unreleased. - Agents: release session locks on process termination. (#2483) Thanks @janeexai. - Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. +- Telegram: log fetch/API errors in delivery to avoid unhandled rejections. (#2492) Thanks @altryne. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. - Build: align memory-core peer dependency with lockfile. From 66a5b324a1e6c0fbbb5fd2ab5cb0e4f29894bda4 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 26 Jan 2026 21:09:08 -0500 Subject: [PATCH 43/43] fix: harden session lock cleanup (#2483) (thanks @janeexai) --- CHANGELOG.md | 2 +- src/agents/session-write-lock.test.ts | 44 +++++++++- src/agents/session-write-lock.ts | 119 +++++++++++++++----------- 3 files changed, 111 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 45426e61a..791dbcd7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,7 @@ Status: unreleased. - Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. -- Agents: release session locks on process termination. (#2483) Thanks @janeexai. +- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai. - Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Telegram: log fetch/API errors in delivery to avoid unhandled rejections. (#2492) Thanks @altryne. diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts index 8eafd6bf4..072eca364 100644 --- a/src/agents/session-write-lock.test.ts +++ b/src/agents/session-write-lock.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; -import { acquireSessionWriteLock } from "./session-write-lock.js"; +import { __testing, acquireSessionWriteLock } from "./session-write-lock.js"; describe("acquireSessionWriteLock", () => { it("reuses locks across symlinked session paths", async () => { @@ -73,9 +73,38 @@ describe("acquireSessionWriteLock", () => { } }); + it("removes held locks on termination signals", async () => { + const signals = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const; + for (const signal of signals) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-cleanup-")); + try { + const sessionFile = path.join(root, "sessions.json"); + const lockPath = `${sessionFile}.lock`; + await acquireSessionWriteLock({ sessionFile, timeoutMs: 500 }); + const keepAlive = () => {}; + if (signal === "SIGINT") { + process.on(signal, keepAlive); + } + + __testing.handleTerminationSignal(signal); + + await expect(fs.stat(lockPath)).rejects.toThrow(); + if (signal === "SIGINT") { + process.off(signal, keepAlive); + } + } finally { + await fs.rm(root, { recursive: true, force: true }); + } + } + }); + + it("registers cleanup for SIGQUIT and SIGABRT", () => { + expect(__testing.cleanupSignals).toContain("SIGQUIT"); + expect(__testing.cleanupSignals).toContain("SIGABRT"); + }); it("cleans up locks on SIGINT without removing other handlers", async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-")); - const originalKill = process.kill; + const originalKill = process.kill.bind(process); const killCalls: Array = []; let otherHandlerCalled = false; @@ -99,7 +128,7 @@ describe("acquireSessionWriteLock", () => { await expect(fs.access(lockPath)).rejects.toThrow(); expect(otherHandlerCalled).toBe(true); - expect(killCalls).toEqual(["SIGINT"]); + expect(killCalls).toEqual([]); } finally { process.off("SIGINT", otherHandler); process.kill = originalKill; @@ -121,4 +150,13 @@ describe("acquireSessionWriteLock", () => { await fs.rm(root, { recursive: true, force: true }); } }); + it("keeps other signal listeners registered", () => { + const keepAlive = () => {}; + process.on("SIGINT", keepAlive); + + __testing.handleTerminationSignal("SIGINT"); + + expect(process.listeners("SIGINT")).toContain(keepAlive); + process.off("SIGINT", keepAlive); + }); }); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index d7499eb2a..832d368a6 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -1,5 +1,5 @@ -import fs from "node:fs/promises"; import fsSync from "node:fs"; +import fs from "node:fs/promises"; import path from "node:path"; type LockFilePayload = { @@ -14,6 +14,9 @@ type HeldLock = { }; const HELD_LOCKS = new Map(); +const CLEANUP_SIGNALS = ["SIGINT", "SIGTERM", "SIGQUIT", "SIGABRT"] as const; +type CleanupSignal = (typeof CLEANUP_SIGNALS)[number]; +const cleanupHandlers = new Map void>(); function isAlive(pid: number): boolean { if (!Number.isFinite(pid) || pid <= 0) return false; @@ -25,6 +28,65 @@ function isAlive(pid: number): boolean { } } +/** + * Synchronously release all held locks. + * Used during process exit when async operations aren't reliable. + */ +function releaseAllLocksSync(): void { + for (const [sessionFile, held] of HELD_LOCKS) { + try { + if (typeof held.handle.fd === "number") { + fsSync.closeSync(held.handle.fd); + } + } catch { + // Ignore errors during cleanup - best effort + } + try { + fsSync.rmSync(held.lockPath, { force: true }); + } catch { + // Ignore errors during cleanup - best effort + } + HELD_LOCKS.delete(sessionFile); + } +} + +let cleanupRegistered = false; + +function handleTerminationSignal(signal: CleanupSignal): void { + releaseAllLocksSync(); + const shouldReraise = process.listenerCount(signal) === 1; + if (shouldReraise) { + const handler = cleanupHandlers.get(signal); + if (handler) process.off(signal, handler); + try { + process.kill(process.pid, signal); + } catch { + // Ignore errors during shutdown + } + } +} + +function registerCleanupHandlers(): void { + if (cleanupRegistered) return; + cleanupRegistered = true; + + // Cleanup on normal exit and process.exit() calls + process.on("exit", () => { + releaseAllLocksSync(); + }); + + // Handle termination signals + for (const signal of CLEANUP_SIGNALS) { + try { + const handler = () => handleTerminationSignal(signal); + cleanupHandlers.set(signal, handler); + process.on(signal, handler); + } catch { + // Ignore unsupported signals on this platform. + } + } +} + async function readLockPayload(lockPath: string): Promise { try { const raw = await fs.readFile(lockPath, "utf8"); @@ -44,6 +106,7 @@ export async function acquireSessionWriteLock(params: { }): Promise<{ release: () => Promise; }> { + registerCleanupHandlers(); const timeoutMs = params.timeoutMs ?? 10_000; const staleMs = params.staleMs ?? 30 * 60 * 1000; const sessionFile = path.resolve(params.sessionFile); @@ -118,52 +181,8 @@ export async function acquireSessionWriteLock(params: { throw new Error(`session file locked (timeout ${timeoutMs}ms): ${owner} ${lockPath}`); } -/** - * Synchronously release all held locks. - * Used during process exit when async operations aren't reliable. - */ -function releaseAllLocksSync(): void { - for (const [sessionFile, held] of HELD_LOCKS) { - try { - fsSync.closeSync(held.handle.fd); - } catch { - // Ignore close errors during cleanup - best effort - } - try { - fsSync.rmSync(held.lockPath, { force: true }); - } catch { - // Ignore errors during cleanup - best effort - } - HELD_LOCKS.delete(sessionFile); - } -} - -let cleanupRegistered = false; - -function registerCleanupHandlers(): void { - if (cleanupRegistered) return; - cleanupRegistered = true; - - // Cleanup on normal exit and process.exit() calls - process.on("exit", () => { - releaseAllLocksSync(); - }); - - // Handle SIGINT (Ctrl+C) and SIGTERM - const handleSignal = (signal: NodeJS.Signals) => { - releaseAllLocksSync(); - // Remove only our handlers and re-raise signal for proper exit code. - process.off("SIGINT", onSigInt); - process.off("SIGTERM", onSigTerm); - process.kill(process.pid, signal); - }; - - const onSigInt = () => handleSignal("SIGINT"); - const onSigTerm = () => handleSignal("SIGTERM"); - - process.on("SIGINT", onSigInt); - process.on("SIGTERM", onSigTerm); -} - -// Register cleanup handlers when module loads -registerCleanupHandlers(); +export const __testing = { + cleanupSignals: [...CLEANUP_SIGNALS], + handleTerminationSignal, + releaseAllLocksSync, +};