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/.github/workflows/auto-response.yml b/.github/workflows/auto-response.yml index 7f242a094..b610e1718 100644 --- a/.github/workflows/auto-response.yml +++ b/.github/workflows/auto-response.yml @@ -3,7 +3,7 @@ name: Auto response on: issues: types: [labeled] - pull_request: + pull_request_target: types: [labeled] permissions: @@ -14,9 +14,15 @@ jobs: auto-response: runs-on: ubuntu-latest steps: + - uses: actions/create-github-app-token@v1 + id: app-token + with: + app-id: "2729701" + private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - name: Handle labeled items uses: actions/github-script@v7 with: + github-token: ${{ steps.app-token.outputs.token }} script: | const rules = [ { diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ce49a181..7c19ab947 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,13 @@ 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. +- CLI: avoid loading config for global help/version while registering plugin commands. (#2212) Thanks @dial481. - Agents: include memory.md when bootstrapping memory context. (#2318) Thanks @czekaj. +- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss. - Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos. - 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..2fdb6414a 100644 --- a/README.md +++ b/README.md @@ -477,35 +477,38 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and Thanks to all clawtributors:

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

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/discord.md b/docs/channels/discord.md index 12dd28084..395f13c6a 100644 --- a/docs/channels/discord.md +++ b/docs/channels/discord.md @@ -10,13 +10,14 @@ Status: ready for DM and guild text channels via the official Discord bot gatewa ## Quick setup (beginner) 1) Create a Discord bot and copy the bot token. -2) Set the token for Clawdbot: +2) In the Discord app settings, enable **Message Content Intent** (and **Server Members Intent** if you plan to use allowlists or name lookups). +3) Set the token for Clawdbot: - Env: `DISCORD_BOT_TOKEN=...` - Or config: `channels.discord.token: "..."`. - If both are set, config takes precedence (env fallback is default-account only). -3) Invite the bot to your server with message permissions. -4) Start the gateway. -5) DM access is pairing by default; approve the pairing code on first contact. +4) Invite the bot to your server with message permissions (create a private server if you just want DMs). +5) Start the gateway. +6) DM access is pairing by default; approve the pairing code on first contact. Minimal config: ```json5 diff --git a/docs/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/cli/onboard.md b/docs/cli/onboard.md index bd100c460..22cf0037e 100644 --- a/docs/cli/onboard.md +++ b/docs/cli/onboard.md @@ -23,3 +23,4 @@ clawdbot onboard --mode remote --remote-url ws://gateway-host:18789 Flow notes: - `quickstart`: minimal prompts, auto-generates a gateway token. - `manual`: full prompts for port/bind/auth (alias of `advanced`). +- Fastest first chat: `clawdbot dashboard` (Control UI, no channel setup). diff --git a/docs/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 564b248fe..52671d864 100644 --- a/docs/gateway/security.md +++ b/docs/gateway/security.md @@ -43,6 +43,18 @@ Start with the smallest access that still works, then widen it as you gain confi If you run `--deep`, Clawdbot also attempts a best-effort live Gateway probe. +## Credential storage map + +Use this when auditing access or deciding what to back up: + +- **WhatsApp**: `~/.clawdbot/credentials/whatsapp//creds.json` +- **Telegram bot token**: config/env or `channels.telegram.tokenFile` +- **Discord bot token**: config/env (token file not yet supported) +- **Slack tokens**: config/env (`channels.slack.*`) +- **Pairing allowlists**: `~/.clawdbot/credentials/-allowFrom.json` +- **Model auth profiles**: `~/.clawdbot/agents//agent/auth-profiles.json` +- **Legacy OAuth import**: `~/.clawdbot/credentials/oauth.json` + ## Security Audit Checklist When the audit prints findings, treat this as a priority order: @@ -130,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: @@ -199,6 +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, 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/start/getting-started.md b/docs/start/getting-started.md index dd68b8f55..00bc00efb 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -9,6 +9,10 @@ read_when: Goal: go from **zero** → **first working chat** (with sane defaults) as quickly as possible. +Fastest chat: open the Control UI (no channel setup needed). Run `clawdbot dashboard` +and chat in the browser, or open `http://127.0.0.1:18789/` on the gateway host. +Docs: [Dashboard](/web/dashboard) and [Control UI](/web/control-ui). + Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up: - model/auth (OAuth recommended) - gateway settings @@ -121,6 +125,7 @@ channels. If you use WhatsApp or Telegram, run the Gateway with **Node**. ```bash clawdbot status clawdbot health +clawdbot security audit --deep ``` ## 4) Pair + connect your first chat surface diff --git a/docs/start/setup.md b/docs/start/setup.md index 587b7fd6b..ec525b7b6 100644 --- a/docs/start/setup.md +++ b/docs/start/setup.md @@ -104,6 +104,19 @@ clawdbot health - Sessions: `~/.clawdbot/agents//sessions/` - Logs: `/tmp/clawdbot/` +## Credential storage map + +Use this when debugging auth or deciding what to back up: + +- **WhatsApp**: `~/.clawdbot/credentials/whatsapp//creds.json` +- **Telegram bot token**: config/env or `channels.telegram.tokenFile` +- **Discord bot token**: config/env (token file not yet supported) +- **Slack tokens**: config/env (`channels.slack.*`) +- **Pairing allowlists**: `~/.clawdbot/credentials/-allowFrom.json` +- **Model auth profiles**: `~/.clawdbot/agents//agent/auth-profiles.json` +- **Legacy OAuth import**: `~/.clawdbot/credentials/oauth.json` +More detail: [Security](/gateway/security#credential-storage-map). + ## Updating (without wrecking your setup) - Keep `~/clawd` and `~/.clawdbot/` as “your stuff”; don’t put personal prompts/config into the `clawdbot` repo. diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 8d4866392..59eb69402 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -18,6 +18,9 @@ Primary entrypoint: clawdbot onboard ``` +Fastest first chat: open the Control UI (no channel setup needed). Run +`clawdbot dashboard` and chat in the browser. Docs: [Dashboard](/web/dashboard). + Follow‑up reconfiguration: ```bash 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/skills.md b/docs/tools/skills.md index 289118bae..d9c840d73 100644 --- a/docs/tools/skills.md +++ b/docs/tools/skills.md @@ -64,6 +64,14 @@ By default, `clawdhub` installs into `./skills` under your current working directory (or falls back to the configured Clawdbot workspace). Clawdbot picks that up as `/skills` on the next session. +## Security notes + +- Treat third-party skills as **trusted code**. Read them before enabling. +- Prefer sandboxed runs for untrusted inputs and risky tools. See [Sandboxing](/gateway/sandboxing). +- `skills.entries.*.env` and `skills.entries.*.apiKey` inject secrets into the **host** process + for that agent turn (not the sandbox). Keep secrets out of prompts and logs. +- For a broader threat model and checklists, see [Security](/gateway/security). + ## Format (AgentSkills + Pi-compatible) `SKILL.md` must include at least: diff --git a/docs/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/docs/web/dashboard.md b/docs/web/dashboard.md index 81d0aacc4..fdbf209be 100644 --- a/docs/web/dashboard.md +++ b/docs/web/dashboard.md @@ -19,6 +19,10 @@ Key references: Authentication is enforced at the WebSocket handshake via `connect.params.auth` (token or password). See `gateway.auth` in [Gateway configuration](/gateway/configuration). +Security note: the Control UI is an **admin surface** (chat, config, exec approvals). +Do not expose it publicly. The UI stores the token in `localStorage` after first load. +Prefer localhost, Tailscale Serve, or an SSH tunnel. + ## Fast path (recommended) - After onboarding, the CLI now auto-opens the dashboard with your token and prints the same tokenized link. diff --git a/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/agents/workspace.ts b/src/agents/workspace.ts index 8692977eb..0cef8e5f0 100644 --- a/src/agents/workspace.ts +++ b/src/agents/workspace.ts @@ -188,9 +188,9 @@ export async function ensureAgentWorkspace(params?: { }; } -async function resolveMemoryBootstrapEntries(resolvedDir: string): Promise< - Array<{ name: WorkspaceBootstrapFileName; filePath: string }> -> { +async function resolveMemoryBootstrapEntries( + resolvedDir: string, +): Promise> { const candidates: WorkspaceBootstrapFileName[] = [ DEFAULT_MEMORY_FILENAME, DEFAULT_MEMORY_ALT_FILENAME, 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/discord.test.ts b/src/channels/plugins/actions/discord.test.ts index 67047410e..9cc184e6c 100644 --- a/src/channels/plugins/actions/discord.test.ts +++ b/src/channels/plugins/actions/discord.test.ts @@ -127,4 +127,30 @@ describe("handleDiscordMessageAction", () => { }), ); }); + + it("accepts threadId for thread replies (tool compatibility)", async () => { + sendMessageDiscord.mockClear(); + const handleDiscordMessageAction = await loadHandleDiscordMessageAction(); + + await handleDiscordMessageAction({ + action: "thread-reply", + params: { + // The `message` tool uses `threadId`. + threadId: "999", + // Include a conflicting channelId to ensure threadId takes precedence. + channelId: "123", + message: "hi", + }, + cfg: {} as ClawdbotConfig, + accountId: "ops", + }); + + expect(sendMessageDiscord).toHaveBeenCalledWith( + "channel:999", + "hi", + expect.objectContaining({ + accountId: "ops", + }), + ); + }); }); diff --git a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts index d65d044e2..5a3b13f61 100644 --- a/src/channels/plugins/actions/discord/handle-action.guild-admin.ts +++ b/src/channels/plugins/actions/discord/handle-action.guild-admin.ts @@ -393,11 +393,17 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { }); const mediaUrl = readStringParam(actionParams, "media", { trim: false }); const replyTo = readStringParam(actionParams, "replyTo"); + + // `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`. + // Prefer `threadId` when present to avoid accidentally replying in the parent channel. + const threadId = readStringParam(actionParams, "threadId"); + const channelId = threadId ?? resolveChannelId(); + return await handleDiscordAction( { action: "threadReply", accountId: accountId ?? undefined, - channelId: resolveChannelId(), + channelId, content, mediaUrl: mediaUrl ?? undefined, replyTo: replyTo ?? undefined, 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/cli/run-main.ts b/src/cli/run-main.ts index b24e5b456..bb029ae31 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -11,7 +11,7 @@ import { assertSupportedRuntime } from "../infra/runtime-guard.js"; import { formatUncaughtError } from "../infra/errors.js"; import { installUnhandledRejectionHandler } from "../infra/unhandled-rejections.js"; import { enableConsoleCapture } from "../logging.js"; -import { getPrimaryCommand } from "./argv.js"; +import { getPrimaryCommand, hasHelpOrVersion } from "./argv.js"; import { tryRouteCli } from "./route.js"; export function rewriteUpdateFlagArgv(argv: string[]): string[] { @@ -56,6 +56,15 @@ export async function runCli(argv: string[] = process.argv) { const { registerSubCliByName } = await import("./program/register.subclis.js"); await registerSubCliByName(program, primary); } + + const shouldSkipPluginRegistration = !primary && hasHelpOrVersion(parseArgv); + if (!shouldSkipPluginRegistration) { + // Register plugin CLI commands before parsing + const { registerPluginCliCommands } = await import("../plugins/cli.js"); + const { loadConfig } = await import("../config/config.js"); + registerPluginCliCommands(program, loadConfig()); + } + await program.parseAsync(parseArgv); } 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 e87a6b47c..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,30 +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 res = await runSecurityAudit({ - config: cfg, - includeFilesystem: true, - includeChannelSecurity: false, - stateDir, - configPath, - }); + 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 ${user}:(F)\n`, + stderr: "", + }; + } + : undefined; + const res = await runSecurityAudit({ + config: cfg, + includeFilesystem: true, + includeChannelSecurity: false, + stateDir, + configPath, + platform: isWindows ? "win32" : undefined, + env: isWindows + ? { ...process.env, USERNAME: "Tester", USERDOMAIN: "DESKTOP-TEST" } + : undefined, + execIcacls, + }); - const expectedCheckId = isWindows - ? "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; diff --git a/ui/src/ui/views/debug.ts b/ui/src/ui/views/debug.ts index 35e2e1af2..d33eaffc7 100644 --- a/ui/src/ui/views/debug.ts +++ b/ui/src/ui/views/debug.ts @@ -21,6 +21,22 @@ export type DebugProps = { }; export function renderDebug(props: DebugProps) { + const securityAudit = + props.status && typeof props.status === "object" + ? (props.status as { securityAudit?: { summary?: Record } }).securityAudit + : null; + const securitySummary = securityAudit?.summary ?? null; + const critical = securitySummary?.critical ?? 0; + const warn = securitySummary?.warn ?? 0; + const info = securitySummary?.info ?? 0; + const securityTone = critical > 0 ? "danger" : warn > 0 ? "warn" : "success"; + const securityLabel = + critical > 0 + ? `${critical} critical` + : warn > 0 + ? `${warn} warnings` + : "No critical issues"; + return html`
@@ -36,6 +52,12 @@ export function renderDebug(props: DebugProps) {
Status
+ ${securitySummary + ? html`
+ Security audit: ${securityLabel}${info > 0 ? ` · ${info} info` : ""}. Run + clawdbot security audit --deep for details. +
` + : nothing}
${JSON.stringify(props.status ?? {}, null, 2)}