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:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ce49a181..5d6e6040a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,12 @@ 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)
+- 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.
- 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.
@@ -40,6 +44,8 @@ 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.
+- 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.
@@ -48,7 +54,12 @@ 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.
+- 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.
+- 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 535cd1c75..e72fe7e16 100644
--- a/README.md
+++ b/README.md
@@ -477,34 +477,35 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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/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/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/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/docs.json b/docs/docs.json
index 2cc5ae78b..c53902451 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"
@@ -852,6 +856,7 @@
"install/docker",
"railway",
"render",
+ "northflank",
"install/bun"
]
},
diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md
index eaba866b1..9c850e070 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)
@@ -1027,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/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 cee21c7c2..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:
@@ -211,7 +221,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/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)
diff --git a/docs/northflank.mdx b/docs/northflank.mdx
new file mode 100644
index 000000000..aae9c6a22
--- /dev/null
+++ b/docs/northflank.mdx
@@ -0,0 +1,53 @@
+---
+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`.
+9. Open the Control UI at `/clawdbot`.
+
+## 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**.
+5) Open the Control UI at `https:///clawdbot`
+
+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/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/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 e2088137b..2524c3665 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
@@ -88,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/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/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.
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)
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..98431775a 100644
--- a/extensions/bluebubbles/src/monitor.ts
+++ b/extensions/bluebubbles/src/monitor.ts
@@ -250,8 +250,178 @@ 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 when no explicit inbound debounce config exists.
+ */
+const DEFAULT_INBOUND_DEBOUNCE_MS = 350;
+
+/**
+ * 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;
+
+ // 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
+>();
+
+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.
+ */
+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: resolveBlueBubblesDebounceMs(config, core),
+ 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?.(`[${account.accountId}] [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 +445,8 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
} else {
webhookTargets.delete(key);
}
+ // Clean up debouncer when target is unregistered
+ removeDebouncer(normalizedTarget);
};
}
@@ -1205,7 +1377,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)}`,
);
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/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
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": {
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: {}
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}...")
diff --git a/src/agents/pi-tools.policy.ts b/src/agents/pi-tools.policy.ts
index 98585ca9d..d6e125e33 100644
--- a/src/agents/pi-tools.policy.ts
+++ b/src/agents/pi-tools.policy.ts
@@ -96,13 +96,28 @@ 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 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]));
+}
+
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)
+ : 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 };
@@ -195,6 +210,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..4a0bebed0 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,
@@ -333,18 +346,18 @@ 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}`);
}
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/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/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..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";
@@ -176,6 +177,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,
@@ -208,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/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/auto-reply/reply/history.ts b/src/auto-reply/reply/history.ts
index bc59b4f2e..45ad44d5a 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;
@@ -34,7 +54,13 @@ 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);
return history;
}
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();
}
diff --git a/src/channels/plugins/actions/telegram.test.ts b/src/channels/plugins/actions/telegram.test.ts
index aac316858..b2673134d 100644
--- a/src/channels/plugins/actions/telegram.test.ts
+++ b/src/channels/plugins/actions/telegram.test.ts
@@ -36,4 +36,79 @@ 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,
+ );
+ });
+
+ 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 fe4e41307..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";
@@ -20,6 +21,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 +30,7 @@ function readTelegramSendParams(params: Record) {
messageThreadId: threadId ?? undefined,
buttons,
asVoice,
+ silent,
};
}
@@ -41,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 }) => {
@@ -98,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/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/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 9db9e3ede..21589e4fa 100644
--- a/src/config/legacy.migrations.part-3.ts
+++ b/src/config/legacy.migrations.part-3.ts
@@ -9,6 +9,10 @@ import {
resolveDefaultAgentIdFromRaw,
} from "./legacy.shared.js";
+// NOTE: tools.alsoAllow was introduced after legacy migrations; no legacy migration needed.
+
+// tools.alsoAllow legacy migration intentionally omitted (field not shipped in prod).
+
export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
{
id: "auth.anthropic-claude-cli-mode-oauth",
@@ -24,6 +28,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
changes.push('Updated auth.profiles["anthropic:claude-cli"].mode → "oauth".');
},
},
+ // 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",
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 24d6bccfe..3261b5170 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",
@@ -308,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",
@@ -641,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 5d0b80e25..fa9e2890a 100644
--- a/src/config/types.telegram.ts
+++ b/src/config/types.telegram.ts
@@ -15,6 +15,12 @@ export type TelegramActionConfig = {
reactions?: boolean;
sendMessage?: boolean;
deleteMessage?: boolean;
+ editMessage?: boolean;
+};
+
+export type TelegramNetworkConfig = {
+ /** Override Node's autoSelectFamily behavior (true = enable, false = disable). */
+ autoSelectFamily?: boolean;
};
export type TelegramInlineButtonsScope = "off" | "dm" | "group" | "all" | "allowlist";
@@ -95,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/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..b5a03a3ea 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -147,13 +147,23 @@ 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({
@@ -202,10 +212,20 @@ 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,
})
- .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
@@ -231,6 +251,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
@@ -271,6 +292,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
@@ -425,6 +455,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,
@@ -507,4 +538,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();
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/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/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");
}
}
diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts
index 18c23692d..a32c728a1 100644
--- a/src/gateway/tools-invoke-http.test.ts
+++ b/src/gateway/tools-invoke-http.test.ts
@@ -1,12 +1,22 @@
-import { describe, expect, it, vi } from "vitest";
+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" });
+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 +57,64 @@ 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("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 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..b747e2561 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,
@@ -176,18 +189,18 @@ 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}`);
}
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/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-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/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..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 } from "../utils/message-channel.js";
+import type { 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;
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);
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/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts
index c444baaa2..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;
@@ -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 : formatErrorMessage(reason);
+ const lowerMessage = message.toLowerCase();
+ return (
+ lowerMessage.includes("fetch failed") ||
+ lowerMessage.includes("network request") ||
+ lowerMessage.includes("econnrefused") ||
+ lowerMessage.includes("econnreset") ||
+ lowerMessage.includes("etimedout") ||
+ lowerMessage.includes("socket hang up") ||
+ lowerMessage.includes("enotfound") ||
+ lowerMessage.includes("network error") ||
+ lowerMessage.includes("getaddrinfo") ||
+ lowerMessage.includes("client network socket disconnected") ||
+ lowerMessage.includes("this operation was aborted") ||
+ lowerMessage.includes("aborted")
+ );
+}
+
export function isUnhandledRejectionHandled(reason: unknown): boolean {
for (const handler of handlers) {
try {
@@ -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/security/audit.test.ts b/src/security/audit.test.ts
index 1006934d3..3a43ff4cc 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,
});
@@ -855,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 () => {
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 ({
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 d958d5616..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,15 @@ 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) => {
+ const message = err instanceof Error ? err.message : String(err);
+ runtime.error?.(danger(`telegram bot error: ${message}`));
+ });
const recentUpdates = createTelegramUpdateDedupe();
let lastUpdateId =
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 7fdaef301..ebed468c9 100644
--- a/src/telegram/fetch.ts
+++ b/src/telegram/fetch.ts
@@ -1,7 +1,36 @@
+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";
+
+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
+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 24c8743df..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,6 +42,9 @@ export function createTelegramRunnerOptions(cfg: ClawdbotConfig): RunOptions {
+ 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.edit-message.test.ts b/src/telegram/send.edit-message.test.ts
new file mode 100644
index 000000000..d495dfff5
--- /dev/null
+++ b/src/telegram/send.edit-message.test.ts
@@ -0,0 +1,91 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const { botApi, botCtorSpy } = vi.hoisted(() => ({
+ 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.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 d659c198b..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?: {
@@ -476,6 +477,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..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";
@@ -40,6 +41,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) */
@@ -82,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)
@@ -201,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) =>
@@ -245,6 +251,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 +305,7 @@ export async function sendMessageTelegram(
caption: htmlCaption,
...(htmlCaption ? { parse_mode: "HTML" as const } : {}),
...baseMediaParams,
+ ...(opts.silent === true ? { disable_notification: true } : {}),
};
let result:
| Awaited>
@@ -430,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) =>
@@ -479,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) =>
@@ -491,6 +501,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":
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;