From 2ad3508a33a6c49d6abcdd07c9ede0f17c5d560a Mon Sep 17 00:00:00 2001 From: Vignesh Natarajan Date: Sun, 25 Jan 2026 00:29:28 -0800 Subject: [PATCH 01/45] 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 02/45] 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 03/45] 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 04/45] 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 05/45] 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 97248a2885da7760db9149f3584da59bfd80b34c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 19:58:54 +0000 Subject: [PATCH 06/45] feat: surface security audit + docs --- docs/channels/discord.md | 9 +++++---- docs/start/getting-started.md | 5 +++++ docs/start/setup.md | 12 ++++++++++++ docs/tools/skills.md | 8 ++++++++ docs/web/dashboard.md | 4 ++++ ui/src/ui/views/debug.ts | 22 ++++++++++++++++++++++ 6 files changed, 56 insertions(+), 4 deletions(-) diff --git a/docs/channels/discord.md b/docs/channels/discord.md index 12dd28084..395f13c6a 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -10,13 +10,14 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa ## Quick setup (beginner) 1) Create a Discord bot and copy the bot token. -2) Set the token for Clawdbot: +2) In the Discord app settings, enable **Message Content Intent** (and **Server Members Intent** if you plan to use allowlists or name lookups). +3) Set the token for Clawdbot: - Env: `DISCORD_BOT_TOKEN=...` - Or config: `channels.discord.token: "..."`. - If both are set, config takes precedence (env fallback is default-account only). -3) Invite the bot to your server with message permissions. -4) Start the gateway. -5) DM access is pairing by default; approve the pairing code on first contact. +4) Invite the bot to your server with message permissions (create a private server if you just want DMs). +5) Start the gateway. +6) DM access is pairing by default; approve the pairing code on first contact. Minimal config: ```json5 diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index dd68b8f55..00bc00efb 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -9,6 +9,10 @@ read_when: Goal: go from **zero** → **first working chat** (with sane defaults) as quickly as possible. +Fastest chat: open the Control UI (no channel setup needed). Run `clawdbot dashboard` +and chat in the browser, or open `http://127.0.0.1:18789/` on the gateway host. +Docs: [Dashboard](/web/dashboard) and [Control UI](/web/control-ui). + Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up: - model/auth (OAuth recommended) - gateway settings @@ -121,6 +125,7 @@ channels. If you use WhatsApp or Telegram, run the Gateway with **Node**. ```bash clawdbot status clawdbot health +clawdbot security audit --deep ``` ## 4) Pair + connect your first chat surface diff --git a/docs/start/setup.md b/docs/start/setup.md index 587b7fd6b..f4024a50d 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -104,6 +104,18 @@ clawdbot health - Sessions: `~/.clawdbot/agents//sessions/` - Logs: `/tmp/clawdbot/` +## Credential storage map + +Use this when debugging auth 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` + ## Updating (without wrecking your setup) - Keep `~/clawd` and `~/.clawdbot/` as “your stuff”; don’t put personal prompts/config into the `clawdbot` repo. diff --git a/docs/tools/skills.md b/docs/tools/skills.md index 289118bae..d9c840d73 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -64,6 +64,14 @@ By default, `clawdhub` installs into `./skills` under your current working directory (or falls back to the configured Clawdbot workspace). Clawdbot picks that up as `/skills` on the next session. +## Security notes + +- Treat third-party skills as **trusted code**. Read them before enabling. +- Prefer sandboxed runs for untrusted inputs and risky tools. See [Sandboxing](/gateway/sandboxing). +- `skills.entries.*.env` and `skills.entries.*.apiKey` inject secrets into the **host** process + for that agent turn (not the sandbox). Keep secrets out of prompts and logs. +- For a broader threat model and checklists, see [Security](/gateway/security). + ## Format (AgentSkills + Pi-compatible) `SKILL.md` must include at least: diff --git a/docs/web/dashboard.md b/docs/web/dashboard.md index 81d0aacc4..fdbf209be 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -19,6 +19,10 @@ Key references: Authentication is enforced at the WebSocket handshake via `connect.params.auth` (token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration). +Security note: the Control UI is an **admin surface** (chat, config, exec approvals). +Do not expose it publicly. The UI stores the token in `localStorage` after first load. +Prefer localhost, Tailscale Serve, or an SSH tunnel. + ## Fast path (recommended) - After onboarding, the CLI now auto-opens the dashboard with your token and prints the same tokenized link. diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts index 35e2e1af2..d33eaffc7 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/ui/views/debug.ts @@ -21,6 +21,22 @@ export type DebugProps = { }; export function renderDebug(props: DebugProps) { + const securityAudit = + props.status && typeof props.status === "object" + ? (props.status as { securityAudit?: { summary?: Record } }).securityAudit + : null; + const securitySummary = securityAudit?.summary ?? null; + const critical = securitySummary?.critical ?? 0; + const warn = securitySummary?.warn ?? 0; + const info = securitySummary?.info ?? 0; + const securityTone = critical > 0 ? "danger" : warn > 0 ? "warn" : "success"; + const securityLabel = + critical > 0 + ? `${critical} critical` + : warn > 0 + ? `${warn} warnings` + : "No critical issues"; + return html`
@@ -36,6 +52,12 @@ export function renderDebug(props: DebugProps) {
Status
+ ${securitySummary + ? html`
+ Security audit: ${securityLabel}${info > 0 ? ` · ${info} info` : ""}. Run + clawdbot security audit --deep for details. +
` + : nothing}
${JSON.stringify(props.status ?? {}, null, 2)}
From 320b45c051a7bc20a02573ce0624533eda62fac6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 20:13:04 +0000 Subject: [PATCH 07/45] docs: note sandbox opt-in in gateway security --- docs/gateway/security.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/gateway/security.md b/docs/gateway/security.md index 564b248fe..3b8f9f036 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -199,6 +199,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. - 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)). From 1371e95e571cec35a7bc9e1bda3e7354cbcab4a5 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 26 Jan 2026 20:26:03 +0000 Subject: [PATCH 08/45] 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 09/45] 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 10/45] 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 11/45] 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 12/45] 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 13/45] 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 14/45] 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 15/45] 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 16/45] 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 17/45] 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 18/45] 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 6d269710518c68b6264bc0c9d4bf4da691e5aba5 Mon Sep 17 00:00:00 2001 From: Tyler Yust Date: Sun, 25 Jan 2026 14:14:41 -0800 Subject: [PATCH 19/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] Fix: allow cron heartbeat payloads through filters (#2219) (thanks @dwfinkelstein) # Conflicts: # CHANGELOG.md --- CHANGELOG.md | 1 + README.md | 62 ++++++++++++------------- src/auto-reply/reply/session-updates.ts | 6 ++- 3 files changed, 37 insertions(+), 32 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a45315b20..6d8725030 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -54,6 +54,7 @@ Status: unreleased. ### Fixes - Security: pin npm overrides to keep tar@7.5.4 for install toolchains. - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. +- Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. diff --git a/README.md b/README.md index 535cd1c75..3f8853b93 100644 --- a/README.md +++ b/README.md @@ -477,35 +477,35 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and Thanks to all clawtributors:

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

diff --git a/src/auto-reply/reply/session-updates.ts b/src/auto-reply/reply/session-updates.ts index 970a714d0..0fea27708 100644 --- a/src/auto-reply/reply/session-updates.ts +++ b/src/auto-reply/reply/session-updates.ts @@ -21,7 +21,11 @@ export async function prependSystemEvents(params: { if (!trimmed) return null; const lower = trimmed.toLowerCase(); if (lower.includes("reason periodic")) return null; - if (lower.includes("heartbeat")) return null; + // Filter out the actual heartbeat prompt, but not cron jobs that mention "heartbeat" + // The heartbeat prompt starts with "Read HEARTBEAT.md" - cron payloads won't match this + if (lower.startsWith("read heartbeat.md")) return null; + // Also filter heartbeat poll/wake noise + if (lower.includes("heartbeat poll") || lower.includes("heartbeat wake")) return null; if (trimmed.startsWith("Node:")) { return trimmed.replace(/ · last input [^·]+/i, "").trim(); } From 5aa02cf3f75d358e5e9eb666dd8b355c4aa0437b Mon Sep 17 00:00:00 2001 From: "Robby (AI-assisted)" Date: Mon, 26 Jan 2026 21:03:41 +0000 Subject: [PATCH 36/45] 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/45] 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/45] 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/45] 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/45] 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/45] 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/45] Telegram: harden network retries and config Co-authored-by: techboss --- CHANGELOG.md | 1 + README.md | 29 ++--- docs/channels/telegram.md | 1 + docs/gateway/configuration.md | 3 + src/config/schema.ts | 3 + src/config/types.telegram.ts | 7 ++ src/config/zod-schema.providers-core.ts | 6 + src/infra/retry-policy.test.ts | 27 +++++ src/infra/retry-policy.ts | 7 +- ...patterns-match-without-botusername.test.ts | 1 + ...topic-skill-filters-system-prompts.test.ts | 1 + ...-all-group-messages-grouppolicy-is.test.ts | 1 + ...e-callback-query-updates-by-update.test.ts | 1 + ...gram-bot.installs-grammy-throttler.test.ts | 1 + ...lowfrom-entries-case-insensitively.test.ts | 1 + ...-case-insensitively-grouppolicy-is.test.ts | 1 + ...-dms-by-telegram-accountid-binding.test.ts | 1 + ...ies-without-native-reply-threading.test.ts | 1 + ...s-media-file-path-no-file-download.test.ts | 1 + ...udes-location-text-ctx-fields-pins.test.ts | 1 + src/telegram/bot.test.ts | 1 + src/telegram/bot.ts | 8 +- src/telegram/fetch.test.ts | 43 ++++++- src/telegram/fetch.ts | 37 ++++-- src/telegram/monitor.test.ts | 46 ++++++- src/telegram/monitor.ts | 29 +---- src/telegram/network-config.test.ts | 48 ++++++++ src/telegram/network-config.ts | 39 ++++++ src/telegram/network-errors.test.ts | 31 +++++ src/telegram/network-errors.ts | 112 ++++++++++++++++++ src/telegram/send.caption-split.test.ts | 1 + ...-thread-params-plain-text-fallback.test.ts | 1 + src/telegram/send.proxy.test.ts | 7 +- ...send.returns-undefined-empty-input.test.ts | 1 + src/telegram/send.ts | 8 +- src/telegram/webhook-set.ts | 11 +- 36 files changed, 457 insertions(+), 61 deletions(-) create mode 100644 src/infra/retry-policy.test.ts create mode 100644 src/telegram/network-config.test.ts create mode 100644 src/telegram/network-config.ts create mode 100644 src/telegram/network-errors.test.ts create mode 100644 src/telegram/network-errors.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4667e60d5..a83519fef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Status: unreleased. - BlueBubbles: coalesce inbound URL link preview messages. (#1981) Thanks @tyler6204. - Cron: allow payloads containing "heartbeat" in event filter. (#2219) Thanks @dwfinkelstein. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. +- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default. - Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers. diff --git a/README.md b/README.md index 3f8853b93..e72fe7e16 100644 --- a/README.md +++ b/README.md @@ -485,12 +485,12 @@ Thanks to all clawtributors: julianengel bradleypriest benithors rohannagpal timolins f-trycua benostein nachx639 pvoo sreekaransrinath gupsammy cristip73 stefangalescu nachoiacovino Vasanth Rao Naik Sabavat petter-b cpojer scald gumadeiras andranik-sahakyan davidguttman sleontenko denysvitali thewilloftheshadow shakkernerd sircrumpet peschee rafaelreis-r ratulsarna lutr0 - danielz1z emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 CashWilliams - sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc travisirby - buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase uos-status gerardward2007 - roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee Josh Phillips YuriNachos robbyczgw-cla dlauer - pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 antons - austinm911 blacksmith-sh[bot] damoahdominic dan-dr dwfinkelstein HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi + danielz1z AdeboyeDN emanuelst KristijanJovanovski rdev rhuanssauro joshrad-dev kiranjd osolmaz adityashaw2 + CashWilliams sheeek artuskg Takhoffman onutc pauloportella neooriginal manuelhettich minghinmatthewlam myfunc + travisirby buddyh connorshea kyleok mcinteerj dependabot[bot] John-Rood obviyus timkrase uos-status + gerardward2007 roshanasingh4 tosh-hamburg azade-c JonUleis bjesuiter cheeeee robbyczgw-cla dlauer Josh Phillips + YuriNachos pookNast Whoaa512 chriseidhof ngutman ysqander aj47 superman32432432 Yurii Chukhlib grp06 + antons austinm911 blacksmith-sh[bot] damoahdominic dan-dr HeimdallStrategy imfing jalehman jarvis-medmatic kkarimi mahmoudashraf93 pkrmf RandyVentures Ryan Lisse dougvk erikpr1994 Ghost jonasjancarik Keith the Silly Goose L36 Server Marc mitschabaude-bot mkbehr neist sibbl chrisrodz Friederike Seiler gabriel-trigo iamadig Jonathan D. Rhyne (DJ-D) Joshua Mitchell Kit koala73 manmal ogulcancelik pasogott petradonka rubyrunsstuff siddhantjain suminhthanh @@ -500,12 +500,13 @@ Thanks to all clawtributors: itsjaydesu ivancasco ivanrvpereira jayhickey jeffersonwarrior jeffersonwarrior jverdi longmaba mickahouan mjrussell odnxe p6l-richard philipp-spiess Pocket Clawd robaxelsen Sash Catanzarite T5-AndyML travisp VAC william arzt zknicker abhaymundhara alejandro maza Alex-Alaniz alexstyl andrewting19 anpoirier arthyn Asleep123 bolismauro - conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim grrowl - gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn kentaro Kevin Lin kitze - levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn MSch - Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe Rolf Fredheim - Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke Suksham-sharma - testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai ymat19 - Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik hougangdev - latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani William Stock + Clawdbot Maintainers conhecendoia dasilva333 Developer Dimitrios Ploutarchos Drake Thomsen fal3 Felix Krause foeken ganghyun kim + grrowl gtsifrikas HazAT hrdwdmrbl hugobarauna Jamie Openshaw Jarvis Jefferson Nunn kentaro Kevin Lin + kitze levifig Lloyd loukotal louzhixian martinpucik Matt mini mertcicekci0 Miles mrdbstn + MSch Mustafa Tag Eldeen ndraiman nexty5870 Noctivoro ppamment prathamdby ptn1411 reeltimeapps RLTCmpe + Rolf Fredheim Rony Kelner Samrat Jha senoldogann Seredeep sergical shiv19 shiyuanhai siraht snopoke + Suksham-sharma testingabc321 The Admiral thesash Ubuntu voidserf Vultr-Clawd Admin Wimmie wstock yazinsai + ymat19 Zach Knickerbocker 0xJonHoldsCrypto aaronn Alphonse-arianee atalovesyou Azade carlulsoe ddyo Erik + hougangdev latitudeki5223 Manuel Maly Mourad Boustani odrobnik pcty-nextgen-ios-builder Quentin Randy Torres rhjoh ronak-guliani + William Stock

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

diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index b24e5b456..bb029ae31 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import { formatUncaughtError } from "../infra/errors.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { enableConsoleCapture } from "../logging.js"; -import { getPrimaryCommand } from "./argv.js"; +import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js"; import { tryRouteCli } from "./route.js"; export function rewriteUpdateFlagArgv(argv: string[]): string[] { @@ -56,6 +56,15 @@ export async function runCli(argv: string[] = process.argv) { const { registerSubCliByName } = await import("./program/register.subclis.js"); await registerSubCliByName(program, primary); } + + const shouldSkipPluginRegistration = !primary && hasHelpOrVersion(parseArgv); + if (!shouldSkipPluginRegistration) { + // Register plugin CLI commands before parsing + const { registerPluginCliCommands } = await import("../plugins/cli.js"); + const { loadConfig } = await import("../config/config.js"); + registerPluginCliCommands(program, loadConfig()); + } + await program.parseAsync(parseArgv); }