From dd6bc5382da3747611e3308592b1fecfe1e8f4c3 Mon Sep 17 00:00:00 2001
From: Alg0rix
Date: Sun, 25 Jan 2026 13:35:32 +0000
Subject: [PATCH 01/66] 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 2ad3508a33a6c49d6abcdd07c9ede0f17c5d560a Mon Sep 17 00:00:00 2001
From: Vignesh Natarajan
Date: Sun, 25 Jan 2026 00:29:28 -0800
Subject: [PATCH 02/66] feat(config): add tools.alsoAllow additive allowlist
---
src/agents/pi-tools.policy.ts | 22 ++++++++++++++-
src/agents/pi-tools.ts | 21 +++++++++++---
src/config/schema.ts | 2 ++
src/config/types.tools.ts | 13 +++++++++
src/config/zod-schema.agent-runtime.ts | 4 +++
src/gateway/tools-invoke-http.test.ts | 38 +++++++++++++++++++++++++-
src/gateway/tools-invoke-http.ts | 17 ++++++++++--
7 files changed, 109 insertions(+), 8 deletions(-)
diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts
index 98585ca9d..1879a6218 100644
--- a/src/agents/pi-tools.policy.ts
+++ b/src/agents/pi-tools.policy.ts
@@ -96,13 +96,22 @@ export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolP
type ToolPolicyConfig = {
allow?: string[];
+ alsoAllow?: string[];
deny?: string[];
profile?: string;
};
+function unionAllow(base?: string[], extra?: string[]) {
+ if (!Array.isArray(extra) || extra.length === 0) return base;
+ if (!Array.isArray(base) || base.length === 0) return base;
+ return Array.from(new Set([...base, ...extra]));
+}
+
function pickToolPolicy(config?: ToolPolicyConfig): SandboxToolPolicy | undefined {
if (!config) return undefined;
- const allow = Array.isArray(config.allow) ? config.allow : undefined;
+ const allow = Array.isArray(config.allow)
+ ? unionAllow(config.allow, config.alsoAllow)
+ : undefined;
const deny = Array.isArray(config.deny) ? config.deny : undefined;
if (!allow && !deny) return undefined;
return { allow, deny };
@@ -195,6 +204,17 @@ export function resolveEffectiveToolPolicy(params: {
agentProviderPolicy: pickToolPolicy(agentProviderPolicy),
profile,
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
+ // alsoAllow is applied at the profile stage (to avoid being filtered out early).
+ profileAlsoAllow: Array.isArray(agentTools?.alsoAllow)
+ ? agentTools?.alsoAllow
+ : Array.isArray(globalTools?.alsoAllow)
+ ? globalTools?.alsoAllow
+ : undefined,
+ providerProfileAlsoAllow: Array.isArray(agentProviderPolicy?.alsoAllow)
+ ? agentProviderPolicy?.alsoAllow
+ : Array.isArray(providerPolicy?.alsoAllow)
+ ? providerPolicy?.alsoAllow
+ : undefined,
};
}
diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts
index 9013f1e52..6f293514d 100644
--- a/src/agents/pi-tools.ts
+++ b/src/agents/pi-tools.ts
@@ -157,6 +157,8 @@ export function createClawdbotCodingTools(options?: {
agentProviderPolicy,
profile,
providerProfile,
+ profileAlsoAllow,
+ providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
@@ -175,14 +177,25 @@ export function createClawdbotCodingTools(options?: {
});
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
+
+ const mergeAlsoAllow = (policy: typeof profilePolicy, alsoAllow?: string[]) => {
+ if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) return policy;
+ return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
+ };
+
+ const profilePolicyWithAlsoAllow = mergeAlsoAllow(profilePolicy, profileAlsoAllow);
+ const providerProfilePolicyWithAlsoAllow = mergeAlsoAllow(
+ providerProfilePolicy,
+ providerProfileAlsoAllow,
+ );
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
? resolveSubagentToolPolicy(options.config)
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
- profilePolicy,
- providerProfilePolicy,
+ profilePolicyWithAlsoAllow,
+ providerProfilePolicyWithAlsoAllow,
globalPolicy,
globalProviderPolicy,
agentPolicy,
@@ -340,11 +353,11 @@ export function createClawdbotCodingTools(options?: {
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
};
const profilePolicyExpanded = resolvePolicy(
- profilePolicy,
+ profilePolicyWithAlsoAllow,
profile ? `tools.profile (${profile})` : "tools.profile",
);
const providerProfileExpanded = resolvePolicy(
- providerProfilePolicy,
+ providerProfilePolicyWithAlsoAllow,
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
);
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 24d6bccfe..9627d64f3 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -165,7 +165,9 @@ const FIELD_LABELS: Record = {
"tools.links.models": "Link Understanding Models",
"tools.links.scope": "Link Understanding Scope",
"tools.profile": "Tool Profile",
+ "tools.alsoAllow": "Tool Allowlist Additions",
"agents.list[].tools.profile": "Agent Tool Profile",
+ "agents.list[].tools.alsoAllow": "Agent Tool Allowlist Additions",
"tools.byProvider": "Tool Policy by Provider",
"agents.list[].tools.byProvider": "Agent Tool Policy by Provider",
"tools.exec.applyPatch.enabled": "Enable apply_patch",
diff --git a/src/config/types.tools.ts b/src/config/types.tools.ts
index ad7f69d85..d84dd1aa7 100644
--- a/src/config/types.tools.ts
+++ b/src/config/types.tools.ts
@@ -140,12 +140,21 @@ export type ToolProfileId = "minimal" | "coding" | "messaging" | "full";
export type ToolPolicyConfig = {
allow?: string[];
+ /**
+ * Additional allowlist entries merged into the effective allowlist.
+ *
+ * Intended for additive configuration (e.g., "also allow lobster") without forcing
+ * users to replace/duplicate an existing allowlist or profile.
+ */
+ alsoAllow?: string[];
deny?: string[];
profile?: ToolProfileId;
};
export type GroupToolPolicyConfig = {
allow?: string[];
+ /** Additional allowlist entries merged into allow. */
+ alsoAllow?: string[];
deny?: string[];
};
@@ -188,6 +197,8 @@ export type AgentToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[];
+ /** Additional allowlist entries merged into allow and/or profile allowlist. */
+ alsoAllow?: string[];
deny?: string[];
/** Optional tool policy overrides keyed by provider id or "provider/model". */
byProvider?: Record;
@@ -312,6 +323,8 @@ export type ToolsConfig = {
/** Base tool profile applied before allow/deny lists. */
profile?: ToolProfileId;
allow?: string[];
+ /** Additional allowlist entries merged into allow and/or profile allowlist. */
+ alsoAllow?: string[];
deny?: string[];
/** Optional tool policy overrides keyed by provider id or "provider/model". */
byProvider?: Record;
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index c733dcfa9..e08f08d6e 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -150,6 +150,7 @@ export const SandboxPruneSchema = z
export const ToolPolicySchema = z
.object({
allow: z.array(z.string()).optional(),
+ alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
.strict()
@@ -202,6 +203,7 @@ export const ToolProfileSchema = z
export const ToolPolicyWithProfileSchema = z
.object({
allow: z.array(z.string()).optional(),
+ alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
profile: ToolProfileSchema,
})
@@ -231,6 +233,7 @@ export const AgentToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
+ alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
elevated: z
@@ -425,6 +428,7 @@ export const ToolsSchema = z
.object({
profile: ToolProfileSchema,
allow: z.array(z.string()).optional(),
+ alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
byProvider: z.record(z.string(), ToolPolicyWithProfileSchema).optional(),
web: ToolsWebSchema,
diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts
index 18c23692d..956ac51dd 100644
--- a/src/gateway/tools-invoke-http.test.ts
+++ b/src/gateway/tools-invoke-http.test.ts
@@ -1,12 +1,19 @@
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http";
+
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
installGatewayTestHooks({ scope: "suite" });
+beforeEach(() => {
+ // Ensure these tests are not affected by host env vars.
+ delete process.env.CLAWDBOT_GATEWAY_TOKEN;
+ delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
+});
+
const resolveGatewayToken = (): string => {
const token = (testState.gatewayAuth as { token?: string } | undefined)?.token;
if (!token) throw new Error("test gateway token missing");
@@ -47,6 +54,35 @@ describe("POST /tools/invoke", () => {
await server.close();
});
+ it("supports tools.alsoAllow as additive allowlist (profile stage)", async () => {
+ // No explicit tool allowlist; rely on profile + alsoAllow.
+ testState.agentsConfig = {
+ list: [{ id: "main" }],
+ } as any;
+
+ // minimal profile does NOT include sessions_list, but alsoAllow should.
+ const { writeConfigFile } = await import("../config/config.js");
+ await writeConfigFile({
+ tools: { profile: "minimal", alsoAllow: ["sessions_list"] },
+ } as any);
+
+ const port = await getFreePort();
+ const server = await startGatewayServer(port, { bind: "loopback" });
+ const token = resolveGatewayToken();
+
+ const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
+ body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.ok).toBe(true);
+
+ await server.close();
+ });
+
it("accepts password auth when bearer token matches", async () => {
testState.agentsConfig = {
list: [
diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts
index 80e2f295e..5fd525c8c 100644
--- a/src/gateway/tools-invoke-http.ts
+++ b/src/gateway/tools-invoke-http.ts
@@ -130,9 +130,22 @@ export async function handleToolsInvokeHttpRequest(
agentProviderPolicy,
profile,
providerProfile,
+ profileAlsoAllow,
+ providerProfileAlsoAllow,
} = resolveEffectiveToolPolicy({ config: cfg, sessionKey });
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
+
+ const mergeAlsoAllow = (policy: typeof profilePolicy, alsoAllow?: string[]) => {
+ if (!policy?.allow || !Array.isArray(alsoAllow) || alsoAllow.length === 0) return policy;
+ return { ...policy, allow: Array.from(new Set([...policy.allow, ...alsoAllow])) };
+ };
+
+ const profilePolicyWithAlsoAllow = mergeAlsoAllow(profilePolicy, profileAlsoAllow);
+ const providerProfilePolicyWithAlsoAllow = mergeAlsoAllow(
+ providerProfilePolicy,
+ providerProfileAlsoAllow,
+ );
const groupPolicy = resolveGroupToolPolicy({
config: cfg,
sessionKey,
@@ -183,11 +196,11 @@ export async function handleToolsInvokeHttpRequest(
return expandPolicyWithPluginGroups(resolved.policy, pluginGroups);
};
const profilePolicyExpanded = resolvePolicy(
- profilePolicy,
+ profilePolicyWithAlsoAllow,
profile ? `tools.profile (${profile})` : "tools.profile",
);
const providerProfileExpanded = resolvePolicy(
- providerProfilePolicy,
+ providerProfilePolicyWithAlsoAllow,
providerProfile ? `tools.byProvider.profile (${providerProfile})` : "tools.byProvider.profile",
);
const globalPolicyExpanded = resolvePolicy(globalPolicy, "tools.allow");
From d62b7c0d1ef776f87d2d62f45c48eb91cbff7738 Mon Sep 17 00:00:00 2001
From: Vignesh Natarajan
Date: Sun, 25 Jan 2026 00:36:47 -0800
Subject: [PATCH 03/66] fix: treat tools.alsoAllow as implicit allow-all when
no allowlist
---
src/agents/pi-tools.policy.ts | 10 ++++++++--
src/gateway/tools-invoke-http.test.ts | 28 +++++++++++++++++++++++++++
2 files changed, 36 insertions(+), 2 deletions(-)
diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts
index 1879a6218..d6e125e33 100644
--- a/src/agents/pi-tools.policy.ts
+++ b/src/agents/pi-tools.policy.ts
@@ -103,7 +103,11 @@ type ToolPolicyConfig = {
function unionAllow(base?: string[], extra?: string[]) {
if (!Array.isArray(extra) || extra.length === 0) return base;
- if (!Array.isArray(base) || base.length === 0) return base;
+ // If the user is using alsoAllow without an allowlist, treat it as additive on top of
+ // an implicit allow-all policy.
+ if (!Array.isArray(base) || base.length === 0) {
+ return Array.from(new Set(["*", ...extra]));
+ }
return Array.from(new Set([...base, ...extra]));
}
@@ -111,7 +115,9 @@ function pickToolPolicy(config?: ToolPolicyConfig): SandboxToolPolicy | undefine
if (!config) return undefined;
const allow = Array.isArray(config.allow)
? unionAllow(config.allow, config.alsoAllow)
- : undefined;
+ : Array.isArray(config.alsoAllow) && config.alsoAllow.length > 0
+ ? unionAllow(undefined, config.alsoAllow)
+ : undefined;
const deny = Array.isArray(config.deny) ? config.deny : undefined;
if (!allow && !deny) return undefined;
return { allow, deny };
diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts
index 956ac51dd..f08035885 100644
--- a/src/gateway/tools-invoke-http.test.ts
+++ b/src/gateway/tools-invoke-http.test.ts
@@ -83,6 +83,34 @@ describe("POST /tools/invoke", () => {
await server.close();
});
+ it("supports tools.alsoAllow without allow/profile (implicit allow-all)", async () => {
+ testState.agentsConfig = {
+ list: [{ id: "main" }],
+ } as any;
+
+ await fs.mkdir(path.dirname(CONFIG_PATH_CLAWDBOT), { recursive: true });
+ await fs.writeFile(
+ CONFIG_PATH_CLAWDBOT,
+ JSON.stringify({ tools: { alsoAllow: ["sessions_list"] } }, null, 2),
+ "utf-8",
+ );
+
+ const port = await getFreePort();
+ const server = await startGatewayServer(port, { bind: "loopback" });
+
+ const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = await res.json();
+ expect(body.ok).toBe(true);
+
+ await server.close();
+ });
+
it("accepts password auth when bearer token matches", async () => {
testState.agentsConfig = {
list: [
From 3497be29630db2166afd00e9733cc38e10cb4717 Mon Sep 17 00:00:00 2001
From: Vignesh Natarajan
Date: Sun, 25 Jan 2026 00:40:13 -0800
Subject: [PATCH 04/66] docs: recommend tools.alsoAllow for optional plugin
tools
---
docs/automation/cron-vs-heartbeat.md | 2 +-
docs/tools/lobster.md | 18 +++++++++++++++---
src/agents/pi-tools.ts | 2 +-
src/agents/tool-policy.ts | 6 ++++++
src/gateway/tools-invoke-http.ts | 2 +-
5 files changed, 24 insertions(+), 6 deletions(-)
diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md
index 333a45d0b..325575602 100644
--- a/docs/automation/cron-vs-heartbeat.md
+++ b/docs/automation/cron-vs-heartbeat.md
@@ -201,7 +201,7 @@ For ad-hoc workflows, call Lobster directly.
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
-- The tool is an **optional plugin**; you must allowlist `lobster` in `tools.allow`.
+- The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended).
- If you pass `lobsterPath`, it must be an **absolute path**.
See [Lobster](/tools/lobster) for full usage and examples.
diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md
index daf04fd39..f4718c4b5 100644
--- a/docs/tools/lobster.md
+++ b/docs/tools/lobster.md
@@ -158,7 +158,19 @@ If you want to use a custom binary location, pass an **absolute** `lobsterPath`
## Enable the tool
-Lobster is an **optional** plugin tool (not enabled by default). Allow it per agent:
+Lobster is an **optional** plugin tool (not enabled by default).
+
+Recommended (additive, safe):
+
+```json
+{
+ "tools": {
+ "alsoAllow": ["lobster"]
+ }
+}
+```
+
+Or per-agent:
```json
{
@@ -167,7 +179,7 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
{
"id": "main",
"tools": {
- "allow": ["lobster"]
+ "alsoAllow": ["lobster"]
}
}
]
@@ -175,7 +187,7 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
}
```
-You can also allow it globally with `tools.allow` if every agent should see it.
+Avoid using `tools.allow: ["lobster"]` unless you intend to run in restrictive allowlist mode.
Note: allowlists are opt-in for optional plugins. If your allowlist only names
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core
diff --git a/src/agents/pi-tools.ts b/src/agents/pi-tools.ts
index 6f293514d..4a0bebed0 100644
--- a/src/agents/pi-tools.ts
+++ b/src/agents/pi-tools.ts
@@ -346,7 +346,7 @@ export function createClawdbotCodingTools(options?: {
if (resolved.unknownAllowlist.length > 0) {
const entries = resolved.unknownAllowlist.join(", ");
const suffix = resolved.strippedAllowlist
- ? "Ignoring allowlist so core tools remain available."
+ ? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement."
: "These entries won't match any tool unless the plugin is enabled.";
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
}
diff --git a/src/agents/tool-policy.ts b/src/agents/tool-policy.ts
index ac2b1a91c..85152069e 100644
--- a/src/agents/tool-policy.ts
+++ b/src/agents/tool-policy.ts
@@ -209,6 +209,12 @@ export function stripPluginOnlyAllowlist(
if (!isCoreEntry && !isPluginEntry) unknownAllowlist.push(entry);
}
const strippedAllowlist = !hasCoreEntry;
+ // When an allowlist contains only plugin tools, we strip it to avoid accidentally
+ // disabling core tools. Users who want additive behavior should prefer `tools.alsoAllow`.
+ if (strippedAllowlist) {
+ // Note: logging happens in the caller (pi-tools/tools-invoke) after this function returns.
+ // We keep this note here for future maintainers.
+ }
return {
policy: strippedAllowlist ? { ...policy, allow: undefined } : policy,
unknownAllowlist: Array.from(new Set(unknownAllowlist)),
diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts
index 5fd525c8c..b747e2561 100644
--- a/src/gateway/tools-invoke-http.ts
+++ b/src/gateway/tools-invoke-http.ts
@@ -189,7 +189,7 @@ export async function handleToolsInvokeHttpRequest(
if (resolved.unknownAllowlist.length > 0) {
const entries = resolved.unknownAllowlist.join(", ");
const suffix = resolved.strippedAllowlist
- ? "Ignoring allowlist so core tools remain available."
+ ? "Ignoring allowlist so core tools remain available. Use tools.alsoAllow for additive plugin tool enablement."
: "These entries won't match any tool unless the plugin is enabled.";
logWarn(`tools: ${label} allowlist contains unknown entries (${entries}). ${suffix}`);
}
From 42d039998d73c47dec264377668ef1be00595bc2 Mon Sep 17 00:00:00 2001
From: Pocket Clawd
Date: Mon, 26 Jan 2026 10:17:50 -0800
Subject: [PATCH 05/66] feat(config): forbid allow+alsoAllow in same scope;
auto-merge
---
src/config/legacy.migrations.part-3.ts | 85 ++++++++++++++++++++++++++
src/config/zod-schema.agent-runtime.ts | 43 +++++++++++--
2 files changed, 124 insertions(+), 4 deletions(-)
diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts
index 9db9e3ede..d4b75e871 100644
--- a/src/config/legacy.migrations.part-3.ts
+++ b/src/config/legacy.migrations.part-3.ts
@@ -9,6 +9,84 @@ import {
resolveDefaultAgentIdFromRaw,
} from "./legacy.shared.js";
+function mergeAlsoAllowIntoAllow(node: unknown): boolean {
+ if (!isRecord(node)) return false;
+ const allow = node.allow;
+ const alsoAllow = node.alsoAllow;
+ if (!Array.isArray(allow) || allow.length === 0) return false;
+ if (!Array.isArray(alsoAllow) || alsoAllow.length === 0) return false;
+ const merged = Array.from(new Set([...(allow as unknown[]), ...(alsoAllow as unknown[])]));
+ node.allow = merged;
+ delete node.alsoAllow;
+ return true;
+}
+
+function migrateAlsoAllowInToolConfig(raw: Record, changes: string[]) {
+ let mutated = false;
+
+ // Global tools
+ const tools = getRecord(raw.tools);
+ if (mergeAlsoAllowIntoAllow(tools)) {
+ mutated = true;
+ changes.push("Merged tools.alsoAllow into tools.allow (and removed tools.alsoAllow).");
+ }
+
+ // tools.byProvider.*
+ const byProvider = getRecord(tools?.byProvider);
+ if (byProvider) {
+ for (const [key, value] of Object.entries(byProvider)) {
+ if (mergeAlsoAllowIntoAllow(value)) {
+ mutated = true;
+ changes.push(`Merged tools.byProvider.${key}.alsoAllow into allow (and removed alsoAllow).`);
+ }
+ }
+ }
+
+ // agents.list[].tools
+ const agentsList = getAgentsList(raw);
+ for (const agent of agentsList) {
+ const agentTools = getRecord(agent.tools);
+ if (mergeAlsoAllowIntoAllow(agentTools)) {
+ mutated = true;
+ const id = typeof agent.id === "string" ? agent.id : "";
+ changes.push(`Merged agents.list[${id}].tools.alsoAllow into allow (and removed alsoAllow).`);
+ }
+
+ const agentByProvider = getRecord(agentTools?.byProvider);
+ if (agentByProvider) {
+ for (const [key, value] of Object.entries(agentByProvider)) {
+ if (mergeAlsoAllowIntoAllow(value)) {
+ mutated = true;
+ const id = typeof agent.id === "string" ? agent.id : "";
+ changes.push(
+ `Merged agents.list[${id}].tools.byProvider.${key}.alsoAllow into allow (and removed alsoAllow).`,
+ );
+ }
+ }
+ }
+ }
+
+ // Provider group tool policies: channels..groups.*.tools and similar nested tool policy objects.
+ const channels = getRecord(raw.channels);
+ if (channels) {
+ for (const [provider, providerCfg] of Object.entries(channels)) {
+ const groups = getRecord(getRecord(providerCfg)?.groups);
+ if (!groups) continue;
+ for (const [groupKey, groupCfg] of Object.entries(groups)) {
+ const toolsCfg = getRecord(getRecord(groupCfg)?.tools);
+ if (mergeAlsoAllowIntoAllow(toolsCfg)) {
+ mutated = true;
+ changes.push(
+ `Merged channels.${provider}.groups.${groupKey}.tools.alsoAllow into allow (and removed alsoAllow).`,
+ );
+ }
+ }
+ }
+ }
+
+ return mutated;
+}
+
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
{
id: "auth.anthropic-claude-cli-mode-oauth",
@@ -24,6 +102,13 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
},
},
+ {
+ id: "tools.alsoAllow-merge",
+ describe: "Merge tools.alsoAllow into allow when allow is present",
+ apply: (raw, changes) => {
+ migrateAlsoAllowInToolConfig(raw, changes);
+ },
+ },
{
id: "tools.bash->tools.exec",
describe: "Move tools.bash to tools.exec",
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index e08f08d6e..99074c55e 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -147,14 +147,22 @@ export const SandboxPruneSchema = z
.strict()
.optional();
-export const ToolPolicySchema = z
+const ToolPolicyBaseSchema = z
.object({
allow: z.array(z.string()).optional(),
alsoAllow: z.array(z.string()).optional(),
deny: z.array(z.string()).optional(),
})
- .strict()
- .optional();
+ .strict();
+
+export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) => {
+ if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: "tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
+ });
+ }
+}).optional();
export const ToolsWebSearchSchema = z
.object({
@@ -207,7 +215,16 @@ export const ToolPolicyWithProfileSchema = z
deny: z.array(z.string()).optional(),
profile: ToolProfileSchema,
})
- .strict();
+ .strict()
+ .superRefine((value, ctx) => {
+ if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ "tools.byProvider policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
+ });
+ }
+ });
// Provider docking: allowlists keyed by provider id (no schema updates when adding providers).
export const ElevatedAllowFromSchema = z
@@ -274,6 +291,15 @@ export const AgentToolsSchema = z
.optional(),
})
.strict()
+ .superRefine((value, ctx) => {
+ if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ "agent tools cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
+ });
+ }
+ })
.optional();
export const MemorySearchSchema = z
@@ -511,4 +537,13 @@ export const ToolsSchema = z
.optional(),
})
.strict()
+ .superRefine((value, ctx) => {
+ if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message:
+ "tools cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
+ });
+ }
+ })
.optional();
From f625303d13ffce8ad25dd2fe54a43df4b93cb16b Mon Sep 17 00:00:00 2001
From: Pocket Clawd
Date: Mon, 26 Jan 2026 10:42:03 -0800
Subject: [PATCH 06/66] test(config): enforce allow+alsoAllow mutual exclusion
---
src/config/config.tools-alsoAllow.test.ts | 53 ++++++++++++++
src/config/legacy.migrations.part-3.ts | 86 +----------------------
2 files changed, 56 insertions(+), 83 deletions(-)
create mode 100644 src/config/config.tools-alsoAllow.test.ts
diff --git a/src/config/config.tools-alsoAllow.test.ts b/src/config/config.tools-alsoAllow.test.ts
new file mode 100644
index 000000000..aea4f02d9
--- /dev/null
+++ b/src/config/config.tools-alsoAllow.test.ts
@@ -0,0 +1,53 @@
+import { describe, expect, it } from "vitest";
+
+import { validateConfigObject } from "./validation.js";
+
+// NOTE: These tests ensure allow + alsoAllow cannot be set in the same scope.
+
+describe("config: tools.alsoAllow", () => {
+ it("rejects tools.allow + tools.alsoAllow together", () => {
+ const res = validateConfigObject({
+ tools: {
+ allow: ["group:fs"],
+ alsoAllow: ["lobster"],
+ },
+ });
+
+ expect(res.ok).toBe(false);
+ if (!res.ok) {
+ expect(res.issues.some((i) => i.path === "tools")).toBe(true);
+ }
+ });
+
+ it("rejects agents.list[].tools.allow + alsoAllow together", () => {
+ const res = validateConfigObject({
+ agents: {
+ list: [
+ {
+ id: "main",
+ tools: {
+ allow: ["group:fs"],
+ alsoAllow: ["lobster"],
+ },
+ },
+ ],
+ },
+ });
+
+ expect(res.ok).toBe(false);
+ if (!res.ok) {
+ expect(res.issues.some((i) => i.path.includes("agents.list"))).toBe(true);
+ }
+ });
+
+ it("allows profile + alsoAllow", () => {
+ const res = validateConfigObject({
+ tools: {
+ profile: "coding",
+ alsoAllow: ["lobster"],
+ },
+ });
+
+ expect(res.ok).toBe(true);
+ });
+});
diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts
index d4b75e871..21589e4fa 100644
--- a/src/config/legacy.migrations.part-3.ts
+++ b/src/config/legacy.migrations.part-3.ts
@@ -9,83 +9,9 @@ import {
resolveDefaultAgentIdFromRaw,
} from "./legacy.shared.js";
-function mergeAlsoAllowIntoAllow(node: unknown): boolean {
- if (!isRecord(node)) return false;
- const allow = node.allow;
- const alsoAllow = node.alsoAllow;
- if (!Array.isArray(allow) || allow.length === 0) return false;
- if (!Array.isArray(alsoAllow) || alsoAllow.length === 0) return false;
- const merged = Array.from(new Set([...(allow as unknown[]), ...(alsoAllow as unknown[])]));
- node.allow = merged;
- delete node.alsoAllow;
- return true;
-}
+// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed.
-function migrateAlsoAllowInToolConfig(raw: Record, changes: string[]) {
- let mutated = false;
-
- // Global tools
- const tools = getRecord(raw.tools);
- if (mergeAlsoAllowIntoAllow(tools)) {
- mutated = true;
- changes.push("Merged tools.alsoAllow into tools.allow (and removed tools.alsoAllow).");
- }
-
- // tools.byProvider.*
- const byProvider = getRecord(tools?.byProvider);
- if (byProvider) {
- for (const [key, value] of Object.entries(byProvider)) {
- if (mergeAlsoAllowIntoAllow(value)) {
- mutated = true;
- changes.push(`Merged tools.byProvider.${key}.alsoAllow into allow (and removed alsoAllow).`);
- }
- }
- }
-
- // agents.list[].tools
- const agentsList = getAgentsList(raw);
- for (const agent of agentsList) {
- const agentTools = getRecord(agent.tools);
- if (mergeAlsoAllowIntoAllow(agentTools)) {
- mutated = true;
- const id = typeof agent.id === "string" ? agent.id : "";
- changes.push(`Merged agents.list[${id}].tools.alsoAllow into allow (and removed alsoAllow).`);
- }
-
- const agentByProvider = getRecord(agentTools?.byProvider);
- if (agentByProvider) {
- for (const [key, value] of Object.entries(agentByProvider)) {
- if (mergeAlsoAllowIntoAllow(value)) {
- mutated = true;
- const id = typeof agent.id === "string" ? agent.id : "";
- changes.push(
- `Merged agents.list[${id}].tools.byProvider.${key}.alsoAllow into allow (and removed alsoAllow).`,
- );
- }
- }
- }
- }
-
- // Provider group tool policies: channels..groups.*.tools and similar nested tool policy objects.
- const channels = getRecord(raw.channels);
- if (channels) {
- for (const [provider, providerCfg] of Object.entries(channels)) {
- const groups = getRecord(getRecord(providerCfg)?.groups);
- if (!groups) continue;
- for (const [groupKey, groupCfg] of Object.entries(groups)) {
- const toolsCfg = getRecord(getRecord(groupCfg)?.tools);
- if (mergeAlsoAllowIntoAllow(toolsCfg)) {
- mutated = true;
- changes.push(
- `Merged channels.${provider}.groups.${groupKey}.tools.alsoAllow into allow (and removed alsoAllow).`,
- );
- }
- }
- }
- }
-
- return mutated;
-}
+// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
{
@@ -102,13 +28,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
},
},
- {
- id: "tools.alsoAllow-merge",
- describe: "Merge tools.alsoAllow into allow when allow is present",
- apply: (raw, changes) => {
- migrateAlsoAllowInToolConfig(raw, changes);
- },
- },
+ // tools.alsoAllow migration removed (field not shipped in prod; enforce via schema instead).
{
id: "tools.bash->tools.exec",
describe: "Move tools.bash to tools.exec",
From 1371e95e571cec35a7bc9e1bda3e7354cbcab4a5 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Mon, 26 Jan 2026 20:26:03 +0000
Subject: [PATCH 07/66] docs: clarify onboarding + credentials
---
docs/cli/onboard.md | 1 +
docs/gateway/security.md | 12 ++++++++++++
docs/start/setup.md | 1 +
docs/start/wizard.md | 3 +++
4 files changed, 17 insertions(+)
diff --git a/docs/cli/onboard.md b/docs/cli/onboard.md
index bd100c460..22cf0037e 100644
--- a/docs/cli/onboard.md
+++ b/docs/cli/onboard.md
@@ -23,3 +23,4 @@ clawdbot onboard --mode remote --remote-url ws://gateway-host:18789
Flow notes:
- `quickstart`: minimal prompts, auto-generates a gateway token.
- `manual`: full prompts for port/bind/auth (alias of `advanced`).
+- Fastest first chat: `clawdbot dashboard` (Control UI, no channel setup).
diff --git a/docs/gateway/security.md b/docs/gateway/security.md
index 3b8f9f036..cee21c7c2 100644
--- a/docs/gateway/security.md
+++ b/docs/gateway/security.md
@@ -43,6 +43,18 @@ Start with the smallest access that still works, then widen it as you gain confi
If you run `--deep`, Clawdbot also attempts a best-effort live Gateway probe.
+## Credential storage map
+
+Use this when auditing access or deciding what to back up:
+
+- **WhatsApp**: `~/.clawdbot/credentials/whatsapp//creds.json`
+- **Telegram bot token**: config/env or `channels.telegram.tokenFile`
+- **Discord bot token**: config/env (token file not yet supported)
+- **Slack tokens**: config/env (`channels.slack.*`)
+- **Pairing allowlists**: `~/.clawdbot/credentials/-allowFrom.json`
+- **Model auth profiles**: `~/.clawdbot/agents//agent/auth-profiles.json`
+- **Legacy OAuth import**: `~/.clawdbot/credentials/oauth.json`
+
## Security Audit Checklist
When the audit prints findings, treat this as a priority order:
diff --git a/docs/start/setup.md b/docs/start/setup.md
index f4024a50d..ec525b7b6 100644
--- a/docs/start/setup.md
+++ b/docs/start/setup.md
@@ -115,6 +115,7 @@ Use this when debugging auth or deciding what to back up:
- **Pairing allowlists**: `~/.clawdbot/credentials/-allowFrom.json`
- **Model auth profiles**: `~/.clawdbot/agents//agent/auth-profiles.json`
- **Legacy OAuth import**: `~/.clawdbot/credentials/oauth.json`
+More detail: [Security](/gateway/security#credential-storage-map).
## Updating (without wrecking your setup)
diff --git a/docs/start/wizard.md b/docs/start/wizard.md
index 8d4866392..59eb69402 100644
--- a/docs/start/wizard.md
+++ b/docs/start/wizard.md
@@ -18,6 +18,9 @@ Primary entrypoint:
clawdbot onboard
```
+Fastest first chat: open the Control UI (no channel setup needed). Run
+`clawdbot dashboard` and chat in the browser. Docs: [Dashboard](/web/dashboard).
+
Follow‑up reconfiguration:
```bash
From a5b99349c9dcd7d26c8bbcee19014f2fdb0054c3 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Mon, 26 Jan 2026 20:28:06 +0000
Subject: [PATCH 08/66] style: format workspace bootstrap signature
---
src/agents/workspace.ts | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts
index 8692977eb..0cef8e5f0 100644
--- a/src/agents/workspace.ts
+++ b/src/agents/workspace.ts
@@ -188,9 +188,9 @@ export async function ensureAgentWorkspace(params?: {
};
}
-async function resolveMemoryBootstrapEntries(resolvedDir: string): Promise<
- Array<{ name: WorkspaceBootstrapFileName; filePath: string }>
-> {
+async function resolveMemoryBootstrapEntries(
+ resolvedDir: string,
+): Promise> {
const candidates: WorkspaceBootstrapFileName[] = [
DEFAULT_MEMORY_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME,
From 8e051a418fcc1529611684f33c92020ed3f12b6b Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Mon, 26 Jan 2026 20:28:09 +0000
Subject: [PATCH 09/66] test: stub windows ACL for include perms audit
---
src/security/audit.test.ts | 21 +++++++++++++++++++++
1 file changed, 21 insertions(+)
diff --git a/src/security/audit.test.ts b/src/security/audit.test.ts
index e87a6b47c..1006934d3 100644
--- a/src/security/audit.test.ts
+++ b/src/security/audit.test.ts
@@ -862,12 +862,33 @@ describe("security audit", () => {
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) {
+ return {
+ stdout: `${target} NT AUTHORITY\\SYSTEM:(F)\n BUILTIN\\Users:(W)\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,
});
const expectedCheckId = isWindows
From 9e6b45faab44200382bc34c4f14bea5ca24a289f Mon Sep 17 00:00:00 2001
From: Paul Pamment
Date: Mon, 26 Jan 2026 17:00:34 +0000
Subject: [PATCH 10/66] fix(discord): honor threadId for thread-reply
---
src/channels/plugins/actions/discord.test.ts | 26 +++++++++++++++++++
.../discord/handle-action.guild-admin.ts | 8 +++++-
2 files changed, 33 insertions(+), 1 deletion(-)
diff --git a/src/channels/plugins/actions/discord.test.ts b/src/channels/plugins/actions/discord.test.ts
index 67047410e..9cc184e6c 100644
--- a/src/channels/plugins/actions/discord.test.ts
+++ b/src/channels/plugins/actions/discord.test.ts
@@ -127,4 +127,30 @@ describe("handleDiscordMessageAction", () => {
}),
);
});
+
+ it("accepts threadId for thread replies (tool compatibility)", async () => {
+ sendMessageDiscord.mockClear();
+ const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
+
+ await handleDiscordMessageAction({
+ action: "thread-reply",
+ params: {
+ // The `message` tool uses `threadId`.
+ threadId: "999",
+ // Include a conflicting channelId to ensure threadId takes precedence.
+ channelId: "123",
+ message: "hi",
+ },
+ cfg: {} as ClawdbotConfig,
+ accountId: "ops",
+ });
+
+ expect(sendMessageDiscord).toHaveBeenCalledWith(
+ "channel:999",
+ "hi",
+ expect.objectContaining({
+ accountId: "ops",
+ }),
+ );
+ });
});
diff --git a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts
index d65d044e2..5a3b13f61 100644
--- a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts
+++ b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts
@@ -393,11 +393,17 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
});
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
const replyTo = readStringParam(actionParams, "replyTo");
+
+ // `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`.
+ // Prefer `threadId` when present to avoid accidentally replying in the parent channel.
+ const threadId = readStringParam(actionParams, "threadId");
+ const channelId = threadId ?? resolveChannelId();
+
return await handleDiscordAction(
{
action: "threadReply",
accountId: accountId ?? undefined,
- channelId: resolveChannelId(),
+ channelId,
content,
mediaUrl: mediaUrl ?? undefined,
replyTo: replyTo ?? undefined,
From ec75e0b3dce1e3f4dabfca55d193d5d156be59af Mon Sep 17 00:00:00 2001
From: Shadow
Date: Mon, 26 Jan 2026 14:36:20 -0600
Subject: [PATCH 11/66] CI: use app token for auto-response
---
.github/workflows/auto-response.yml | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml
index 7f242a094..e4a9ac6f2 100644
--- a/.github/workflows/auto-response.yml
+++ b/.github/workflows/auto-response.yml
@@ -14,9 +14,15 @@ jobs:
auto-response:
runs-on: ubuntu-latest
steps:
+ - uses: actions/create-github-app-token@v1
+ id: app-token
+ with:
+ app-id: "2729701"
+ private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
- name: Handle labeled items
uses: actions/github-script@v7
with:
+ github-token: ${{ steps.app-token.outputs.token }}
script: |
const rules = [
{
From bdea26570402262b44af63031840fcc859637afe Mon Sep 17 00:00:00 2001
From: Shadow
Date: Mon, 26 Jan 2026 14:37:39 -0600
Subject: [PATCH 12/66] CI: run auto-response on pull_request_target
---
.github/workflows/auto-response.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml
index e4a9ac6f2..b610e1718 100644
--- a/.github/workflows/auto-response.yml
+++ b/.github/workflows/auto-response.yml
@@ -3,7 +3,7 @@ name: Auto response
on:
issues:
types: [labeled]
- pull_request:
+ pull_request_target:
types: [labeled]
permissions:
From fbc5ac1fde27dbc4088eb7adb5d65388ae643a92 Mon Sep 17 00:00:00 2001
From: Vignesh
Date: Mon, 26 Jan 2026 12:59:06 -0800
Subject: [PATCH 13/66] docs(install): add migration guide for moving to a new
machine (#2381)
* docs(install): add migration guide for moving to a new machine
* chore(changelog): mention migration guide docs
---------
Co-authored-by: Pocket Clawd
---
CHANGELOG.md | 1 +
docs/help/faq.md | 2 +-
docs/install/index.md | 1 +
docs/install/migrating.md | 190 ++++++++++++++++++++++++++++++++++++++
4 files changed, 193 insertions(+), 1 deletion(-)
create mode 100644 docs/install/migrating.md
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ce49a181..422ee8aa4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ Status: unreleased.
### Changes
- 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)
- 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.
diff --git a/docs/help/faq.md b/docs/help/faq.md
index f4e177f8d..336b324c9 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -401,7 +401,7 @@ remote mode, remember the gateway host owns the session store and workspace.
up **memory + bootstrap files**, but **not** session history or auth. Those live
under `~/.clawdbot/` (for example `~/.clawdbot/agents//sessions/`).
-Related: [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
+Related: [Migrating](/install/migrating), [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
[Agent workspace](/concepts/agent-workspace), [Doctor](/gateway/doctor),
[Remote mode](/gateway/remote).
diff --git a/docs/install/index.md b/docs/install/index.md
index dde0e5eeb..7ccab0ca8 100644
--- a/docs/install/index.md
+++ b/docs/install/index.md
@@ -177,4 +177,5 @@ Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
## Update / uninstall
- Updates: [Updating](/install/updating)
+- Migrate to a new machine: [Migrating](/install/migrating)
- Uninstall: [Uninstall](/install/uninstall)
diff --git a/docs/install/migrating.md b/docs/install/migrating.md
new file mode 100644
index 000000000..4987b38b9
--- /dev/null
+++ b/docs/install/migrating.md
@@ -0,0 +1,190 @@
+---
+summary: "Move (migrate) a Clawdbot install from one machine to another"
+read_when:
+ - You are moving Clawdbot to a new laptop/server
+ - You want to preserve sessions, auth, and channel logins (WhatsApp, etc.)
+---
+# Migrating Clawdbot to a new machine
+
+This guide migrates a Clawdbot Gateway from one machine to another **without redoing onboarding**.
+
+The migration is simple conceptually:
+
+- Copy the **state directory** (`$CLAWDBOT_STATE_DIR`, default: `~/.clawdbot/`) — this includes config, auth, sessions, and channel state.
+- Copy your **workspace** (`~/clawd/` by default) — this includes your agent files (memory, prompts, etc.).
+
+But there are common footguns around **profiles**, **permissions**, and **partial copies**.
+
+## Before you start (what you are migrating)
+
+### 1) Identify your state directory
+
+Most installs use the default:
+
+- **State dir:** `~/.clawdbot/`
+
+But it may be different if you use:
+
+- `--profile ` (often becomes `~/.clawdbot-/`)
+- `CLAWDBOT_STATE_DIR=/some/path`
+
+If you’re not sure, run on the **old** machine:
+
+```bash
+clawdbot status
+```
+
+Look for mentions of `CLAWDBOT_STATE_DIR` / profile in the output. If you run multiple gateways, repeat for each profile.
+
+### 2) Identify your workspace
+
+Common defaults:
+
+- `~/clawd/` (recommended workspace)
+- a custom folder you created
+
+Your workspace is where files like `MEMORY.md`, `USER.md`, and `memory/*.md` live.
+
+### 3) Understand what you will preserve
+
+If you copy **both** the state dir and workspace, you keep:
+
+- Gateway configuration (`clawdbot.json`)
+- Auth profiles / API keys / OAuth tokens
+- Session history + agent state
+- Channel state (e.g. WhatsApp login/session)
+- Your workspace files (memory, skills notes, etc.)
+
+If you copy **only** the workspace (e.g., via Git), you do **not** preserve:
+
+- sessions
+- credentials
+- channel logins
+
+Those live under `$CLAWDBOT_STATE_DIR`.
+
+## Migration steps (recommended)
+
+### Step 0 — Make a backup (old machine)
+
+On the **old** machine, stop the gateway first so files aren’t changing mid-copy:
+
+```bash
+clawdbot gateway stop
+```
+
+(Optional but recommended) archive the state dir and workspace:
+
+```bash
+# Adjust paths if you use a profile or custom locations
+cd ~
+tar -czf clawdbot-state.tgz .clawdbot
+
+tar -czf clawd-workspace.tgz clawd
+```
+
+If you have multiple profiles/state dirs (e.g. `~/.clawdbot-main`, `~/.clawdbot-work`), archive each.
+
+### Step 1 — Install Clawdbot on the new machine
+
+On the **new** machine, install the CLI (and Node if needed):
+
+- See: [Install](/install)
+
+At this stage, it’s OK if onboarding creates a fresh `~/.clawdbot/` — you will overwrite it in the next step.
+
+### Step 2 — Copy the state dir + workspace to the new machine
+
+Copy **both**:
+
+- `$CLAWDBOT_STATE_DIR` (default `~/.clawdbot/`)
+- your workspace (default `~/clawd/`)
+
+Common approaches:
+
+- `scp` the tarballs and extract
+- `rsync -a` over SSH
+- external drive
+
+After copying, ensure:
+
+- Hidden directories were included (e.g. `.clawdbot/`)
+- File ownership is correct for the user running the gateway
+
+### Step 3 — Run Doctor (migrations + service repair)
+
+On the **new** machine:
+
+```bash
+clawdbot doctor
+```
+
+Doctor is the “safe boring” command. It repairs services, applies config migrations, and warns about mismatches.
+
+Then:
+
+```bash
+clawdbot gateway restart
+clawdbot status
+```
+
+## Common footguns (and how to avoid them)
+
+### Footgun: profile / state-dir mismatch
+
+If you ran the old gateway with a profile (or `CLAWDBOT_STATE_DIR`), and the new gateway uses a different one, you’ll see symptoms like:
+
+- config changes not taking effect
+- channels missing / logged out
+- empty session history
+
+Fix: run the gateway/service using the **same** profile/state dir you migrated, then rerun:
+
+```bash
+clawdbot doctor
+```
+
+### Footgun: copying only `clawdbot.json`
+
+`clawdbot.json` is not enough. Many providers store state under:
+
+- `$CLAWDBOT_STATE_DIR/credentials/`
+- `$CLAWDBOT_STATE_DIR/agents//...`
+
+Always migrate the entire `$CLAWDBOT_STATE_DIR` folder.
+
+### Footgun: permissions / ownership
+
+If you copied as root or changed users, the gateway may fail to read credentials/sessions.
+
+Fix: ensure the state dir + workspace are owned by the user running the gateway.
+
+### Footgun: migrating between remote/local modes
+
+- If your UI (WebUI/TUI) points at a **remote** gateway, the remote host owns the session store + workspace.
+- Migrating your laptop won’t move the remote gateway’s state.
+
+If you’re in remote mode, migrate the **gateway host**.
+
+### Footgun: secrets in backups
+
+`$CLAWDBOT_STATE_DIR` contains secrets (API keys, OAuth tokens, WhatsApp creds). Treat backups like production secrets:
+
+- store encrypted
+- avoid sharing over insecure channels
+- rotate keys if you suspect exposure
+
+## Verification checklist
+
+On the new machine, confirm:
+
+- `clawdbot status` shows the gateway running
+- Your channels are still connected (e.g. WhatsApp doesn’t require re-pair)
+- The dashboard opens and shows existing sessions
+- Your workspace files (memory, configs) are present
+
+## Related
+
+- [Doctor](/gateway/doctor)
+- [Gateway troubleshooting](/gateway/troubleshooting)
+- [Where does Clawdbot store its data?](/help/faq#where-does-clawdbot-store-its-data)
From d34ae86114c7a2726df10b4497616b32049ebcc9 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Mon, 26 Jan 2026 15:01:04 -0600
Subject: [PATCH 14/66] chore: expand labeler coverage
---
.github/labeler.yml | 36 ++++++++++++++++++++++++++++++++++++
1 file changed, 36 insertions(+)
diff --git a/.github/labeler.yml b/.github/labeler.yml
index f22868736..5c19fa418 100644
--- a/.github/labeler.yml
+++ b/.github/labeler.yml
@@ -138,6 +138,42 @@
- any-glob-to-any-file:
- "src/cli/**"
+"commands":
+ - changed-files:
+ - any-glob-to-any-file:
+ - "src/commands/**"
+
+"scripts":
+ - changed-files:
+ - any-glob-to-any-file:
+ - "scripts/**"
+
+"docker":
+ - changed-files:
+ - any-glob-to-any-file:
+ - "Dockerfile"
+ - "Dockerfile.*"
+ - "docker-compose.yml"
+ - "docker-setup.sh"
+ - ".dockerignore"
+ - "scripts/**/*docker*"
+ - "scripts/**/Dockerfile*"
+ - "scripts/sandbox-*.sh"
+ - "src/agents/sandbox*.ts"
+ - "src/commands/sandbox*.ts"
+ - "src/cli/sandbox-cli.ts"
+ - "src/docker-setup.test.ts"
+ - "src/config/**/*sandbox*"
+ - "docs/cli/sandbox.md"
+ - "docs/gateway/sandbox*.md"
+ - "docs/install/docker.md"
+ - "docs/multi-agent-sandbox-tools.md"
+
+"agents":
+ - changed-files:
+ - any-glob-to-any-file:
+ - "src/agents/**"
+
"security":
- changed-files:
- any-glob-to-any-file:
From fb141460334f90ce9f8d159575cf342cd5567744 Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Mon, 26 Jan 2026 21:10:36 +0000
Subject: [PATCH 15/66] fix: harden ssh target handling
---
.../Sources/Clawdbot/CommandResolver.swift | 114 ++++++++++++++----
.../Sources/Clawdbot/GeneralSettings.swift | 72 ++++++-----
.../NodePairingApprovalPrompter.swift | 27 ++---
.../Clawdbot/OnboardingView+Pages.swift | 10 ++
.../Sources/Clawdbot/RemotePortTunnel.swift | 15 +--
.../CommandResolverTests.swift | 15 ++-
.../MasterDiscoveryMenuSmokeTests.swift | 14 ++-
src/gateway/tools-invoke-http.test.ts | 6 +-
8 files changed, 196 insertions(+), 77 deletions(-)
diff --git a/apps/macos/Sources/Clawdbot/CommandResolver.swift b/apps/macos/Sources/Clawdbot/CommandResolver.swift
index 7661c48f1..f83638b10 100644
--- a/apps/macos/Sources/Clawdbot/CommandResolver.swift
+++ b/apps/macos/Sources/Clawdbot/CommandResolver.swift
@@ -282,22 +282,6 @@ enum CommandResolver {
guard !settings.target.isEmpty else { return nil }
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
- var args: [String] = [
- "-o", "BatchMode=yes",
- "-o", "StrictHostKeyChecking=accept-new",
- "-o", "UpdateHostKeys=yes",
- ]
- if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
- let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
- if !identity.isEmpty {
- // Only use IdentitiesOnly when an explicit identity file is provided.
- // This allows 1Password SSH agent and other SSH agents to provide keys.
- args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
- args.append(contentsOf: ["-i", identity])
- }
- let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
- args.append(userHost)
-
// Run the real clawdbot CLI on the remote host.
let exportedPath = [
"/opt/homebrew/bin",
@@ -324,7 +308,7 @@ enum CommandResolver {
} else {
"""
PRJ=\(self.shellQuote(userPRJ))
- cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
+ cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
"""
}
@@ -378,7 +362,16 @@ enum CommandResolver {
echo "clawdbot CLI missing on remote host"; exit 127;
fi
"""
- args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
+ let options: [String] = [
+ "-o", "BatchMode=yes",
+ "-o", "StrictHostKeyChecking=accept-new",
+ "-o", "UpdateHostKeys=yes",
+ ]
+ let args = self.sshArguments(
+ target: parsed,
+ identity: settings.identity,
+ options: options,
+ remoteCommand: ["/bin/sh", "-c", scriptBody])
return ["/usr/bin/ssh"] + args
}
@@ -427,8 +420,11 @@ enum CommandResolver {
}
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
- let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmed = self.normalizeSSHTargetInput(target)
guard !trimmed.isEmpty else { return nil }
+ if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
+ return nil
+ }
let userHostPort: String
let user: String?
if let atRange = trimmed.range(of: "@") {
@@ -444,13 +440,31 @@ enum CommandResolver {
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
host = String(userHostPort[.. 0, parsedPort <= 65535 else {
+ return nil
+ }
+ port = parsedPort
} else {
host = userHostPort
port = 22
}
- return SSHParsedTarget(user: user, host: host, port: port)
+ return self.makeSSHTarget(user: user, host: host, port: port)
+ }
+
+ static func sshTargetValidationMessage(_ target: String) -> String? {
+ let trimmed = self.normalizeSSHTargetInput(target)
+ guard !trimmed.isEmpty else { return nil }
+ if trimmed.hasPrefix("-") {
+ return "SSH target cannot start with '-'"
+ }
+ if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
+ return "SSH target cannot contain spaces"
+ }
+ if self.parseSSHTarget(trimmed) == nil {
+ return "SSH target must look like user@host[:port]"
+ }
+ return nil
}
private static func shellQuote(_ text: String) -> String {
@@ -468,6 +482,64 @@ enum CommandResolver {
return URL(fileURLWithPath: expanded)
}
+ private static func normalizeSSHTargetInput(_ target: String) -> String {
+ var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.hasPrefix("ssh ") {
+ trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ return trimmed
+ }
+
+ private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool {
+ if value.isEmpty { return false }
+ if !allowLeadingDash, value.hasPrefix("-") { return false }
+ let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters)
+ return value.rangeOfCharacter(from: invalid) == nil
+ }
+
+ static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? {
+ let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard self.isValidSSHComponent(trimmedHost) else { return nil }
+ let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let normalizedUser: String?
+ if let trimmedUser {
+ guard self.isValidSSHComponent(trimmedUser) else { return nil }
+ normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser
+ } else {
+ normalizedUser = nil
+ }
+ guard port > 0, port <= 65535 else { return nil }
+ return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port)
+ }
+
+ private static func sshTargetString(_ target: SSHParsedTarget) -> String {
+ target.user.map { "\($0)@\(target.host)" } ?? target.host
+ }
+
+ static func sshArguments(
+ target: SSHParsedTarget,
+ identity: String,
+ options: [String],
+ remoteCommand: [String] = []) -> [String]
+ {
+ var args = options
+ if target.port > 0 {
+ args.append(contentsOf: ["-p", String(target.port)])
+ }
+ let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedIdentity.isEmpty {
+ // Only use IdentitiesOnly when an explicit identity file is provided.
+ // This allows 1Password SSH agent and other SSH agents to provide keys.
+ args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
+ args.append(contentsOf: ["-i", trimmedIdentity])
+ }
+ args.append("--")
+ args.append(self.sshTargetString(target))
+ args.append(contentsOf: remoteCommand)
+ return args
+ }
+
#if SWIFT_PACKAGE
static func _testNodeManagerBinPaths(home: URL) -> [String] {
self.nodeManagerBinPaths(home: home)
diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift
index 18dd423a2..b315ad32e 100644
--- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift
+++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift
@@ -243,25 +243,36 @@ struct GeneralSettings: View {
}
private var remoteSshRow: some View {
- HStack(alignment: .center, spacing: 10) {
- Text("SSH target")
- .font(.callout.weight(.semibold))
- .frame(width: self.remoteLabelWidth, alignment: .leading)
- TextField("user@host[:22]", text: self.$state.remoteTarget)
- .textFieldStyle(.roundedBorder)
- .frame(maxWidth: .infinity)
- Button {
- Task { await self.testRemote() }
- } label: {
- if self.remoteStatus == .checking {
- ProgressView().controlSize(.small)
- } else {
- Text("Test remote")
+ let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
+ let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget)
+ let canTest = !trimmedTarget.isEmpty && validationMessage == nil
+
+ return VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .center, spacing: 10) {
+ Text("SSH target")
+ .font(.callout.weight(.semibold))
+ .frame(width: self.remoteLabelWidth, alignment: .leading)
+ TextField("user@host[:22]", text: self.$state.remoteTarget)
+ .textFieldStyle(.roundedBorder)
+ .frame(maxWidth: .infinity)
+ Button {
+ Task { await self.testRemote() }
+ } label: {
+ if self.remoteStatus == .checking {
+ ProgressView().controlSize(.small)
+ } else {
+ Text("Test remote")
+ }
}
+ .buttonStyle(.borderedProminent)
+ .disabled(self.remoteStatus == .checking || !canTest)
+ }
+ if let validationMessage {
+ Text(validationMessage)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .padding(.leading, self.remoteLabelWidth + 10)
}
- .buttonStyle(.borderedProminent)
- .disabled(self.remoteStatus == .checking || self.state.remoteTarget
- .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
@@ -540,8 +551,15 @@ extension GeneralSettings {
}
// Step 1: basic SSH reachability check
+ guard let sshCommand = Self.sshCheckCommand(
+ target: settings.target,
+ identity: settings.identity)
+ else {
+ self.remoteStatus = .failed("SSH target is invalid")
+ return
+ }
let sshResult = await ShellExecutor.run(
- command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
+ command: sshCommand,
cwd: nil,
env: nil,
timeout: 8)
@@ -587,20 +605,20 @@ extension GeneralSettings {
return !host.isEmpty
}
- private static func sshCheckCommand(target: String, identity: String) -> [String] {
- var args: [String] = [
- "/usr/bin/ssh",
+ private static func sshCheckCommand(target: String, identity: String) -> [String]? {
+ guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
+ let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
- if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- args.append(contentsOf: ["-i", identity])
- }
- args.append(target)
- args.append("echo ok")
- return args
+ let args = CommandResolver.sshArguments(
+ target: parsed,
+ identity: identity,
+ options: options,
+ remoteCommand: ["echo", "ok"])
+ return ["/usr/bin/ssh"] + args
}
private func formatSSHFailure(_ response: Response, target: String) -> String {
diff --git a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift
index b3f7e9295..e81b7a914 100644
--- a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift
+++ b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift
@@ -559,22 +559,21 @@ final class NodePairingApprovalPrompter {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
- var args = [
- "-o",
- "BatchMode=yes",
- "-o",
- "ConnectTimeout=5",
- "-o",
- "NumberOfPasswordPrompts=0",
- "-o",
- "PreferredAuthentications=publickey",
- "-o",
- "StrictHostKeyChecking=accept-new",
+ let options = [
+ "-o", "BatchMode=yes",
+ "-o", "ConnectTimeout=5",
+ "-o", "NumberOfPasswordPrompts=0",
+ "-o", "PreferredAuthentications=publickey",
+ "-o", "StrictHostKeyChecking=accept-new",
]
- if port > 0, port != 22 {
- args.append(contentsOf: ["-p", String(port)])
+ guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
+ return false
}
- args.append(contentsOf: ["-l", user, host, "/usr/bin/true"])
+ let args = CommandResolver.sshArguments(
+ target: target,
+ identity: "",
+ options: options,
+ remoteCommand: ["/usr/bin/true"])
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift
index 5c5eead34..9abbcf972 100644
--- a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift
+++ b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift
@@ -206,6 +206,16 @@ extension OnboardingView {
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
+ if let message = CommandResolver.sshTargetValidationMessage(self.state.remoteTarget) {
+ GridRow {
+ Text("")
+ .frame(width: labelWidth, alignment: .leading)
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .frame(width: fieldWidth, alignment: .leading)
+ }
+ }
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
diff --git a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
index 8eaee1c05..4206a3750 100644
--- a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
+++ b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
@@ -70,7 +70,7 @@ final class RemotePortTunnel {
"ssh tunnel using default remote port " +
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
}
- var args: [String] = [
+ let options: [String] = [
"-o", "BatchMode=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new",
@@ -81,16 +81,11 @@ final class RemotePortTunnel {
"-N",
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
]
- if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
- if !identity.isEmpty {
- // Only use IdentitiesOnly when an explicit identity file is provided.
- // This allows 1Password SSH agent and other SSH agents to provide keys.
- args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
- args.append(contentsOf: ["-i", identity])
- }
- let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
- args.append(userHost)
+ let args = CommandResolver.sshArguments(
+ target: parsed,
+ identity: identity,
+ options: options)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
diff --git a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift
index 827057888..d8daa17f6 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift
@@ -123,11 +123,16 @@ import Testing
configRoot: [:])
#expect(cmd.first == "/usr/bin/ssh")
- #expect(cmd.contains("clawd@example.com"))
+ if let marker = cmd.firstIndex(of: "--") {
+ #expect(cmd[marker + 1] == "clawd@example.com")
+ } else {
+ #expect(Bool(false))
+ }
#expect(cmd.contains("-i"))
#expect(cmd.contains("/tmp/id_ed25519"))
if let script = cmd.last {
- #expect(script.contains("cd '/srv/clawdbot'"))
+ #expect(script.contains("PRJ='/srv/clawdbot'"))
+ #expect(script.contains("cd \"$PRJ\""))
#expect(script.contains("clawdbot"))
#expect(script.contains("status"))
#expect(script.contains("--json"))
@@ -135,6 +140,12 @@ import Testing
}
}
+ @Test func rejectsUnsafeSSHTargets() async throws {
+ #expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
+ #expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
+ #expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
+ }
+
@Test func configRootLocalOverridesRemoteDefaults() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
diff --git a/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift
index 10630c202..2541e0634 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift
@@ -11,7 +11,12 @@ struct MasterDiscoveryMenuSmokeTests {
discovery.statusText = "Searching…"
discovery.gateways = []
- let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in })
+ let view = GatewayDiscoveryInlineList(
+ discovery: discovery,
+ currentTarget: nil,
+ currentUrl: nil,
+ transport: .ssh,
+ onSelect: { _ in })
_ = view.body
}
@@ -32,7 +37,12 @@ struct MasterDiscoveryMenuSmokeTests {
]
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
- let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in })
+ let view = GatewayDiscoveryInlineList(
+ discovery: discovery,
+ currentTarget: currentTarget,
+ currentUrl: nil,
+ transport: .ssh,
+ onSelect: { _ in })
_ = view.body
}
diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts
index f08035885..a32c728a1 100644
--- a/src/gateway/tools-invoke-http.test.ts
+++ b/src/gateway/tools-invoke-http.test.ts
@@ -1,10 +1,13 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IncomingMessage, ServerResponse } from "node:http";
+import { promises as fs } from "node:fs";
+import path from "node:path";
import { installGatewayTestHooks, getFreePort, startGatewayServer } from "./test-helpers.server.js";
import { resetTestPluginRegistry, setTestPluginRegistry, testState } from "./test-helpers.mocks.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
+import { CONFIG_PATH_CLAWDBOT } from "../config/config.js";
installGatewayTestHooks({ scope: "suite" });
@@ -97,10 +100,11 @@ describe("POST /tools/invoke", () => {
const port = await getFreePort();
const server = await startGatewayServer(port, { bind: "loopback" });
+ const token = resolveGatewayToken();
const res = await fetch(`http://127.0.0.1:${port}/tools/invoke`, {
method: "POST",
- headers: { "content-type": "application/json" },
+ headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ tool: "sessions_list", action: "json", args: {}, sessionKey: "main" }),
});
From 20f6a5546fad7a1a1f934320c152308c01f6cb50 Mon Sep 17 00:00:00 2001
From: Suksham
Date: Tue, 27 Jan 2026 02:44:13 +0530
Subject: [PATCH 16/66] feat(telegram): add silent message option (#2382)
* feat(telegram): add silent message option (disable_notification)
Add support for sending Telegram messages silently without notification
sound via the `silent` parameter on the message tool.
Changes:
- Add `silent` boolean to message tool schema
- Extract and pass `silent` through telegram plugin
- Add `disable_notification: true` to Telegram API calls
- Add `--silent` flag to CLI `message send` command
- Add unit test for silent flag
Closes #2249
AI-assisted (Claude) - fully tested with unit tests + manual Telegram testing
* feat(telegram): add silent send option (#2382) (thanks @Suksham-sharma)
---------
Co-authored-by: Pocket Clawd
---
CHANGELOG.md | 1 +
src/agents/tools/message-tool.ts | 1 +
src/agents/tools/telegram-actions.ts | 1 +
src/channels/plugins/actions/telegram.test.ts | 26 +++++++++++++++++++
src/channels/plugins/actions/telegram.ts | 2 ++
src/cli/program/message/register.send.ts | 3 ++-
src/config/zod-schema.agent-runtime.ts | 3 ++-
...send.returns-undefined-empty-input.test.ts | 22 ++++++++++++++++
src/telegram/send.ts | 4 +++
9 files changed, 61 insertions(+), 2 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 422ee8aa4..8f1330931 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -41,6 +41,7 @@ Status: unreleased.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- 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.
- 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/message-tool.ts b/src/agents/tools/message-tool.ts
index eae4356db..73969cb54 100644
--- a/src/agents/tools/message-tool.ts
+++ b/src/agents/tools/message-tool.ts
@@ -59,6 +59,7 @@ function buildSendSchema(options: { includeButtons: boolean; includeCards: boole
replyTo: Type.Optional(Type.String()),
threadId: Type.Optional(Type.String()),
asVoice: Type.Optional(Type.Boolean()),
+ silent: Type.Optional(Type.Boolean()),
bestEffort: Type.Optional(Type.Boolean()),
gifPlayback: Type.Optional(Type.Boolean()),
buttons: Type.Optional(
diff --git a/src/agents/tools/telegram-actions.ts b/src/agents/tools/telegram-actions.ts
index 5385dd10f..c167ac32a 100644
--- a/src/agents/tools/telegram-actions.ts
+++ b/src/agents/tools/telegram-actions.ts
@@ -176,6 +176,7 @@ export async function handleTelegramAction(
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,
asVoice: typeof params.asVoice === "boolean" ? params.asVoice : undefined,
+ silent: typeof params.silent === "boolean" ? params.silent : undefined,
});
return jsonResult({
ok: true,
diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts
index aac316858..6b79bf5ba 100644
--- a/src/channels/plugins/actions/telegram.test.ts
+++ b/src/channels/plugins/actions/telegram.test.ts
@@ -36,4 +36,30 @@ describe("telegramMessageActions", () => {
cfg,
);
});
+
+ it("passes silent flag for silent sends", async () => {
+ handleTelegramAction.mockClear();
+ const cfg = { channels: { telegram: { botToken: "tok" } } } as ClawdbotConfig;
+
+ await telegramMessageActions.handleAction({
+ action: "send",
+ params: {
+ to: "456",
+ message: "Silent notification test",
+ silent: true,
+ },
+ cfg,
+ accountId: undefined,
+ });
+
+ expect(handleTelegramAction).toHaveBeenCalledWith(
+ expect.objectContaining({
+ action: "sendMessage",
+ to: "456",
+ content: "Silent notification test",
+ silent: true,
+ }),
+ cfg,
+ );
+ });
});
diff --git a/src/channels/plugins/actions/telegram.ts b/src/channels/plugins/actions/telegram.ts
index fe4e41307..e281772bd 100644
--- a/src/channels/plugins/actions/telegram.ts
+++ b/src/channels/plugins/actions/telegram.ts
@@ -20,6 +20,7 @@ function readTelegramSendParams(params: Record) {
const threadId = readStringParam(params, "threadId");
const buttons = params.buttons;
const asVoice = typeof params.asVoice === "boolean" ? params.asVoice : undefined;
+ const silent = typeof params.silent === "boolean" ? params.silent : undefined;
return {
to,
content,
@@ -28,6 +29,7 @@ function readTelegramSendParams(params: Record) {
messageThreadId: threadId ?? undefined,
buttons,
asVoice,
+ silent,
};
}
diff --git a/src/cli/program/message/register.send.ts b/src/cli/program/message/register.send.ts
index 8841c3ce8..4ab3a852f 100644
--- a/src/cli/program/message/register.send.ts
+++ b/src/cli/program/message/register.send.ts
@@ -22,7 +22,8 @@ export function registerMessageSendCommand(message: Command, helpers: MessageCli
.option("--card ", "Adaptive Card JSON object (when supported by the channel)")
.option("--reply-to ", "Reply-to message id")
.option("--thread-id ", "Thread id (Telegram forum thread)")
- .option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false),
+ .option("--gif-playback", "Treat video media as GIF playback (WhatsApp only).", false)
+ .option("--silent", "Send message silently without notification (Telegram only)", false),
)
.action(async (opts) => {
await helpers.runMessageAction("send", opts);
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index 99074c55e..b5a03a3ea 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -159,7 +159,8 @@ export const ToolPolicySchema = ToolPolicyBaseSchema.superRefine((value, ctx) =>
if (value.allow && value.allow.length > 0 && value.alsoAllow && value.alsoAllow.length > 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: "tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
+ message:
+ "tools policy cannot set both allow and alsoAllow in the same scope (merge alsoAllow into allow, or remove allow and use profile + alsoAllow)",
});
}
}).optional();
diff --git a/src/telegram/send.returns-undefined-empty-input.test.ts b/src/telegram/send.returns-undefined-empty-input.test.ts
index d659c198b..6e2ea85d0 100644
--- a/src/telegram/send.returns-undefined-empty-input.test.ts
+++ b/src/telegram/send.returns-undefined-empty-input.test.ts
@@ -476,6 +476,28 @@ describe("sendMessageTelegram", () => {
});
});
+ it("sets disable_notification when silent is true", async () => {
+ const chatId = "123";
+ const sendMessage = vi.fn().mockResolvedValue({
+ message_id: 1,
+ chat: { id: chatId },
+ });
+ const api = { sendMessage } as unknown as {
+ sendMessage: typeof sendMessage;
+ };
+
+ await sendMessageTelegram(chatId, "hi", {
+ token: "tok",
+ api,
+ silent: true,
+ });
+
+ expect(sendMessage).toHaveBeenCalledWith(chatId, "hi", {
+ parse_mode: "HTML",
+ disable_notification: true,
+ });
+ });
+
it("parses message_thread_id from recipient string (telegram:group:...:topic:...)", async () => {
const chatId = "-1001234567890";
const sendMessage = vi.fn().mockResolvedValue({
diff --git a/src/telegram/send.ts b/src/telegram/send.ts
index 636676465..f9557bf1e 100644
--- a/src/telegram/send.ts
+++ b/src/telegram/send.ts
@@ -40,6 +40,8 @@ type TelegramSendOpts = {
plainText?: string;
/** Send audio as voice message (voice bubble) instead of audio file. Defaults to false. */
asVoice?: boolean;
+ /** Send message silently (no notification). Defaults to false. */
+ silent?: boolean;
/** Message ID to reply to (for threading) */
replyToMessageId?: number;
/** Forum topic thread ID (for forum supergroups) */
@@ -245,6 +247,7 @@ export async function sendMessageTelegram(
const sendParams = {
parse_mode: "HTML" as const,
...baseParams,
+ ...(opts.silent === true ? { disable_notification: true } : {}),
};
const res = await requestWithDiag(
() => api.sendMessage(chatId, htmlText, sendParams),
@@ -298,6 +301,7 @@ export async function sendMessageTelegram(
caption: htmlCaption,
...(htmlCaption ? { parse_mode: "HTML" as const } : {}),
...baseMediaParams,
+ ...(opts.silent === true ? { disable_notification: true } : {}),
};
let result:
| Awaited>
From 820ab8765a09df5240548fb8693b140b9ebcb79e Mon Sep 17 00:00:00 2001
From: Peter Steinberger
Date: Mon, 26 Jan 2026 21:37:52 +0000
Subject: [PATCH 17/66] docs: clarify exec defaults
---
docs/gateway/security.md | 2 +-
docs/tools/exec.md | 3 +++
2 files changed, 4 insertions(+), 1 deletion(-)
diff --git a/docs/gateway/security.md b/docs/gateway/security.md
index cee21c7c2..700e6fdaf 100644
--- a/docs/gateway/security.md
+++ b/docs/gateway/security.md
@@ -211,7 +211,7 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
- Prefer mention gating in groups; avoid “always-on” bots in public rooms.
- Treat links, attachments, and pasted instructions as hostile by default.
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
-- Note: sandboxing is opt-in; if sandbox mode is off, exec runs on the gateway host even though tools.exec.host defaults to sandbox.
+- Note: sandboxing is opt-in. If sandbox mode is off, exec runs on the gateway host even though tools.exec.host defaults to sandbox, and host exec does not require approvals unless you set host=gateway and configure exec approvals.
- Limit high-risk tools (`exec`, `browser`, `web_fetch`, `web_search`) to trusted agents or explicit allowlists.
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
diff --git a/docs/tools/exec.md b/docs/tools/exec.md
index e2088137b..9579a5c27 100644
--- a/docs/tools/exec.md
+++ b/docs/tools/exec.md
@@ -34,6 +34,9 @@ Notes:
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
- On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`)
from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists.
+- Important: sandboxing is **off by default**. If sandboxing is off, `host=sandbox` runs directly on
+ the gateway host (no container) and **does not require approvals**. To require approvals, run with
+ `host=gateway` and configure exec approvals (or enable sandboxing).
## Config
From 86fa9340ae428096171694257dcced1618c7cdd2 Mon Sep 17 00:00:00 2001
From: Dave Lauer
Date: Mon, 26 Jan 2026 16:40:13 -0500
Subject: [PATCH 18/66] fix: reset chat state on webchat reconnect after
gateway restart
When the gateway restarts, the WebSocket disconnects and any in-flight
chat.final events are lost. On reconnect, chatRunId/chatStream were
still set from the orphaned run, making the UI think a run was still
in progress and not updating properly.
Fix: Reset chatRunId, chatStream, chatStreamStartedAt, and tool stream
state in the onHello callback when the WebSocket reconnects.
Fixes issue where users had to refresh the page after gateway restart
to see completed messages.
---
ui/src/ui/app-gateway.ts | 6 ++++++
1 file changed, 6 insertions(+)
diff --git a/ui/src/ui/app-gateway.ts b/ui/src/ui/app-gateway.ts
index d9a267a98..0df25bbdf 100644
--- a/ui/src/ui/app-gateway.ts
+++ b/ui/src/ui/app-gateway.ts
@@ -127,6 +127,12 @@ export function connectGateway(host: GatewayHost) {
host.lastError = null;
host.hello = hello;
applySnapshot(host, hello);
+ // Reset orphaned chat run state from before disconnect.
+ // Any in-flight run's final event was lost during the disconnect window.
+ host.chatRunId = null;
+ (host as unknown as { chatStream: string | null }).chatStream = null;
+ (host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null;
+ resetToolStream(host as unknown as Parameters[0]);
void loadAssistantIdentity(host as unknown as ClawdbotApp);
void loadAgents(host as unknown as ClawdbotApp);
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });
From 6d269710518c68b6264bc0c9d4bf4da691e5aba5 Mon Sep 17 00:00:00 2001
From: Tyler Yust
Date: Sun, 25 Jan 2026 14:14:41 -0800
Subject: [PATCH 19/66] 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 20/66] 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 21/66] 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 22/66] 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 23/66] 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 24/66] 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 25/66] 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 26/66] 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 27/66] 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 28/66] 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 29/66] 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 30/66] 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 31/66] 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 32/66] 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 33/66] 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 34/66] 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 35/66] 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:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 36/66] 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 37/66] 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 38/66] 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 39/66] 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 40/66] 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 41/66] 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 42/66] 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:
-
-
-
-
-
-
+
+
+
+
+
+
@@ -500,12 +500,13 @@ Thanks to all clawtributors:
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
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 43/66] 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 44/66] 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 4b6347459bb268bf81bf07761a627988a41c2361 Mon Sep 17 00:00:00 2001
From: Dave Lauer
Date: Mon, 26 Jan 2026 20:03:25 -0500
Subject: [PATCH 45/66] fix: fallback to main agent OAuth credentials when
secondary agent refresh fails
When a secondary agent's OAuth token expires and refresh fails, the agent
would error out even if the main agent had fresh, valid credentials for
the same profile.
This fix adds a fallback mechanism that:
1. Detects when OAuth refresh fails for a secondary agent (agentDir is set)
2. Checks if the main agent has fresh credentials for the same profileId
3. If so, copies those credentials to the secondary agent and uses them
4. Logs the inheritance for debugging
This prevents the situation where users have to manually copy auth-profiles.json
between agent directories when tokens expire at different times.
Fixes: Secondary agents failing with 'OAuth token refresh failed' while main
agent continues to work fine.
---
.../oauth.fallback-to-main-agent.test.ts | 93 +++++++++++++++++++
src/agents/auth-profiles/oauth.ts | 28 +++++-
2 files changed, 120 insertions(+), 1 deletion(-)
create mode 100644 src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts
diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts
new file mode 100644
index 000000000..f00046338
--- /dev/null
+++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts
@@ -0,0 +1,93 @@
+import fs from "node:fs";
+import path from "node:path";
+import os from "node:os";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { resolveApiKeyForProfile } from "./oauth.js";
+import type { AuthProfileStore } from "./types.js";
+
+describe("resolveApiKeyForProfile", () => {
+ let tmpDir: string;
+ let mainAgentDir: string;
+ let secondaryAgentDir: string;
+
+ beforeEach(async () => {
+ tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "oauth-test-"));
+ mainAgentDir = path.join(tmpDir, "agents", "main", "agent");
+ secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent");
+ await fs.promises.mkdir(mainAgentDir, { recursive: true });
+ await fs.promises.mkdir(secondaryAgentDir, { recursive: true });
+
+ // Set env to use our temp dir
+ process.env.CLAWDBOT_STATE_DIR = tmpDir;
+ });
+
+ afterEach(async () => {
+ delete process.env.CLAWDBOT_STATE_DIR;
+ await fs.promises.rm(tmpDir, { recursive: true, force: true });
+ vi.restoreAllMocks();
+ });
+
+ it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => {
+ const profileId = "anthropic:claude-cli";
+ const now = Date.now();
+ const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
+ const freshTime = now + 60 * 60 * 1000; // 1 hour from now
+
+ // Write expired credentials for secondary agent
+ const secondaryStore: AuthProfileStore = {
+ version: 1,
+ profiles: {
+ [profileId]: {
+ type: "oauth",
+ provider: "anthropic",
+ access: "expired-access-token",
+ refresh: "expired-refresh-token",
+ expires: expiredTime,
+ },
+ },
+ };
+ await fs.promises.writeFile(
+ path.join(secondaryAgentDir, "auth-profiles.json"),
+ JSON.stringify(secondaryStore),
+ );
+
+ // Write fresh credentials for main agent
+ const mainStore: AuthProfileStore = {
+ version: 1,
+ profiles: {
+ [profileId]: {
+ type: "oauth",
+ provider: "anthropic",
+ access: "fresh-access-token",
+ refresh: "fresh-refresh-token",
+ expires: freshTime,
+ },
+ },
+ };
+ await fs.promises.writeFile(
+ path.join(mainAgentDir, "auth-profiles.json"),
+ JSON.stringify(mainStore),
+ );
+
+ // The secondary agent should fall back to main agent's credentials
+ // when its own token refresh fails
+ const result = await resolveApiKeyForProfile({
+ store: secondaryStore,
+ profileId,
+ agentDir: secondaryAgentDir,
+ });
+
+ expect(result).not.toBeNull();
+ expect(result?.apiKey).toBe("fresh-access-token");
+ expect(result?.provider).toBe("anthropic");
+
+ // Verify the credentials were copied to the secondary agent
+ const updatedSecondaryStore = JSON.parse(
+ await fs.promises.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
+ ) as AuthProfileStore;
+ expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
+ access: "fresh-access-token",
+ expires: freshTime,
+ });
+ });
+});
diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts
index 4138cda94..d7b3360de 100644
--- a/src/agents/auth-profiles/oauth.ts
+++ b/src/agents/auth-profiles/oauth.ts
@@ -4,7 +4,7 @@ import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../../config/config.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
-import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js";
+import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
import { formatAuthDoctorHint } from "./doctor.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
@@ -196,6 +196,32 @@ export async function resolveApiKeyForProfile(params: {
// keep original error
}
}
+
+ // Fallback: if this is a secondary agent, try using the main agent's credentials
+ if (params.agentDir) {
+ try {
+ const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir)
+ const mainCred = mainStore.profiles[profileId];
+ if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) {
+ // Main agent has fresh credentials - copy them to this agent and use them
+ refreshedStore.profiles[profileId] = { ...mainCred };
+ saveAuthProfileStore(refreshedStore, params.agentDir);
+ log.info("inherited fresh OAuth credentials from main agent", {
+ profileId,
+ agentDir: params.agentDir,
+ expires: new Date(mainCred.expires).toISOString(),
+ });
+ return {
+ apiKey: buildOAuthApiKey(mainCred.provider, mainCred),
+ provider: mainCred.provider,
+ email: mainCred.email,
+ };
+ }
+ } catch {
+ // keep original error if main agent fallback also fails
+ }
+ }
+
const message = error instanceof Error ? error.message : String(error);
const hint = formatAuthDoctorHint({
cfg,
From 1e7cb23f00f4232bd8ac31c87a1a6b8881c62d1a Mon Sep 17 00:00:00 2001
From: Shadow
Date: Mon, 26 Jan 2026 15:38:35 -0600
Subject: [PATCH 46/66] 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:
-
+
+
@@ -509,4 +510,5 @@ Thanks to all clawtributors:
+
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 47/66] 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 48/66] 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 49/66] 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 50/66] 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 51/66] 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 52/66] 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 53/66] =?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 54/66] 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 55/66] 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:
-
-
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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 56/66] 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 57/66] 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 58/66] 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 59/66] 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 60/66] 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 61/66] 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,
+};
From 9e200068dc65a2b0e3253141eb50d961497a15f7 Mon Sep 17 00:00:00 2001
From: Shadow
Date: Mon, 26 Jan 2026 20:25:06 -0600
Subject: [PATCH 62/66] telegram: centralize api error logging
---
src/telegram/api-logging.ts | 41 ++++++++++++
src/telegram/bot-handlers.ts | 20 ++++--
src/telegram/bot-message-context.ts | 51 +++++++++------
src/telegram/bot-native-commands.ts | 69 +++++++++++++++-----
src/telegram/bot.ts | 7 ++-
src/telegram/bot/delivery.ts | 97 ++++++++++++++++-------------
src/telegram/send.ts | 21 +++++--
src/telegram/webhook-set.ts | 16 +++--
src/telegram/webhook.ts | 12 +++-
9 files changed, 234 insertions(+), 100 deletions(-)
create mode 100644 src/telegram/api-logging.ts
diff --git a/src/telegram/api-logging.ts b/src/telegram/api-logging.ts
new file mode 100644
index 000000000..110fd4e34
--- /dev/null
+++ b/src/telegram/api-logging.ts
@@ -0,0 +1,41 @@
+import { danger } from "../globals.js";
+import { formatErrorMessage } from "../infra/errors.js";
+import { createSubsystemLogger } from "../logging/subsystem.js";
+import type { RuntimeEnv } from "../runtime.js";
+
+export type TelegramApiLogger = (message: string) => void;
+
+type TelegramApiLoggingParams = {
+ operation: string;
+ fn: () => Promise;
+ runtime?: RuntimeEnv;
+ logger?: TelegramApiLogger;
+ shouldLog?: (err: unknown) => boolean;
+};
+
+const fallbackLogger = createSubsystemLogger("telegram/api");
+
+function resolveTelegramApiLogger(runtime?: RuntimeEnv, logger?: TelegramApiLogger) {
+ if (logger) return logger;
+ if (runtime?.error) return runtime.error;
+ return (message: string) => fallbackLogger.error(message);
+}
+
+export async function withTelegramApiErrorLogging({
+ operation,
+ fn,
+ runtime,
+ logger,
+ shouldLog,
+}: TelegramApiLoggingParams): Promise {
+ try {
+ return await fn();
+ } catch (err) {
+ if (!shouldLog || shouldLog(err)) {
+ const errText = formatErrorMessage(err);
+ const log = resolveTelegramApiLogger(runtime, logger);
+ log(danger(`telegram ${operation} failed: ${errText}`));
+ }
+ throw err;
+ }
+}
diff --git a/src/telegram/bot-handlers.ts b/src/telegram/bot-handlers.ts
index 8dfcc5ac1..f7ddb256f 100644
--- a/src/telegram/bot-handlers.ts
+++ b/src/telegram/bot-handlers.ts
@@ -8,6 +8,7 @@ import { loadConfig } from "../config/config.js";
import { writeConfigFile } from "../config/io.js";
import { danger, logVerbose, warn } from "../globals.js";
import { resolveMedia } from "./bot/delivery.js";
+import { withTelegramApiErrorLogging } from "./api-logging.js";
import { resolveTelegramForumThreadId } from "./bot/helpers.js";
import type { TelegramMessage } from "./bot/types.js";
import { firstDefined, isSenderAllowed, normalizeAllowFromWithStore } from "./bot-access.js";
@@ -180,7 +181,11 @@ export const registerTelegramHandlers = ({
if (!callback) return;
if (shouldSkipUpdate(ctx)) return;
// Answer immediately to prevent Telegram from retrying while we process
- await bot.api.answerCallbackQuery(callback.id).catch(() => {});
+ await withTelegramApiErrorLogging({
+ operation: "answerCallbackQuery",
+ runtime,
+ fn: () => bot.api.answerCallbackQuery(callback.id),
+ }).catch(() => {});
try {
const data = (callback.data ?? "").trim();
const callbackMessage = callback.message;
@@ -577,11 +582,14 @@ export const registerTelegramHandlers = ({
const errMsg = String(mediaErr);
if (errMsg.includes("exceeds") && errMsg.includes("MB limit")) {
const limitMb = Math.round(mediaMaxBytes / (1024 * 1024));
- await bot.api
- .sendMessage(chatId, `⚠️ File too large. Maximum size is ${limitMb}MB.`, {
- reply_to_message_id: msg.message_id,
- })
- .catch(() => {});
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ runtime,
+ fn: () =>
+ bot.api.sendMessage(chatId, `⚠️ File too large. Maximum size is ${limitMb}MB.`, {
+ reply_to_message_id: msg.message_id,
+ }),
+ }).catch(() => {});
logger.warn({ chatId, error: errMsg }, "media exceeds size limit");
return;
}
diff --git a/src/telegram/bot-message-context.ts b/src/telegram/bot-message-context.ts
index d90b6ffea..a054943a2 100644
--- a/src/telegram/bot-message-context.ts
+++ b/src/telegram/bot-message-context.ts
@@ -25,6 +25,7 @@ import { shouldAckReaction as shouldAckReactionGate } from "../channels/ack-reac
import { resolveMentionGatingWithBypass } from "../channels/mention-gating.js";
import { resolveControlCommandGate } from "../channels/command-gating.js";
import { logInboundDrop } from "../channels/logging.js";
+import { withTelegramApiErrorLogging } from "./api-logging.js";
import {
buildGroupLabel,
buildSenderLabel,
@@ -165,16 +166,19 @@ export const buildTelegramMessageContext = async ({
}
const sendTyping = async () => {
- await bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId));
+ await withTelegramApiErrorLogging({
+ operation: "sendChatAction",
+ fn: () => bot.api.sendChatAction(chatId, "typing", buildTypingThreadParams(resolvedThreadId)),
+ });
};
const sendRecordVoice = async () => {
try {
- await bot.api.sendChatAction(
- chatId,
- "record_voice",
- buildTypingThreadParams(resolvedThreadId),
- );
+ await withTelegramApiErrorLogging({
+ operation: "sendChatAction",
+ fn: () =>
+ bot.api.sendChatAction(chatId, "record_voice", buildTypingThreadParams(resolvedThreadId)),
+ });
} catch (err) {
logVerbose(`telegram record_voice cue failed for chat ${chatId}: ${String(err)}`);
}
@@ -227,19 +231,23 @@ export const buildTelegramMessageContext = async ({
},
"telegram pairing request",
);
- await bot.api.sendMessage(
- chatId,
- [
- "Clawdbot: access not configured.",
- "",
- `Your Telegram user id: ${telegramUserId}`,
- "",
- `Pairing code: ${code}`,
- "",
- "Ask the bot owner to approve with:",
- formatCliCommand("clawdbot pairing approve telegram "),
- ].join("\n"),
- );
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ fn: () =>
+ bot.api.sendMessage(
+ chatId,
+ [
+ "Clawdbot: access not configured.",
+ "",
+ `Your Telegram user id: ${telegramUserId}`,
+ "",
+ `Pairing code: ${code}`,
+ "",
+ "Ask the bot owner to approve with:",
+ formatCliCommand("clawdbot pairing approve telegram "),
+ ].join("\n"),
+ ),
+ });
}
} catch (err) {
logVerbose(`telegram pairing reply failed for chat ${chatId}: ${String(err)}`);
@@ -408,7 +416,10 @@ export const buildTelegramMessageContext = async ({
typeof api.setMessageReaction === "function" ? api.setMessageReaction.bind(api) : null;
const ackReactionPromise =
shouldAckReaction() && msg.message_id && reactionApi
- ? reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReaction }]).then(
+ ? withTelegramApiErrorLogging({
+ operation: "setMessageReaction",
+ fn: () => reactionApi(chatId, msg.message_id, [{ type: "emoji", emoji: ackReaction }]),
+ }).then(
() => true,
(err) => {
logVerbose(`telegram react failed for chat ${chatId}: ${String(err)}`);
diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts
index e9d287d0d..3cdb3d72e 100644
--- a/src/telegram/bot-native-commands.ts
+++ b/src/telegram/bot-native-commands.ts
@@ -17,6 +17,7 @@ import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/pr
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import { danger, logVerbose } from "../globals.js";
import { resolveMarkdownTableMode } from "../config/markdown-tables.js";
+import { withTelegramApiErrorLogging } from "./api-logging.js";
import {
normalizeTelegramCommandName,
TELEGRAM_COMMAND_NAME_PATTERN,
@@ -134,11 +135,17 @@ async function resolveTelegramCommandAuth(params: {
const senderUsername = msg.from?.username ?? "";
if (isGroup && groupConfig?.enabled === false) {
- await bot.api.sendMessage(chatId, "This group is disabled.");
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ fn: () => bot.api.sendMessage(chatId, "This group is disabled."),
+ });
return null;
}
if (isGroup && topicConfig?.enabled === false) {
- await bot.api.sendMessage(chatId, "This topic is disabled.");
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ fn: () => bot.api.sendMessage(chatId, "This topic is disabled."),
+ });
return null;
}
if (requireAuth && isGroup && hasGroupAllowOverride) {
@@ -150,7 +157,10 @@ async function resolveTelegramCommandAuth(params: {
senderUsername,
})
) {
- await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ fn: () => bot.api.sendMessage(chatId, "You are not authorized to use this command."),
+ });
return null;
}
}
@@ -159,7 +169,10 @@ async function resolveTelegramCommandAuth(params: {
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = telegramCfg.groupPolicy ?? defaultGroupPolicy ?? "open";
if (groupPolicy === "disabled") {
- await bot.api.sendMessage(chatId, "Telegram group commands are disabled.");
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ fn: () => bot.api.sendMessage(chatId, "Telegram group commands are disabled."),
+ });
return null;
}
if (groupPolicy === "allowlist" && requireAuth) {
@@ -171,13 +184,19 @@ async function resolveTelegramCommandAuth(params: {
senderUsername,
})
) {
- await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ fn: () => bot.api.sendMessage(chatId, "You are not authorized to use this command."),
+ });
return null;
}
}
const groupAllowlist = resolveGroupPolicy(chatId);
if (groupAllowlist.allowlistEnabled && !groupAllowlist.allowed) {
- await bot.api.sendMessage(chatId, "This group is not allowed.");
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ fn: () => bot.api.sendMessage(chatId, "This group is not allowed."),
+ });
return null;
}
}
@@ -197,7 +216,10 @@ async function resolveTelegramCommandAuth(params: {
modeWhenAccessGroupsOff: "configured",
});
if (requireAuth && !commandAuthorized) {
- await bot.api.sendMessage(chatId, "You are not authorized to use this command.");
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ fn: () => bot.api.sendMessage(chatId, "You are not authorized to use this command."),
+ });
return null;
}
@@ -300,9 +322,11 @@ export const registerTelegramNativeCommands = ({
];
if (allCommands.length > 0) {
- bot.api.setMyCommands(allCommands).catch((err) => {
- runtime.error?.(danger(`telegram setMyCommands failed: ${String(err)}`));
- });
+ void withTelegramApiErrorLogging({
+ operation: "setMyCommands",
+ runtime,
+ fn: () => bot.api.setMyCommands(allCommands),
+ }).catch(() => {});
if (typeof (bot as unknown as { command?: unknown }).command !== "function") {
logVerbose("telegram: bot.command unavailable; skipping native handlers");
@@ -376,9 +400,14 @@ export const registerTelegramNativeCommands = ({
);
}
const replyMarkup = buildInlineKeyboard(rows);
- await bot.api.sendMessage(chatId, title, {
- ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
- ...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}),
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ runtime,
+ fn: () =>
+ bot.api.sendMessage(chatId, title, {
+ ...(replyMarkup ? { reply_markup: replyMarkup } : {}),
+ ...(resolvedThreadId != null ? { message_thread_id: resolvedThreadId } : {}),
+ }),
});
return;
}
@@ -492,7 +521,11 @@ export const registerTelegramNativeCommands = ({
const commandBody = `/${pluginCommand.command}${rawText ? ` ${rawText}` : ""}`;
const match = matchPluginCommand(commandBody);
if (!match) {
- await bot.api.sendMessage(chatId, "Command not found.");
+ await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ runtime,
+ fn: () => bot.api.sendMessage(chatId, "Command not found."),
+ });
return;
}
const auth = await resolveTelegramCommandAuth({
@@ -543,8 +576,10 @@ export const registerTelegramNativeCommands = ({
}
}
} else if (nativeDisabledExplicit) {
- bot.api.setMyCommands([]).catch((err) => {
- runtime.error?.(danger(`telegram clear commands failed: ${String(err)}`));
- });
+ void withTelegramApiErrorLogging({
+ operation: "setMyCommands",
+ runtime,
+ fn: () => bot.api.setMyCommands([]),
+ }).catch(() => {});
}
};
diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts
index 6705d359f..d855554d0 100644
--- a/src/telegram/bot.ts
+++ b/src/telegram/bot.ts
@@ -24,6 +24,7 @@ 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 { withTelegramApiErrorLogging } from "./api-logging.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
@@ -261,7 +262,11 @@ export function createTelegramBot(opts: TelegramBotOptions) {
}
if (typeof botHasTopicsEnabled === "boolean") return botHasTopicsEnabled;
try {
- const me = (await bot.api.getMe()) as { has_topics_enabled?: boolean };
+ const me = (await withTelegramApiErrorLogging({
+ operation: "getMe",
+ runtime,
+ fn: () => bot.api.getMe(),
+ })) as { has_topics_enabled?: boolean };
botHasTopicsEnabled = Boolean(me?.has_topics_enabled);
} catch (err) {
logVerbose(`telegram getMe failed: ${String(err)}`);
diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts
index 7a3748e5b..c2489300c 100644
--- a/src/telegram/bot/delivery.ts
+++ b/src/telegram/bot/delivery.ts
@@ -4,6 +4,7 @@ import {
markdownToTelegramHtml,
renderTelegramHtmlText,
} from "../format.js";
+import { withTelegramApiErrorLogging } from "../api-logging.js";
import { chunkMarkdownTextWithMode, type ChunkMode } from "../../auto-reply/chunk.js";
import { splitTelegramCaption } from "../caption.js";
import type { ReplyPayload } from "../../auto-reply/types.js";
@@ -25,24 +26,6 @@ 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;
@@ -164,17 +147,23 @@ export async function deliverReplies(params: {
mediaParams.message_thread_id = threadParams.message_thread_id;
}
if (isGif) {
- await withMediaErrorHandler("sendAnimation", runtime, () =>
- bot.api.sendAnimation(chatId, file, { ...mediaParams }),
- );
+ await withTelegramApiErrorLogging({
+ operation: "sendAnimation",
+ runtime,
+ fn: () => bot.api.sendAnimation(chatId, file, { ...mediaParams }),
+ });
} else if (kind === "image") {
- await withMediaErrorHandler("sendPhoto", runtime, () =>
- bot.api.sendPhoto(chatId, file, { ...mediaParams }),
- );
+ await withTelegramApiErrorLogging({
+ operation: "sendPhoto",
+ runtime,
+ fn: () => bot.api.sendPhoto(chatId, file, { ...mediaParams }),
+ });
} else if (kind === "video") {
- await withMediaErrorHandler("sendVideo", runtime, () =>
- bot.api.sendVideo(chatId, file, { ...mediaParams }),
- );
+ await withTelegramApiErrorLogging({
+ operation: "sendVideo",
+ runtime,
+ fn: () => bot.api.sendVideo(chatId, file, { ...mediaParams }),
+ });
} else if (kind === "audio") {
const { useVoice } = resolveTelegramVoiceSend({
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
@@ -187,9 +176,12 @@ export async function deliverReplies(params: {
// Switch typing indicator to record_voice before sending.
await params.onVoiceRecording?.();
try {
- await withMediaErrorHandler("sendVoice", runtime, () =>
- bot.api.sendVoice(chatId, file, { ...mediaParams }),
- );
+ await withTelegramApiErrorLogging({
+ operation: "sendVoice",
+ runtime,
+ shouldLog: (err) => !isVoiceMessagesForbidden(err),
+ fn: () => 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
@@ -222,14 +214,18 @@ export async function deliverReplies(params: {
}
} else {
// Audio file - displays with metadata (title, duration) - DEFAULT
- await withMediaErrorHandler("sendAudio", runtime, () =>
- bot.api.sendAudio(chatId, file, { ...mediaParams }),
- );
+ await withTelegramApiErrorLogging({
+ operation: "sendAudio",
+ runtime,
+ fn: () => bot.api.sendAudio(chatId, file, { ...mediaParams }),
+ });
}
} else {
- await withMediaErrorHandler("sendDocument", runtime, () =>
- bot.api.sendDocument(chatId, file, { ...mediaParams }),
- );
+ await withTelegramApiErrorLogging({
+ operation: "sendDocument",
+ runtime,
+ fn: () => bot.api.sendDocument(chatId, file, { ...mediaParams }),
+ });
}
if (replyToId && !hasReplied) {
hasReplied = true;
@@ -371,11 +367,17 @@ async function sendTelegramText(
const textMode = opts?.textMode ?? "markdown";
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
try {
- const res = await bot.api.sendMessage(chatId, htmlText, {
- parse_mode: "HTML",
- ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
- ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
- ...baseParams,
+ const res = await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ runtime,
+ shouldLog: (err) => !PARSE_ERR_RE.test(formatErrorMessage(err)),
+ fn: () =>
+ bot.api.sendMessage(chatId, htmlText, {
+ parse_mode: "HTML",
+ ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
+ ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
+ ...baseParams,
+ }),
});
return res.message_id;
} catch (err) {
@@ -383,10 +385,15 @@ async function sendTelegramText(
if (PARSE_ERR_RE.test(errText)) {
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`);
const fallbackText = opts?.plainText ?? text;
- const res = await bot.api.sendMessage(chatId, fallbackText, {
- ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
- ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
- ...baseParams,
+ const res = await withTelegramApiErrorLogging({
+ operation: "sendMessage",
+ runtime,
+ fn: () =>
+ bot.api.sendMessage(chatId, fallbackText, {
+ ...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
+ ...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
+ ...baseParams,
+ }),
});
return res.message_id;
}
diff --git a/src/telegram/send.ts b/src/telegram/send.ts
index d28cff55e..92cd3ddc1 100644
--- a/src/telegram/send.ts
+++ b/src/telegram/send.ts
@@ -8,6 +8,7 @@ import { type ApiClientOptions, Bot, HttpError, InputFile } from "grammy";
import { loadConfig } from "../config/config.js";
import { logVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
+import { withTelegramApiErrorLogging } from "./api-logging.js";
import { formatErrorMessage, formatUncaughtError } from "../infra/errors.js";
import { isDiagnosticFlagEnabled } from "../infra/diagnostic-flags.js";
import type { RetryConfig } from "../infra/retry.js";
@@ -210,7 +211,10 @@ export async function sendMessageTelegram(
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = (fn: () => Promise, label?: string) =>
- request(fn, label).catch((err) => {
+ withTelegramApiErrorLogging({
+ operation: label ?? "request",
+ fn: () => request(fn, label),
+ }).catch((err) => {
logHttpError(label ?? "request", err);
throw err;
});
@@ -442,7 +446,10 @@ export async function reactMessageTelegram(
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = (fn: () => Promise, label?: string) =>
- request(fn, label).catch((err) => {
+ withTelegramApiErrorLogging({
+ operation: label ?? "request",
+ fn: () => request(fn, label),
+ }).catch((err) => {
logHttpError(label ?? "request", err);
throw err;
});
@@ -492,7 +499,10 @@ export async function deleteMessageTelegram(
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = (fn: () => Promise, label?: string) =>
- request(fn, label).catch((err) => {
+ withTelegramApiErrorLogging({
+ operation: label ?? "request",
+ fn: () => request(fn, label),
+ }).catch((err) => {
logHttpError(label ?? "request", err);
throw err;
});
@@ -537,7 +547,10 @@ export async function editMessageTelegram(
});
const logHttpError = createTelegramHttpLogger(cfg);
const requestWithDiag = (fn: () => Promise, label?: string) =>
- request(fn, label).catch((err) => {
+ withTelegramApiErrorLogging({
+ operation: label ?? "request",
+ fn: () => request(fn, label),
+ }).catch((err) => {
logHttpError(label ?? "request", err);
throw err;
});
diff --git a/src/telegram/webhook-set.ts b/src/telegram/webhook-set.ts
index 2880c8254..0d2e815fc 100644
--- a/src/telegram/webhook-set.ts
+++ b/src/telegram/webhook-set.ts
@@ -1,6 +1,7 @@
import { type ApiClientOptions, Bot } from "grammy";
import type { TelegramNetworkConfig } from "../config/types.telegram.js";
import { resolveTelegramFetch } from "./fetch.js";
+import { withTelegramApiErrorLogging } from "./api-logging.js";
export async function setTelegramWebhook(opts: {
token: string;
@@ -14,9 +15,13 @@ export async function setTelegramWebhook(opts: {
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined;
const bot = new Bot(opts.token, client ? { client } : undefined);
- await bot.api.setWebhook(opts.url, {
- secret_token: opts.secret,
- drop_pending_updates: opts.dropPendingUpdates ?? false,
+ await withTelegramApiErrorLogging({
+ operation: "setWebhook",
+ fn: () =>
+ bot.api.setWebhook(opts.url, {
+ secret_token: opts.secret,
+ drop_pending_updates: opts.dropPendingUpdates ?? false,
+ }),
});
}
@@ -29,5 +34,8 @@ export async function deleteTelegramWebhook(opts: {
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined;
const bot = new Bot(opts.token, client ? { client } : undefined);
- await bot.api.deleteWebhook();
+ await withTelegramApiErrorLogging({
+ operation: "deleteWebhook",
+ fn: () => bot.api.deleteWebhook(),
+ });
}
diff --git a/src/telegram/webhook.ts b/src/telegram/webhook.ts
index 4d341bb88..d8c0a30f0 100644
--- a/src/telegram/webhook.ts
+++ b/src/telegram/webhook.ts
@@ -15,6 +15,7 @@ import {
} from "../logging/diagnostic.js";
import { resolveTelegramAllowedUpdates } from "./allowed-updates.js";
import { createTelegramBot } from "./bot.js";
+import { withTelegramApiErrorLogging } from "./api-logging.js";
export async function startTelegramWebhook(opts: {
token: string;
@@ -97,9 +98,14 @@ export async function startTelegramWebhook(opts: {
const publicUrl =
opts.publicUrl ?? `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
- await bot.api.setWebhook(publicUrl, {
- secret_token: opts.secret,
- allowed_updates: resolveTelegramAllowedUpdates(),
+ await withTelegramApiErrorLogging({
+ operation: "setWebhook",
+ runtime,
+ fn: () =>
+ bot.api.setWebhook(publicUrl, {
+ secret_token: opts.secret,
+ allowed_updates: resolveTelegramAllowedUpdates(),
+ }),
});
await new Promise((resolve) => server.listen(port, host, resolve));
From 7d5221bcb2e1bfb12773d5ae1732f4fc59ca3d3b Mon Sep 17 00:00:00 2001
From: Shadow
Date: Mon, 26 Jan 2026 20:30:29 -0600
Subject: [PATCH 63/66] fix: centralize telegram api error logging (#2492)
(thanks @altryne)
---
CHANGELOG.md | 2 +-
README.md | 50 +++++++++++++++++++++++++-------------------------
2 files changed, 26 insertions(+), 26 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 791dbcd7d..00aa560a4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -67,7 +67,7 @@ Status: unreleased.
- 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.
+- Telegram: centralize API error logging for delivery and bot calls. (#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.
diff --git a/README.md b/README.md
index a5daba163..db80c6cd0 100644
--- a/README.md
+++ b/README.md
@@ -484,29 +484,29 @@ Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
From dde9605874696e36a4246cd3164e2abd855c131d Mon Sep 17 00:00:00 2001
From: jigar
Date: Tue, 27 Jan 2026 07:35:54 +0530
Subject: [PATCH 64/66] Agents: summarize dropped messages during compaction
safeguard pruning (#2418)
---
CHANGELOG.md | 1 +
src/agents/compaction.test.ts | 42 ++++++++++++++++
src/agents/compaction.ts | 4 ++
src/agents/pi-embedded-runner/extensions.ts | 5 ++
.../compaction-safeguard-runtime.ts | 34 +++++++++++++
.../compaction-safeguard.test.ts | 42 ++++++++++++++++
.../pi-extensions/compaction-safeguard.ts | 49 +++++++++++++++++--
src/config/types.agent-defaults.ts | 2 +
src/config/zod-schema.agent-defaults.ts | 1 +
9 files changed, 177 insertions(+), 3 deletions(-)
create mode 100644 src/agents/pi-extensions/compaction-safeguard-runtime.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 00aa560a4..4a373ff5f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ Docs: https://docs.clawd.bot
Status: unreleased.
### Changes
+- Agents: summarize dropped messages during compaction safeguard pruning. (#2418)
- 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.
diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts
index 1cfacda9a..32511a586 100644
--- a/src/agents/compaction.test.ts
+++ b/src/agents/compaction.test.ts
@@ -103,5 +103,47 @@ describe("pruneHistoryForContextShare", () => {
expect(pruned.droppedChunks).toBe(0);
expect(pruned.messages.length).toBe(messages.length);
expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages));
+ expect(pruned.droppedMessagesList).toEqual([]);
+ });
+
+ it("returns droppedMessagesList containing dropped messages", () => {
+ const messages: AgentMessage[] = [
+ makeMessage(1, 4000),
+ makeMessage(2, 4000),
+ makeMessage(3, 4000),
+ makeMessage(4, 4000),
+ ];
+ const maxContextTokens = 2000; // budget is 1000 tokens (50%)
+ const pruned = pruneHistoryForContextShare({
+ messages,
+ maxContextTokens,
+ maxHistoryShare: 0.5,
+ parts: 2,
+ });
+
+ expect(pruned.droppedChunks).toBeGreaterThan(0);
+ expect(pruned.droppedMessagesList.length).toBe(pruned.droppedMessages);
+
+ // All messages accounted for: kept + dropped = original
+ const allIds = [
+ ...pruned.droppedMessagesList.map((m) => m.timestamp),
+ ...pruned.messages.map((m) => m.timestamp),
+ ].sort((a, b) => a - b);
+ const originalIds = messages.map((m) => m.timestamp).sort((a, b) => a - b);
+ expect(allIds).toEqual(originalIds);
+ });
+
+ it("returns empty droppedMessagesList when no pruning needed", () => {
+ const messages: AgentMessage[] = [makeMessage(1, 100)];
+ const pruned = pruneHistoryForContextShare({
+ messages,
+ maxContextTokens: 100_000,
+ maxHistoryShare: 0.5,
+ parts: 2,
+ });
+
+ expect(pruned.droppedChunks).toBe(0);
+ expect(pruned.droppedMessagesList).toEqual([]);
+ expect(pruned.messages.length).toBe(1);
});
});
diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts
index 2ab4566fd..a88447307 100644
--- a/src/agents/compaction.ts
+++ b/src/agents/compaction.ts
@@ -301,6 +301,7 @@ export function pruneHistoryForContextShare(params: {
parts?: number;
}): {
messages: AgentMessage[];
+ droppedMessagesList: AgentMessage[];
droppedChunks: number;
droppedMessages: number;
droppedTokens: number;
@@ -310,6 +311,7 @@ export function pruneHistoryForContextShare(params: {
const maxHistoryShare = params.maxHistoryShare ?? 0.5;
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
let keptMessages = params.messages;
+ const allDroppedMessages: AgentMessage[] = [];
let droppedChunks = 0;
let droppedMessages = 0;
let droppedTokens = 0;
@@ -323,11 +325,13 @@ export function pruneHistoryForContextShare(params: {
droppedChunks += 1;
droppedMessages += dropped.length;
droppedTokens += estimateMessagesTokens(dropped);
+ allDroppedMessages.push(...dropped);
keptMessages = rest.flat();
}
return {
messages: keptMessages,
+ droppedMessagesList: allDroppedMessages,
droppedChunks,
droppedMessages,
droppedTokens,
diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts
index 73deae21d..bb592e930 100644
--- a/src/agents/pi-embedded-runner/extensions.ts
+++ b/src/agents/pi-embedded-runner/extensions.ts
@@ -7,6 +7,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveContextWindowInfo } from "../context-window-guard.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
+import { setCompactionSafeguardRuntime } from "../pi-extensions/compaction-safeguard-runtime.js";
import { setContextPruningRuntime } from "../pi-extensions/context-pruning/runtime.js";
import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js";
import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js";
@@ -75,6 +76,10 @@ export function buildEmbeddedExtensionPaths(params: {
}): string[] {
const paths: string[] = [];
if (resolveCompactionMode(params.cfg) === "safeguard") {
+ const compactionCfg = params.cfg?.agents?.defaults?.compaction;
+ setCompactionSafeguardRuntime(params.sessionManager, {
+ maxHistoryShare: compactionCfg?.maxHistoryShare,
+ });
paths.push(resolvePiExtensionPath("compaction-safeguard"));
}
const pruning = buildContextPruningExtension(params);
diff --git a/src/agents/pi-extensions/compaction-safeguard-runtime.ts b/src/agents/pi-extensions/compaction-safeguard-runtime.ts
new file mode 100644
index 000000000..f42cf7abe
--- /dev/null
+++ b/src/agents/pi-extensions/compaction-safeguard-runtime.ts
@@ -0,0 +1,34 @@
+export type CompactionSafeguardRuntimeValue = {
+ maxHistoryShare?: number;
+};
+
+// Session-scoped runtime registry keyed by object identity.
+// Follows the same WeakMap pattern as context-pruning/runtime.ts.
+const REGISTRY = new WeakMap