From 0aa48a26d1545aca659251b84d7401229612a3fc Mon Sep 17 00:00:00 2001 From: adeboyedn Date: Mon, 26 Jan 2026 08:07:33 +0100 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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 04/11] 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 05/11] 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 06/11] 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 07/11] 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 08/11] 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 09/11] 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 10/11] 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 11/11] 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":