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/.github/workflows/labeler.yml b/.github/workflows/labeler.yml
index 8d078774b..2b2f80130 100644
--- a/.github/workflows/labeler.yml
+++ b/.github/workflows/labeler.yml
@@ -21,3 +21,4 @@ jobs:
with:
configuration-path: .github/labeler.yml
repo-token: ${{ steps.app-token.outputs.token }}
+ sync-labels: true
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4ce49a181..17bb4477c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,8 +6,13 @@ Docs: https://docs.clawd.bot
Status: unreleased.
### Changes
+- Agents: summarize dropped messages during compaction safeguard pruning. (#2509) Thanks @jogi47.
+- 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.
@@ -35,11 +40,14 @@ Status: unreleased.
- Telegram: allow caption param for media sends. (#1888) Thanks @mguellsegarra.
- Telegram: support plugin sendPayload channelData (media/buttons) and validate plugin commands. (#1917) Thanks @JoshuaLelon.
- Telegram: avoid block replies when streaming is disabled. (#1885) Thanks @ivancasco.
+- Docs: keep docs header sticky so navbar stays visible while scrolling. (#2445) Thanks @chenyuan99.
- Security: use Windows ACLs for permission audits and fixes on Windows. (#1957)
- Auth: show copyable Google auth URL after ASCII prompt. (#1787) Thanks @robbyczgw-cla.
- Routing: precompile session key regexes. (#1697) Thanks @Ray0907.
- 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,8 +56,19 @@ Status: unreleased.
- **BREAKING:** Gateway auth mode "none" is removed; gateway now requires token/password (Tailscale Serve identity still allowed).
### Fixes
+- Gateway: suppress AbortError and transient network errors in unhandled rejections. (#2451) Thanks @Glucksberg.
+- TTS: keep /tts status replies on text-only commands and avoid duplicate block-stream audio. (#2451) Thanks @Glucksberg.
+- Security: pin npm overrides to keep tar@7.5.4 for install toolchains.
+- Security: properly test Windows ACL audit for config includes. (#2403) Thanks @dominicnunez.
+- CLI: recognize versioned Node executables when parsing argv. (#2490) Thanks @David-Marsh-Photo.
+- 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.
+- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
+- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
+- Telegram: centralize API error logging for delivery and bot calls. (#2492) Thanks @altryne.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
- Build: align memory-core peer dependency with lockfile.
diff --git a/README.md b/README.md
index 535cd1c75..db80c6cd0 100644
--- a/README.md
+++ b/README.md
@@ -477,35 +477,36 @@ Special thanks to [Mario Zechner](https://mariozechner.at/) for his support and
Thanks to all clawtributors:
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/macos/Sources/Clawdbot/CommandResolver.swift b/apps/macos/Sources/Clawdbot/CommandResolver.swift
index 7661c48f1..f83638b10 100644
--- a/apps/macos/Sources/Clawdbot/CommandResolver.swift
+++ b/apps/macos/Sources/Clawdbot/CommandResolver.swift
@@ -282,22 +282,6 @@ enum CommandResolver {
guard !settings.target.isEmpty else { return nil }
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
- var args: [String] = [
- "-o", "BatchMode=yes",
- "-o", "StrictHostKeyChecking=accept-new",
- "-o", "UpdateHostKeys=yes",
- ]
- if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
- let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
- if !identity.isEmpty {
- // Only use IdentitiesOnly when an explicit identity file is provided.
- // This allows 1Password SSH agent and other SSH agents to provide keys.
- args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
- args.append(contentsOf: ["-i", identity])
- }
- let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
- args.append(userHost)
-
// Run the real clawdbot CLI on the remote host.
let exportedPath = [
"/opt/homebrew/bin",
@@ -324,7 +308,7 @@ enum CommandResolver {
} else {
"""
PRJ=\(self.shellQuote(userPRJ))
- cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
+ cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
"""
}
@@ -378,7 +362,16 @@ enum CommandResolver {
echo "clawdbot CLI missing on remote host"; exit 127;
fi
"""
- args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
+ let options: [String] = [
+ "-o", "BatchMode=yes",
+ "-o", "StrictHostKeyChecking=accept-new",
+ "-o", "UpdateHostKeys=yes",
+ ]
+ let args = self.sshArguments(
+ target: parsed,
+ identity: settings.identity,
+ options: options,
+ remoteCommand: ["/bin/sh", "-c", scriptBody])
return ["/usr/bin/ssh"] + args
}
@@ -427,8 +420,11 @@ enum CommandResolver {
}
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
- let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
+ let trimmed = self.normalizeSSHTargetInput(target)
guard !trimmed.isEmpty else { return nil }
+ if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
+ return nil
+ }
let userHostPort: String
let user: String?
if let atRange = trimmed.range(of: "@") {
@@ -444,13 +440,31 @@ enum CommandResolver {
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
host = String(userHostPort[.. 0, parsedPort <= 65535 else {
+ return nil
+ }
+ port = parsedPort
} else {
host = userHostPort
port = 22
}
- return SSHParsedTarget(user: user, host: host, port: port)
+ return self.makeSSHTarget(user: user, host: host, port: port)
+ }
+
+ static func sshTargetValidationMessage(_ target: String) -> String? {
+ let trimmed = self.normalizeSSHTargetInput(target)
+ guard !trimmed.isEmpty else { return nil }
+ if trimmed.hasPrefix("-") {
+ return "SSH target cannot start with '-'"
+ }
+ if trimmed.rangeOfCharacter(from: CharacterSet.whitespacesAndNewlines.union(.controlCharacters)) != nil {
+ return "SSH target cannot contain spaces"
+ }
+ if self.parseSSHTarget(trimmed) == nil {
+ return "SSH target must look like user@host[:port]"
+ }
+ return nil
}
private static func shellQuote(_ text: String) -> String {
@@ -468,6 +482,64 @@ enum CommandResolver {
return URL(fileURLWithPath: expanded)
}
+ private static func normalizeSSHTargetInput(_ target: String) -> String {
+ var trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
+ if trimmed.hasPrefix("ssh ") {
+ trimmed = trimmed.replacingOccurrences(of: "ssh ", with: "")
+ .trimmingCharacters(in: .whitespacesAndNewlines)
+ }
+ return trimmed
+ }
+
+ private static func isValidSSHComponent(_ value: String, allowLeadingDash: Bool = false) -> Bool {
+ if value.isEmpty { return false }
+ if !allowLeadingDash, value.hasPrefix("-") { return false }
+ let invalid = CharacterSet.whitespacesAndNewlines.union(.controlCharacters)
+ return value.rangeOfCharacter(from: invalid) == nil
+ }
+
+ static func makeSSHTarget(user: String?, host: String, port: Int) -> SSHParsedTarget? {
+ let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard self.isValidSSHComponent(trimmedHost) else { return nil }
+ let trimmedUser = user?.trimmingCharacters(in: .whitespacesAndNewlines)
+ let normalizedUser: String?
+ if let trimmedUser {
+ guard self.isValidSSHComponent(trimmedUser) else { return nil }
+ normalizedUser = trimmedUser.isEmpty ? nil : trimmedUser
+ } else {
+ normalizedUser = nil
+ }
+ guard port > 0, port <= 65535 else { return nil }
+ return SSHParsedTarget(user: normalizedUser, host: trimmedHost, port: port)
+ }
+
+ private static func sshTargetString(_ target: SSHParsedTarget) -> String {
+ target.user.map { "\($0)@\(target.host)" } ?? target.host
+ }
+
+ static func sshArguments(
+ target: SSHParsedTarget,
+ identity: String,
+ options: [String],
+ remoteCommand: [String] = []) -> [String]
+ {
+ var args = options
+ if target.port > 0 {
+ args.append(contentsOf: ["-p", String(target.port)])
+ }
+ let trimmedIdentity = identity.trimmingCharacters(in: .whitespacesAndNewlines)
+ if !trimmedIdentity.isEmpty {
+ // Only use IdentitiesOnly when an explicit identity file is provided.
+ // This allows 1Password SSH agent and other SSH agents to provide keys.
+ args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
+ args.append(contentsOf: ["-i", trimmedIdentity])
+ }
+ args.append("--")
+ args.append(self.sshTargetString(target))
+ args.append(contentsOf: remoteCommand)
+ return args
+ }
+
#if SWIFT_PACKAGE
static func _testNodeManagerBinPaths(home: URL) -> [String] {
self.nodeManagerBinPaths(home: home)
diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift
index 18dd423a2..b315ad32e 100644
--- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift
+++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift
@@ -243,25 +243,36 @@ struct GeneralSettings: View {
}
private var remoteSshRow: some View {
- HStack(alignment: .center, spacing: 10) {
- Text("SSH target")
- .font(.callout.weight(.semibold))
- .frame(width: self.remoteLabelWidth, alignment: .leading)
- TextField("user@host[:22]", text: self.$state.remoteTarget)
- .textFieldStyle(.roundedBorder)
- .frame(maxWidth: .infinity)
- Button {
- Task { await self.testRemote() }
- } label: {
- if self.remoteStatus == .checking {
- ProgressView().controlSize(.small)
- } else {
- Text("Test remote")
+ let trimmedTarget = self.state.remoteTarget.trimmingCharacters(in: .whitespacesAndNewlines)
+ let validationMessage = CommandResolver.sshTargetValidationMessage(trimmedTarget)
+ let canTest = !trimmedTarget.isEmpty && validationMessage == nil
+
+ return VStack(alignment: .leading, spacing: 4) {
+ HStack(alignment: .center, spacing: 10) {
+ Text("SSH target")
+ .font(.callout.weight(.semibold))
+ .frame(width: self.remoteLabelWidth, alignment: .leading)
+ TextField("user@host[:22]", text: self.$state.remoteTarget)
+ .textFieldStyle(.roundedBorder)
+ .frame(maxWidth: .infinity)
+ Button {
+ Task { await self.testRemote() }
+ } label: {
+ if self.remoteStatus == .checking {
+ ProgressView().controlSize(.small)
+ } else {
+ Text("Test remote")
+ }
}
+ .buttonStyle(.borderedProminent)
+ .disabled(self.remoteStatus == .checking || !canTest)
+ }
+ if let validationMessage {
+ Text(validationMessage)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .padding(.leading, self.remoteLabelWidth + 10)
}
- .buttonStyle(.borderedProminent)
- .disabled(self.remoteStatus == .checking || self.state.remoteTarget
- .trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
@@ -540,8 +551,15 @@ extension GeneralSettings {
}
// Step 1: basic SSH reachability check
+ guard let sshCommand = Self.sshCheckCommand(
+ target: settings.target,
+ identity: settings.identity)
+ else {
+ self.remoteStatus = .failed("SSH target is invalid")
+ return
+ }
let sshResult = await ShellExecutor.run(
- command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
+ command: sshCommand,
cwd: nil,
env: nil,
timeout: 8)
@@ -587,20 +605,20 @@ extension GeneralSettings {
return !host.isEmpty
}
- private static func sshCheckCommand(target: String, identity: String) -> [String] {
- var args: [String] = [
- "/usr/bin/ssh",
+ private static func sshCheckCommand(target: String, identity: String) -> [String]? {
+ guard let parsed = CommandResolver.parseSSHTarget(target) else { return nil }
+ let options = [
"-o", "BatchMode=yes",
"-o", "ConnectTimeout=5",
"-o", "StrictHostKeyChecking=accept-new",
"-o", "UpdateHostKeys=yes",
]
- if !identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
- args.append(contentsOf: ["-i", identity])
- }
- args.append(target)
- args.append("echo ok")
- return args
+ let args = CommandResolver.sshArguments(
+ target: parsed,
+ identity: identity,
+ options: options,
+ remoteCommand: ["echo", "ok"])
+ return ["/usr/bin/ssh"] + args
}
private func formatSSHFailure(_ response: Response, target: String) -> String {
diff --git a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift
index b3f7e9295..e81b7a914 100644
--- a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift
+++ b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift
@@ -559,22 +559,21 @@ final class NodePairingApprovalPrompter {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
- var args = [
- "-o",
- "BatchMode=yes",
- "-o",
- "ConnectTimeout=5",
- "-o",
- "NumberOfPasswordPrompts=0",
- "-o",
- "PreferredAuthentications=publickey",
- "-o",
- "StrictHostKeyChecking=accept-new",
+ let options = [
+ "-o", "BatchMode=yes",
+ "-o", "ConnectTimeout=5",
+ "-o", "NumberOfPasswordPrompts=0",
+ "-o", "PreferredAuthentications=publickey",
+ "-o", "StrictHostKeyChecking=accept-new",
]
- if port > 0, port != 22 {
- args.append(contentsOf: ["-p", String(port)])
+ guard let target = CommandResolver.makeSSHTarget(user: user, host: host, port: port) else {
+ return false
}
- args.append(contentsOf: ["-l", user, host, "/usr/bin/true"])
+ let args = CommandResolver.sshArguments(
+ target: target,
+ identity: "",
+ options: options,
+ remoteCommand: ["/usr/bin/true"])
process.arguments = args
let pipe = Pipe()
process.standardOutput = pipe
diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift
index 5c5eead34..9abbcf972 100644
--- a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift
+++ b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift
@@ -206,6 +206,16 @@ extension OnboardingView {
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
+ if let message = CommandResolver.sshTargetValidationMessage(self.state.remoteTarget) {
+ GridRow {
+ Text("")
+ .frame(width: labelWidth, alignment: .leading)
+ Text(message)
+ .font(.caption)
+ .foregroundStyle(.red)
+ .frame(width: fieldWidth, alignment: .leading)
+ }
+ }
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
diff --git a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
index 8eaee1c05..4206a3750 100644
--- a/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
+++ b/apps/macos/Sources/Clawdbot/RemotePortTunnel.swift
@@ -70,7 +70,7 @@ final class RemotePortTunnel {
"ssh tunnel using default remote port " +
"host=\(sshHost, privacy: .public) port=\(remotePort, privacy: .public)")
}
- var args: [String] = [
+ let options: [String] = [
"-o", "BatchMode=yes",
"-o", "ExitOnForwardFailure=yes",
"-o", "StrictHostKeyChecking=accept-new",
@@ -81,16 +81,11 @@ final class RemotePortTunnel {
"-N",
"-L", "\(localPort):127.0.0.1:\(resolvedRemotePort)",
]
- if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
let identity = settings.identity.trimmingCharacters(in: .whitespacesAndNewlines)
- if !identity.isEmpty {
- // Only use IdentitiesOnly when an explicit identity file is provided.
- // This allows 1Password SSH agent and other SSH agents to provide keys.
- args.append(contentsOf: ["-o", "IdentitiesOnly=yes"])
- args.append(contentsOf: ["-i", identity])
- }
- let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
- args.append(userHost)
+ let args = CommandResolver.sshArguments(
+ target: parsed,
+ identity: identity,
+ options: options)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/ssh")
diff --git a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift
index 827057888..d8daa17f6 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/CommandResolverTests.swift
@@ -123,11 +123,16 @@ import Testing
configRoot: [:])
#expect(cmd.first == "/usr/bin/ssh")
- #expect(cmd.contains("clawd@example.com"))
+ if let marker = cmd.firstIndex(of: "--") {
+ #expect(cmd[marker + 1] == "clawd@example.com")
+ } else {
+ #expect(Bool(false))
+ }
#expect(cmd.contains("-i"))
#expect(cmd.contains("/tmp/id_ed25519"))
if let script = cmd.last {
- #expect(script.contains("cd '/srv/clawdbot'"))
+ #expect(script.contains("PRJ='/srv/clawdbot'"))
+ #expect(script.contains("cd \"$PRJ\""))
#expect(script.contains("clawdbot"))
#expect(script.contains("status"))
#expect(script.contains("--json"))
@@ -135,6 +140,12 @@ import Testing
}
}
+ @Test func rejectsUnsafeSSHTargets() async throws {
+ #expect(CommandResolver.parseSSHTarget("-oProxyCommand=calc") == nil)
+ #expect(CommandResolver.parseSSHTarget("host:-oProxyCommand=calc") == nil)
+ #expect(CommandResolver.parseSSHTarget("user@host:2222")?.port == 2222)
+ }
+
@Test func configRootLocalOverridesRemoteDefaults() async throws {
let defaults = self.makeDefaults()
defaults.set(AppState.ConnectionMode.remote.rawValue, forKey: connectionModeKey)
diff --git a/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift
index 10630c202..2541e0634 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/MasterDiscoveryMenuSmokeTests.swift
@@ -11,7 +11,12 @@ struct MasterDiscoveryMenuSmokeTests {
discovery.statusText = "Searching…"
discovery.gateways = []
- let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: nil, onSelect: { _ in })
+ let view = GatewayDiscoveryInlineList(
+ discovery: discovery,
+ currentTarget: nil,
+ currentUrl: nil,
+ transport: .ssh,
+ onSelect: { _ in })
_ = view.body
}
@@ -32,7 +37,12 @@ struct MasterDiscoveryMenuSmokeTests {
]
let currentTarget = "\(NSUserName())@office.tailnet-123.ts.net:2222"
- let view = GatewayDiscoveryInlineList(discovery: discovery, currentTarget: currentTarget, onSelect: { _ in })
+ let view = GatewayDiscoveryInlineList(
+ discovery: discovery,
+ currentTarget: currentTarget,
+ currentUrl: nil,
+ transport: .ssh,
+ onSelect: { _ in })
_ = view.body
}
diff --git a/docs/assets/terminal.css b/docs/assets/terminal.css
index 23283d651..e5e51af9e 100644
--- a/docs/assets/terminal.css
+++ b/docs/assets/terminal.css
@@ -115,6 +115,9 @@ body::after {
}
.shell {
+ position: sticky;
+ top: 0;
+ z-index: 100;
padding: 22px 16px 10px;
}
diff --git a/docs/automation/cron-vs-heartbeat.md b/docs/automation/cron-vs-heartbeat.md
index 333a45d0b..325575602 100644
--- a/docs/automation/cron-vs-heartbeat.md
+++ b/docs/automation/cron-vs-heartbeat.md
@@ -201,7 +201,7 @@ For ad-hoc workflows, call Lobster directly.
- Lobster runs as a **local subprocess** (`lobster` CLI) in tool mode and returns a **JSON envelope**.
- If the tool returns `needs_approval`, you resume with a `resumeToken` and `approve` flag.
-- The tool is an **optional plugin**; you must allowlist `lobster` in `tools.allow`.
+- The tool is an **optional plugin**; enable it additively via `tools.alsoAllow: ["lobster"]` (recommended).
- If you pass `lobsterPath`, it must be an **absolute path**.
See [Lobster](/tools/lobster) for full usage and examples.
diff --git a/docs/channels/matrix.md b/docs/channels/matrix.md
index 2d9025f51..8151bfed1 100644
--- a/docs/channels/matrix.md
+++ b/docs/channels/matrix.md
@@ -10,7 +10,7 @@ on any homeserver, so you need a Matrix account for the bot. Once it is logged i
the bot directly or invite it to rooms (Matrix "groups"). Beeper is a valid client option too,
but it requires E2EE to be enabled.
-Status: supported via plugin (matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
+Status: supported via plugin (@vector-im/matrix-bot-sdk). Direct messages, rooms, threads, media, reactions,
polls (send + poll-start as text), location, and E2EE (with crypto support).
## Plugin required
diff --git a/docs/channels/telegram.md b/docs/channels/telegram.md
index e708e2e64..39f3a2ec3 100644
--- a/docs/channels/telegram.md
+++ b/docs/channels/telegram.md
@@ -529,6 +529,7 @@ Provider options:
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
+- `channels.telegram.network.autoSelectFamily`: override Node autoSelectFamily (true=enable, false=disable). Defaults to disabled on Node 22 to avoid Happy Eyeballs timeouts.
- `channels.telegram.proxy`: proxy URL for Bot API calls (SOCKS/HTTP).
- `channels.telegram.webhookUrl`: enable webhook mode.
- `channels.telegram.webhookSecret`: webhook secret (optional).
diff --git a/docs/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..01a338a18 100644
--- a/docs/docs.json
+++ b/docs/docs.json
@@ -345,10 +345,6 @@
"source": "/auth-monitoring",
"destination": "/automation/auth-monitoring"
},
- {
- "source": "/scripts",
- "destination": "/scripts"
- },
{
"source": "/camera",
"destination": "/nodes/camera"
@@ -805,6 +801,10 @@
"source": "/install/railway/",
"destination": "/railway"
},
+ {
+ "source": "/install/northflank/",
+ "destination": "/northflank"
+ },
{
"source": "/gcp",
"destination": "/platforms/gcp"
@@ -852,6 +852,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 3b8f9f036..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,7 +221,7 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
- Prefer mention gating in groups; avoid “always-on” bots in public rooms.
- Treat links, attachments, and pasted instructions as hostile by default.
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
-- Note: sandboxing is opt-in; if sandbox mode is off, exec runs on the gateway host even though tools.exec.host defaults to sandbox.
+- Note: sandboxing is opt-in. If sandbox mode is off, exec runs on the gateway host even though tools.exec.host defaults to sandbox, and host exec does not require approvals unless you set host=gateway and configure exec approvals.
- Limit high-risk tools (`exec`, `browser`, `web_fetch`, `web_search`) to trusted agents or explicit allowlists.
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
diff --git a/docs/help/faq.md b/docs/help/faq.md
index f4e177f8d..336b324c9 100644
--- a/docs/help/faq.md
+++ b/docs/help/faq.md
@@ -401,7 +401,7 @@ remote mode, remember the gateway host owns the session store and workspace.
up **memory + bootstrap files**, but **not** session history or auth. Those live
under `~/.clawdbot/` (for example `~/.clawdbot/agents//sessions/`).
-Related: [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
+Related: [Migrating](/install/migrating), [Where things live on disk](/help/faq#where-does-clawdbot-store-its-data),
[Agent workspace](/concepts/agent-workspace), [Doctor](/gateway/doctor),
[Remote mode](/gateway/remote).
diff --git a/docs/install/index.md b/docs/install/index.md
index dde0e5eeb..7ccab0ca8 100644
--- a/docs/install/index.md
+++ b/docs/install/index.md
@@ -177,4 +177,5 @@ Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
## Update / uninstall
- Updates: [Updating](/install/updating)
+- Migrate to a new machine: [Migrating](/install/migrating)
- Uninstall: [Uninstall](/install/uninstall)
diff --git a/docs/install/migrating.md b/docs/install/migrating.md
new file mode 100644
index 000000000..4987b38b9
--- /dev/null
+++ b/docs/install/migrating.md
@@ -0,0 +1,190 @@
+---
+summary: "Move (migrate) a Clawdbot install from one machine to another"
+read_when:
+ - You are moving Clawdbot to a new laptop/server
+ - You want to preserve sessions, auth, and channel logins (WhatsApp, etc.)
+---
+# Migrating Clawdbot to a new machine
+
+This guide migrates a Clawdbot Gateway from one machine to another **without redoing onboarding**.
+
+The migration is simple conceptually:
+
+- Copy the **state directory** (`$CLAWDBOT_STATE_DIR`, default: `~/.clawdbot/`) — this includes config, auth, sessions, and channel state.
+- Copy your **workspace** (`~/clawd/` by default) — this includes your agent files (memory, prompts, etc.).
+
+But there are common footguns around **profiles**, **permissions**, and **partial copies**.
+
+## Before you start (what you are migrating)
+
+### 1) Identify your state directory
+
+Most installs use the default:
+
+- **State dir:** `~/.clawdbot/`
+
+But it may be different if you use:
+
+- `--profile ` (often becomes `~/.clawdbot-/`)
+- `CLAWDBOT_STATE_DIR=/some/path`
+
+If you’re not sure, run on the **old** machine:
+
+```bash
+clawdbot status
+```
+
+Look for mentions of `CLAWDBOT_STATE_DIR` / profile in the output. If you run multiple gateways, repeat for each profile.
+
+### 2) Identify your workspace
+
+Common defaults:
+
+- `~/clawd/` (recommended workspace)
+- a custom folder you created
+
+Your workspace is where files like `MEMORY.md`, `USER.md`, and `memory/*.md` live.
+
+### 3) Understand what you will preserve
+
+If you copy **both** the state dir and workspace, you keep:
+
+- Gateway configuration (`clawdbot.json`)
+- Auth profiles / API keys / OAuth tokens
+- Session history + agent state
+- Channel state (e.g. WhatsApp login/session)
+- Your workspace files (memory, skills notes, etc.)
+
+If you copy **only** the workspace (e.g., via Git), you do **not** preserve:
+
+- sessions
+- credentials
+- channel logins
+
+Those live under `$CLAWDBOT_STATE_DIR`.
+
+## Migration steps (recommended)
+
+### Step 0 — Make a backup (old machine)
+
+On the **old** machine, stop the gateway first so files aren’t changing mid-copy:
+
+```bash
+clawdbot gateway stop
+```
+
+(Optional but recommended) archive the state dir and workspace:
+
+```bash
+# Adjust paths if you use a profile or custom locations
+cd ~
+tar -czf clawdbot-state.tgz .clawdbot
+
+tar -czf clawd-workspace.tgz clawd
+```
+
+If you have multiple profiles/state dirs (e.g. `~/.clawdbot-main`, `~/.clawdbot-work`), archive each.
+
+### Step 1 — Install Clawdbot on the new machine
+
+On the **new** machine, install the CLI (and Node if needed):
+
+- See: [Install](/install)
+
+At this stage, it’s OK if onboarding creates a fresh `~/.clawdbot/` — you will overwrite it in the next step.
+
+### Step 2 — Copy the state dir + workspace to the new machine
+
+Copy **both**:
+
+- `$CLAWDBOT_STATE_DIR` (default `~/.clawdbot/`)
+- your workspace (default `~/clawd/`)
+
+Common approaches:
+
+- `scp` the tarballs and extract
+- `rsync -a` over SSH
+- external drive
+
+After copying, ensure:
+
+- Hidden directories were included (e.g. `.clawdbot/`)
+- File ownership is correct for the user running the gateway
+
+### Step 3 — Run Doctor (migrations + service repair)
+
+On the **new** machine:
+
+```bash
+clawdbot doctor
+```
+
+Doctor is the “safe boring” command. It repairs services, applies config migrations, and warns about mismatches.
+
+Then:
+
+```bash
+clawdbot gateway restart
+clawdbot status
+```
+
+## Common footguns (and how to avoid them)
+
+### Footgun: profile / state-dir mismatch
+
+If you ran the old gateway with a profile (or `CLAWDBOT_STATE_DIR`), and the new gateway uses a different one, you’ll see symptoms like:
+
+- config changes not taking effect
+- channels missing / logged out
+- empty session history
+
+Fix: run the gateway/service using the **same** profile/state dir you migrated, then rerun:
+
+```bash
+clawdbot doctor
+```
+
+### Footgun: copying only `clawdbot.json`
+
+`clawdbot.json` is not enough. Many providers store state under:
+
+- `$CLAWDBOT_STATE_DIR/credentials/`
+- `$CLAWDBOT_STATE_DIR/agents//...`
+
+Always migrate the entire `$CLAWDBOT_STATE_DIR` folder.
+
+### Footgun: permissions / ownership
+
+If you copied as root or changed users, the gateway may fail to read credentials/sessions.
+
+Fix: ensure the state dir + workspace are owned by the user running the gateway.
+
+### Footgun: migrating between remote/local modes
+
+- If your UI (WebUI/TUI) points at a **remote** gateway, the remote host owns the session store + workspace.
+- Migrating your laptop won’t move the remote gateway’s state.
+
+If you’re in remote mode, migrate the **gateway host**.
+
+### Footgun: secrets in backups
+
+`$CLAWDBOT_STATE_DIR` contains secrets (API keys, OAuth tokens, WhatsApp creds). Treat backups like production secrets:
+
+- store encrypted
+- avoid sharing over insecure channels
+- rotate keys if you suspect exposure
+
+## Verification checklist
+
+On the new machine, confirm:
+
+- `clawdbot status` shows the gateway running
+- Your channels are still connected (e.g. WhatsApp doesn’t require re-pair)
+- The dashboard opens and shows existing sessions
+- Your workspace files (memory, configs) are present
+
+## Related
+
+- [Doctor](/gateway/doctor)
+- [Gateway troubleshooting](/gateway/troubleshooting)
+- [Where does Clawdbot store its data?](/help/faq#where-does-clawdbot-store-its-data)
diff --git a/docs/install/node.md b/docs/install/node.md
index 6a622e198..3075b6207 100644
--- a/docs/install/node.md
+++ b/docs/install/node.md
@@ -1,9 +1,10 @@
---
+title: "Node.js + npm (PATH sanity)"
summary: "Node.js + npm install sanity: versions, PATH, and global installs"
read_when:
- - You installed Clawdbot but `clawdbot` is “command not found”
- - You’re setting up Node.js/npm on a new machine
- - `npm install -g ...` fails with permissions or PATH issues
+ - "You installed Clawdbot but `clawdbot` is “command not found”"
+ - "You’re setting up Node.js/npm on a new machine"
+ - "npm install -g ... fails with permissions or PATH issues"
---
# Node.js + npm (PATH sanity)
diff --git a/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/setup.md b/docs/start/setup.md
index f4024a50d..ec525b7b6 100644
--- a/docs/start/setup.md
+++ b/docs/start/setup.md
@@ -115,6 +115,7 @@ Use this when debugging auth or deciding what to back up:
- **Pairing allowlists**: `~/.clawdbot/credentials/-allowFrom.json`
- **Model auth profiles**: `~/.clawdbot/agents//agent/auth-profiles.json`
- **Legacy OAuth import**: `~/.clawdbot/credentials/oauth.json`
+More detail: [Security](/gateway/security#credential-storage-map).
## Updating (without wrecking your setup)
diff --git a/docs/start/wizard.md b/docs/start/wizard.md
index 8d4866392..59eb69402 100644
--- a/docs/start/wizard.md
+++ b/docs/start/wizard.md
@@ -18,6 +18,9 @@ Primary entrypoint:
clawdbot onboard
```
+Fastest first chat: open the Control UI (no channel setup needed). Run
+`clawdbot dashboard` and chat in the browser. Docs: [Dashboard](/web/dashboard).
+
Follow‑up reconfiguration:
```bash
diff --git a/docs/tools/elevated.md b/docs/tools/elevated.md
index 863c53a1f..7635bbbee 100644
--- a/docs/tools/elevated.md
+++ b/docs/tools/elevated.md
@@ -23,6 +23,7 @@ read_when:
- **Approvals**: `full` skips exec approvals; `on`/`ask` honor them when allowlist/ask rules require.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
+- **Separate from `/exec`**: `/exec` adjusts per-session defaults for authorized senders and does not require elevated.
## Resolution order
1. Inline directive on the message (applies only to that message).
diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md
index ec350f9d9..2ec8ec191 100644
--- a/docs/tools/exec-approvals.md
+++ b/docs/tools/exec-approvals.md
@@ -216,6 +216,9 @@ Approval-gated execs reuse the approval id as the `runId` in these messages for
- **full** is powerful; prefer allowlists when possible.
- **ask** keeps you in the loop while still allowing fast approvals.
- Per-agent allowlists prevent one agent’s approvals from leaking into others.
+- Approvals only apply to host exec requests from **authorized senders**. Unauthorized senders cannot issue `/exec`.
+- `/exec security=full` is a session-level convenience for authorized operators and skips approvals by design.
+ To hard-block host exec, set approvals security to `deny` or deny the `exec` tool via tool policy.
Related:
- [Exec tool](/tools/exec)
diff --git a/docs/tools/exec.md b/docs/tools/exec.md
index e2088137b..2524c3665 100644
--- a/docs/tools/exec.md
+++ b/docs/tools/exec.md
@@ -34,6 +34,9 @@ Notes:
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
- On non-Windows hosts, exec uses `SHELL` when set; if `SHELL` is `fish`, it prefers `bash` (or `sh`)
from `PATH` to avoid fish-incompatible scripts, then falls back to `SHELL` if neither exists.
+- Important: sandboxing is **off by default**. If sandboxing is off, `host=sandbox` runs directly on
+ the gateway host (no container) and **does not require approvals**. To require approvals, run with
+ `host=gateway` and configure exec approvals (or enable sandboxing).
## Config
@@ -88,6 +91,13 @@ Example:
/exec host=gateway security=allowlist ask=on-miss node=mac-1
```
+## Authorization model
+
+`/exec` is only honored for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`).
+It updates **session state only** and does not write config. To hard-disable exec, deny it via tool
+policy (`tools.deny: ["exec"]` or per-agent). Host approvals still apply unless you explicitly set
+`security=full` and `ask=off`.
+
## Exec approvals (companion app / node host)
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
diff --git a/docs/tools/lobster.md b/docs/tools/lobster.md
index daf04fd39..f4718c4b5 100644
--- a/docs/tools/lobster.md
+++ b/docs/tools/lobster.md
@@ -158,7 +158,19 @@ If you want to use a custom binary location, pass an **absolute** `lobsterPath`
## Enable the tool
-Lobster is an **optional** plugin tool (not enabled by default). Allow it per agent:
+Lobster is an **optional** plugin tool (not enabled by default).
+
+Recommended (additive, safe):
+
+```json
+{
+ "tools": {
+ "alsoAllow": ["lobster"]
+ }
+}
+```
+
+Or per-agent:
```json
{
@@ -167,7 +179,7 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
{
"id": "main",
"tools": {
- "allow": ["lobster"]
+ "alsoAllow": ["lobster"]
}
}
]
@@ -175,7 +187,7 @@ Lobster is an **optional** plugin tool (not enabled by default). Allow it per ag
}
```
-You can also allow it globally with `tools.allow` if every agent should see it.
+Avoid using `tools.allow: ["lobster"]` unless you intend to run in restrictive allowlist mode.
Note: allowlists are opt-in for optional plugins. If your allowlist only names
plugin tools (like `lobster`), Clawdbot keeps core tools enabled. To restrict core
diff --git a/docs/tools/slash-commands.md b/docs/tools/slash-commands.md
index 93b51d5ae..138ede9d0 100644
--- a/docs/tools/slash-commands.md
+++ b/docs/tools/slash-commands.md
@@ -16,6 +16,8 @@ There are two related systems:
- Directives are stripped from the message before the model sees it.
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
+ - Directives are only applied for **authorized senders** (channel allowlists/pairing plus `commands.useAccessGroups`).
+ Unauthorized senders see directives treated as plain text.
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`).
They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow.
diff --git a/docs/vps.md b/docs/vps.md
index 192ab830e..08910733f 100644
--- a/docs/vps.md
+++ b/docs/vps.md
@@ -11,6 +11,8 @@ deployments work at a high level.
## Pick a provider
+- **Railway** (one‑click + browser setup): [Railway](/railway)
+- **Northflank** (one‑click + browser setup): [Northflank](/northflank)
- **Oracle Cloud (Always Free)**: [Oracle](/platforms/oracle) — $0/month (Always Free, ARM; capacity/signup can be finicky)
- **Fly.io**: [Fly.io](/platforms/fly)
- **Hetzner (Docker)**: [Hetzner](/platforms/hetzner)
diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts
index 12aef679c..76c9eebf6 100644
--- a/extensions/bluebubbles/src/monitor.test.ts
+++ b/extensions/bluebubbles/src/monitor.test.ts
@@ -146,8 +146,14 @@ function createMockRuntime(): PluginRuntime {
resolveRequireMention: mockResolveRequireMention as unknown as PluginRuntime["channel"]["groups"]["resolveRequireMention"],
},
debounce: {
- createInboundDebouncer: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
- resolveInboundDebounceMs: vi.fn() as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
+ // Create a pass-through debouncer that immediately calls onFlush
+ createInboundDebouncer: vi.fn((params: { onFlush: (items: unknown[]) => Promise }) => ({
+ enqueue: async (item: unknown) => {
+ await params.onFlush([item]);
+ },
+ flushKey: vi.fn(),
+ })) as unknown as PluginRuntime["channel"]["debounce"]["createInboundDebouncer"],
+ resolveInboundDebounceMs: vi.fn(() => 0) as unknown as PluginRuntime["channel"]["debounce"]["resolveInboundDebounceMs"],
},
commands: {
resolveCommandAuthorizedFromAuthorizers: mockResolveCommandAuthorizedFromAuthorizers as unknown as PluginRuntime["channel"]["commands"]["resolveCommandAuthorizedFromAuthorizers"],
diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts
index 8635b183e..98431775a 100644
--- a/extensions/bluebubbles/src/monitor.ts
+++ b/extensions/bluebubbles/src/monitor.ts
@@ -250,8 +250,178 @@ type WebhookTarget = {
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
+/**
+ * Entry type for debouncing inbound messages.
+ * Captures the normalized message and its target for later combined processing.
+ */
+type BlueBubblesDebounceEntry = {
+ message: NormalizedWebhookMessage;
+ target: WebhookTarget;
+};
+
+/**
+ * Default debounce window for inbound message coalescing (ms).
+ * This helps combine URL text + link preview balloon messages that BlueBubbles
+ * sends as separate webhook events when no explicit inbound debounce config exists.
+ */
+const DEFAULT_INBOUND_DEBOUNCE_MS = 350;
+
+/**
+ * Combines multiple debounced messages into a single message for processing.
+ * Used when multiple webhook events arrive within the debounce window.
+ */
+function combineDebounceEntries(entries: BlueBubblesDebounceEntry[]): NormalizedWebhookMessage {
+ if (entries.length === 0) {
+ throw new Error("Cannot combine empty entries");
+ }
+ if (entries.length === 1) {
+ return entries[0].message;
+ }
+
+ // Use the first message as the base (typically the text message)
+ const first = entries[0].message;
+
+ // Combine text from all entries, filtering out duplicates and empty strings
+ const seenTexts = new Set();
+ const textParts: string[] = [];
+
+ for (const entry of entries) {
+ const text = entry.message.text.trim();
+ if (!text) continue;
+ // Skip duplicate text (URL might be in both text message and balloon)
+ const normalizedText = text.toLowerCase();
+ if (seenTexts.has(normalizedText)) continue;
+ seenTexts.add(normalizedText);
+ textParts.push(text);
+ }
+
+ // Merge attachments from all entries
+ const allAttachments = entries.flatMap((e) => e.message.attachments ?? []);
+
+ // Use the latest timestamp
+ const timestamps = entries
+ .map((e) => e.message.timestamp)
+ .filter((t): t is number => typeof t === "number");
+ const latestTimestamp = timestamps.length > 0 ? Math.max(...timestamps) : first.timestamp;
+
+ // Collect all message IDs for reference
+ const messageIds = entries
+ .map((e) => e.message.messageId)
+ .filter((id): id is string => Boolean(id));
+
+ // Prefer reply context from any entry that has it
+ const entryWithReply = entries.find((e) => e.message.replyToId);
+
+ return {
+ ...first,
+ text: textParts.join(" "),
+ attachments: allAttachments.length > 0 ? allAttachments : first.attachments,
+ timestamp: latestTimestamp,
+ // Use first message's ID as primary (for reply reference), but we've coalesced others
+ messageId: messageIds[0] ?? first.messageId,
+ // Preserve reply context if present
+ replyToId: entryWithReply?.message.replyToId ?? first.replyToId,
+ replyToBody: entryWithReply?.message.replyToBody ?? first.replyToBody,
+ replyToSender: entryWithReply?.message.replyToSender ?? first.replyToSender,
+ // Clear balloonBundleId since we've combined (the combined message is no longer just a balloon)
+ balloonBundleId: undefined,
+ };
+}
+
const webhookTargets = new Map();
+/**
+ * Maps webhook targets to their inbound debouncers.
+ * Each target gets its own debouncer keyed by a unique identifier.
+ */
+const targetDebouncers = new Map<
+ WebhookTarget,
+ ReturnType
+>();
+
+function resolveBlueBubblesDebounceMs(
+ config: ClawdbotConfig,
+ core: BlueBubblesCoreRuntime,
+): number {
+ const inbound = config.messages?.inbound;
+ const hasExplicitDebounce =
+ typeof inbound?.debounceMs === "number" || typeof inbound?.byChannel?.bluebubbles === "number";
+ if (!hasExplicitDebounce) return DEFAULT_INBOUND_DEBOUNCE_MS;
+ return core.channel.debounce.resolveInboundDebounceMs({ cfg: config, channel: "bluebubbles" });
+}
+
+/**
+ * Creates or retrieves a debouncer for a webhook target.
+ */
+function getOrCreateDebouncer(target: WebhookTarget) {
+ const existing = targetDebouncers.get(target);
+ if (existing) return existing;
+
+ const { account, config, runtime, core } = target;
+
+ const debouncer = core.channel.debounce.createInboundDebouncer({
+ debounceMs: resolveBlueBubblesDebounceMs(config, core),
+ buildKey: (entry) => {
+ const msg = entry.message;
+ // Build key from account + chat + sender to coalesce messages from same source
+ const chatKey =
+ msg.chatGuid?.trim() ??
+ msg.chatIdentifier?.trim() ??
+ (msg.chatId ? String(msg.chatId) : "dm");
+ return `bluebubbles:${account.accountId}:${chatKey}:${msg.senderId}`;
+ },
+ shouldDebounce: (entry) => {
+ const msg = entry.message;
+ // Skip debouncing for messages with attachments - process immediately
+ if (msg.attachments && msg.attachments.length > 0) return false;
+ // Skip debouncing for from-me messages (they're just cached, not processed)
+ if (msg.fromMe) return false;
+ // Skip debouncing for control commands - process immediately
+ if (core.channel.text.hasControlCommand(msg.text, config)) return false;
+ // Debounce normal text messages and URL balloon messages
+ return true;
+ },
+ onFlush: async (entries) => {
+ if (entries.length === 0) return;
+
+ // Use target from first entry (all entries have same target due to key structure)
+ const flushTarget = entries[0].target;
+
+ if (entries.length === 1) {
+ // Single message - process normally
+ await processMessage(entries[0].message, flushTarget);
+ return;
+ }
+
+ // Multiple messages - combine and process
+ const combined = combineDebounceEntries(entries);
+
+ if (core.logging.shouldLogVerbose()) {
+ const count = entries.length;
+ const preview = combined.text.slice(0, 50);
+ runtime.log?.(
+ `[bluebubbles] coalesced ${count} messages: "${preview}${combined.text.length > 50 ? "..." : ""}"`,
+ );
+ }
+
+ await processMessage(combined, flushTarget);
+ },
+ onError: (err) => {
+ runtime.error?.(`[${account.accountId}] [bluebubbles] debounce flush failed: ${String(err)}`);
+ },
+ });
+
+ targetDebouncers.set(target, debouncer);
+ return debouncer;
+}
+
+/**
+ * Removes a debouncer for a target (called during unregistration).
+ */
+function removeDebouncer(target: WebhookTarget): void {
+ targetDebouncers.delete(target);
+}
+
function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "/";
@@ -275,6 +445,8 @@ export function registerBlueBubblesWebhookTarget(target: WebhookTarget): () => v
} else {
webhookTargets.delete(key);
}
+ // Clean up debouncer when target is unregistered
+ removeDebouncer(normalizedTarget);
};
}
@@ -1205,7 +1377,10 @@ export async function handleBlueBubblesWebhookRequest(
);
});
} else if (message) {
- processMessage(message, target).catch((err) => {
+ // Route messages through debouncer to coalesce rapid-fire events
+ // (e.g., text message + URL balloon arriving as separate webhooks)
+ const debouncer = getOrCreateDebouncer(target);
+ debouncer.enqueue({ message, target }).catch((err) => {
target.runtime.error?.(
`[${target.account.accountId}] BlueBubbles webhook failed: ${String(err)}`,
);
diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json
index 7fa12bc74..625c92df0 100644
--- a/extensions/matrix/package.json
+++ b/extensions/matrix/package.json
@@ -26,7 +26,7 @@
"dependencies": {
"@matrix-org/matrix-sdk-crypto-nodejs": "^0.4.0",
"markdown-it": "14.1.0",
- "matrix-bot-sdk": "0.8.0",
+ "@vector-im/matrix-bot-sdk": "0.8.0-element.3",
"music-metadata": "^11.10.6",
"zod": "^4.3.6"
},
diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts
index dae1a0f20..60f69e219 100644
--- a/extensions/matrix/src/matrix/actions/messages.ts
+++ b/extensions/matrix/src/matrix/actions/messages.ts
@@ -95,7 +95,7 @@ export async function readMatrixMessages(
: 20;
const token = opts.before?.trim() || opts.after?.trim() || undefined;
const dir = opts.after ? "f" : "b";
- // matrix-bot-sdk uses doRequest for room messages
+ // @vector-im/matrix-bot-sdk uses doRequest for room messages
const res = await client.doRequest(
"GET",
`/_matrix/client/v3/rooms/${encodeURIComponent(resolvedRoom)}/messages`,
diff --git a/extensions/matrix/src/matrix/actions/reactions.ts b/extensions/matrix/src/matrix/actions/reactions.ts
index 5c3f65305..044ef46c5 100644
--- a/extensions/matrix/src/matrix/actions/reactions.ts
+++ b/extensions/matrix/src/matrix/actions/reactions.ts
@@ -21,7 +21,7 @@ export async function listMatrixReactions(
typeof opts.limit === "number" && Number.isFinite(opts.limit)
? Math.max(1, Math.floor(opts.limit))
: 100;
- // matrix-bot-sdk uses doRequest for relations
+ // @vector-im/matrix-bot-sdk uses doRequest for relations
const res = await client.doRequest(
"GET",
`/_matrix/client/v1/rooms/${encodeURIComponent(resolvedRoom)}/relations/${encodeURIComponent(messageId)}/${RelationType.Annotation}/${EventType.Reaction}`,
diff --git a/extensions/matrix/src/matrix/actions/room.ts b/extensions/matrix/src/matrix/actions/room.ts
index 1b52404dc..68cf9b0a0 100644
--- a/extensions/matrix/src/matrix/actions/room.ts
+++ b/extensions/matrix/src/matrix/actions/room.ts
@@ -9,9 +9,9 @@ export async function getMatrixMemberInfo(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined;
- // matrix-bot-sdk uses getUserProfile
+ // @vector-im/matrix-bot-sdk uses getUserProfile
const profile = await client.getUserProfile(userId);
- // Note: matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
+ // Note: @vector-im/matrix-bot-sdk doesn't have getRoom().getMember() like matrix-js-sdk
// We'd need to fetch room state separately if needed
return {
userId,
@@ -36,7 +36,7 @@ export async function getMatrixRoomInfo(
const { client, stopOnDone } = await resolveActionClient(opts);
try {
const resolvedRoom = await resolveMatrixRoomId(client, roomId);
- // matrix-bot-sdk uses getRoomState for state events
+ // @vector-im/matrix-bot-sdk uses getRoomState for state events
let name: string | null = null;
let topic: string | null = null;
let canonicalAlias: string | null = null;
diff --git a/extensions/matrix/src/matrix/actions/summary.ts b/extensions/matrix/src/matrix/actions/summary.ts
index f58d6a9b8..2fa2d27b3 100644
--- a/extensions/matrix/src/matrix/actions/summary.ts
+++ b/extensions/matrix/src/matrix/actions/summary.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import {
EventType,
diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts
index 506e00783..75fddbd9c 100644
--- a/extensions/matrix/src/matrix/actions/types.ts
+++ b/extensions/matrix/src/matrix/actions/types.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
export const MsgType = {
Text: "m.text",
diff --git a/extensions/matrix/src/matrix/active-client.ts b/extensions/matrix/src/matrix/active-client.ts
index 9aa0ffdde..5ff540926 100644
--- a/extensions/matrix/src/matrix/active-client.ts
+++ b/extensions/matrix/src/matrix/active-client.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
let activeClient: MatrixClient | null = null;
diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts
index bc0729ddb..048c3bef9 100644
--- a/extensions/matrix/src/matrix/client/config.ts
+++ b/extensions/matrix/src/matrix/client/config.ts
@@ -1,4 +1,4 @@
-import { MatrixClient } from "matrix-bot-sdk";
+import { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { getMatrixRuntime } from "../../runtime.js";
diff --git a/extensions/matrix/src/matrix/client/create-client.ts b/extensions/matrix/src/matrix/client/create-client.ts
index 01dc2e7ad..874da7e92 100644
--- a/extensions/matrix/src/matrix/client/create-client.ts
+++ b/extensions/matrix/src/matrix/client/create-client.ts
@@ -5,8 +5,8 @@ import {
MatrixClient,
SimpleFsStorageProvider,
RustSdkCryptoStorageProvider,
-} from "matrix-bot-sdk";
-import type { IStorageProvider, ICryptoStorageProvider } from "matrix-bot-sdk";
+} from "@vector-im/matrix-bot-sdk";
+import type { IStorageProvider, ICryptoStorageProvider } from "@vector-im/matrix-bot-sdk";
import { ensureMatrixSdkLoggingConfigured } from "./logging.js";
import {
diff --git a/extensions/matrix/src/matrix/client/logging.ts b/extensions/matrix/src/matrix/client/logging.ts
index 7c4011fc5..5a7180597 100644
--- a/extensions/matrix/src/matrix/client/logging.ts
+++ b/extensions/matrix/src/matrix/client/logging.ts
@@ -1,4 +1,4 @@
-import { ConsoleLogger, LogService } from "matrix-bot-sdk";
+import { ConsoleLogger, LogService } from "@vector-im/matrix-bot-sdk";
let matrixSdkLoggingConfigured = false;
const matrixSdkBaseLogger = new ConsoleLogger();
diff --git a/extensions/matrix/src/matrix/client/shared.ts b/extensions/matrix/src/matrix/client/shared.ts
index fcde28268..da10fc360 100644
--- a/extensions/matrix/src/matrix/client/shared.ts
+++ b/extensions/matrix/src/matrix/client/shared.ts
@@ -1,5 +1,5 @@
-import { LogService } from "matrix-bot-sdk";
-import type { MatrixClient } from "matrix-bot-sdk";
+import { LogService } from "@vector-im/matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { CoreConfig } from "../types.js";
import { createMatrixClient } from "./create-client.js";
@@ -157,7 +157,7 @@ export async function waitForMatrixSync(_params: {
timeoutMs?: number;
abortSignal?: AbortSignal;
}): Promise {
- // matrix-bot-sdk handles sync internally in start()
+ // @vector-im/matrix-bot-sdk handles sync internally in start()
// This is kept for API compatibility but is essentially a no-op now
}
diff --git a/extensions/matrix/src/matrix/deps.ts b/extensions/matrix/src/matrix/deps.ts
index df2f58706..5777e43a7 100644
--- a/extensions/matrix/src/matrix/deps.ts
+++ b/extensions/matrix/src/matrix/deps.ts
@@ -6,7 +6,7 @@ import { fileURLToPath } from "node:url";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
-const MATRIX_SDK_PACKAGE = "matrix-bot-sdk";
+const MATRIX_SDK_PACKAGE = "@vector-im/matrix-bot-sdk";
export function isMatrixSdkAvailable(): boolean {
try {
@@ -30,9 +30,9 @@ export async function ensureMatrixSdkInstalled(params: {
if (isMatrixSdkAvailable()) return;
const confirm = params.confirm;
if (confirm) {
- const ok = await confirm("Matrix requires matrix-bot-sdk. Install now?");
+ const ok = await confirm("Matrix requires @vector-im/matrix-bot-sdk. Install now?");
if (!ok) {
- throw new Error("Matrix requires matrix-bot-sdk (install dependencies first).");
+ throw new Error("Matrix requires @vector-im/matrix-bot-sdk (install dependencies first).");
}
}
@@ -52,6 +52,6 @@ export async function ensureMatrixSdkInstalled(params: {
);
}
if (!isMatrixSdkAvailable()) {
- throw new Error("Matrix dependency install completed but matrix-bot-sdk is still missing.");
+ throw new Error("Matrix dependency install completed but @vector-im/matrix-bot-sdk is still missing.");
}
}
diff --git a/extensions/matrix/src/matrix/monitor/auto-join.ts b/extensions/matrix/src/matrix/monitor/auto-join.ts
index 564c78995..5feb5bc3a 100644
--- a/extensions/matrix/src/matrix/monitor/auto-join.ts
+++ b/extensions/matrix/src/matrix/monitor/auto-join.ts
@@ -1,5 +1,5 @@
-import type { MatrixClient } from "matrix-bot-sdk";
-import { AutojoinRoomsMixin } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
+import { AutojoinRoomsMixin } from "@vector-im/matrix-bot-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "../../types.js";
diff --git a/extensions/matrix/src/matrix/monitor/direct.ts b/extensions/matrix/src/matrix/monitor/direct.ts
index fff8383ca..cd2234fdd 100644
--- a/extensions/matrix/src/matrix/monitor/direct.ts
+++ b/extensions/matrix/src/matrix/monitor/direct.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
type DirectMessageCheck = {
roomId: string;
diff --git a/extensions/matrix/src/matrix/monitor/events.ts b/extensions/matrix/src/matrix/monitor/events.ts
index af49693ff..3705eb356 100644
--- a/extensions/matrix/src/matrix/monitor/events.ts
+++ b/extensions/matrix/src/matrix/monitor/events.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import type { MatrixAuth } from "../client.js";
diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts
index 4542e113a..19f9be38d 100644
--- a/extensions/matrix/src/matrix/monitor/handler.ts
+++ b/extensions/matrix/src/matrix/monitor/handler.ts
@@ -1,4 +1,4 @@
-import type { LocationMessageEventContent, MatrixClient } from "matrix-bot-sdk";
+import type { LocationMessageEventContent, MatrixClient } from "@vector-im/matrix-bot-sdk";
import {
createReplyPrefixContext,
@@ -110,7 +110,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
try {
const eventType = event.type;
if (eventType === EventType.RoomMessageEncrypted) {
- // Encrypted messages are decrypted automatically by matrix-bot-sdk with crypto enabled
+ // Encrypted messages are decrypted automatically by @vector-im/matrix-bot-sdk with crypto enabled
return;
}
@@ -436,7 +436,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
threadReplies,
messageId,
threadRootId,
- isThreadRoot: false, // matrix-bot-sdk doesn't have this info readily available
+ isThreadRoot: false, // @vector-im/matrix-bot-sdk doesn't have this info readily available
});
const route = core.channel.routing.resolveAgentRoute({
diff --git a/extensions/matrix/src/matrix/monitor/index.ts b/extensions/matrix/src/matrix/monitor/index.ts
index 35e75c4ed..0a203be41 100644
--- a/extensions/matrix/src/matrix/monitor/index.ts
+++ b/extensions/matrix/src/matrix/monitor/index.ts
@@ -244,7 +244,7 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
});
logVerboseMessage("matrix: client started");
- // matrix-bot-sdk client is already started via resolveSharedMatrixClient
+ // @vector-im/matrix-bot-sdk client is already started via resolveSharedMatrixClient
logger.info(`matrix: logged in as ${auth.userId}`);
// If E2EE is enabled, trigger device verification
diff --git a/extensions/matrix/src/matrix/monitor/location.ts b/extensions/matrix/src/matrix/monitor/location.ts
index 22374cad8..0054b6c6b 100644
--- a/extensions/matrix/src/matrix/monitor/location.ts
+++ b/extensions/matrix/src/matrix/monitor/location.ts
@@ -1,4 +1,4 @@
-import type { LocationMessageEventContent } from "matrix-bot-sdk";
+import type { LocationMessageEventContent } from "@vector-im/matrix-bot-sdk";
import {
formatLocationText,
diff --git a/extensions/matrix/src/matrix/monitor/media.test.ts b/extensions/matrix/src/matrix/monitor/media.test.ts
index 10cbd8b47..28ed5046a 100644
--- a/extensions/matrix/src/matrix/monitor/media.test.ts
+++ b/extensions/matrix/src/matrix/monitor/media.test.ts
@@ -29,7 +29,7 @@ describe("downloadMatrixMedia", () => {
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
- } as unknown as import("matrix-bot-sdk").MatrixClient;
+ } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
@@ -70,7 +70,7 @@ describe("downloadMatrixMedia", () => {
const client = {
crypto: { decryptMedia },
mxcToHttp: vi.fn().mockReturnValue("https://example/mxc"),
- } as unknown as import("matrix-bot-sdk").MatrixClient;
+ } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
const file = {
url: "mxc://example/file",
diff --git a/extensions/matrix/src/matrix/monitor/media.ts b/extensions/matrix/src/matrix/monitor/media.ts
index 1ade1d19c..0b33cca53 100644
--- a/extensions/matrix/src/matrix/monitor/media.ts
+++ b/extensions/matrix/src/matrix/monitor/media.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { getMatrixRuntime } from "../../runtime.js";
@@ -22,7 +22,7 @@ async function fetchMatrixMediaBuffer(params: {
mxcUrl: string;
maxBytes: number;
}): Promise<{ buffer: Buffer; headerType?: string } | null> {
- // matrix-bot-sdk provides mxcToHttp helper
+ // @vector-im/matrix-bot-sdk provides mxcToHttp helper
const url = params.client.mxcToHttp(params.mxcUrl);
if (!url) return null;
@@ -40,7 +40,7 @@ async function fetchMatrixMediaBuffer(params: {
/**
* Download and decrypt encrypted media from a Matrix room.
- * Uses matrix-bot-sdk's decryptMedia which handles both download and decryption.
+ * Uses @vector-im/matrix-bot-sdk's decryptMedia which handles both download and decryption.
*/
async function fetchEncryptedMediaBuffer(params: {
client: MatrixClient;
diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts
index f79ef5926..70ac9bacc 100644
--- a/extensions/matrix/src/matrix/monitor/replies.ts
+++ b/extensions/matrix/src/matrix/monitor/replies.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk";
import { sendMessageMatrix } from "../send.js";
diff --git a/extensions/matrix/src/matrix/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts
index e32b5b37a..cad377e1a 100644
--- a/extensions/matrix/src/matrix/monitor/room-info.ts
+++ b/extensions/matrix/src/matrix/monitor/room-info.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
export type MatrixRoomInfo = {
name?: string;
diff --git a/extensions/matrix/src/matrix/monitor/threads.ts b/extensions/matrix/src/matrix/monitor/threads.ts
index 3378d3b2b..4d618f329 100644
--- a/extensions/matrix/src/matrix/monitor/threads.ts
+++ b/extensions/matrix/src/matrix/monitor/threads.ts
@@ -1,4 +1,4 @@
-// Type for raw Matrix event from matrix-bot-sdk
+// Type for raw Matrix event from @vector-im/matrix-bot-sdk
type MatrixRawEvent = {
event_id: string;
sender: string;
diff --git a/extensions/matrix/src/matrix/monitor/types.ts b/extensions/matrix/src/matrix/monitor/types.ts
index c77cf0282..c910f931f 100644
--- a/extensions/matrix/src/matrix/monitor/types.ts
+++ b/extensions/matrix/src/matrix/monitor/types.ts
@@ -1,4 +1,4 @@
-import type { EncryptedFile, MessageEventContent } from "matrix-bot-sdk";
+import type { EncryptedFile, MessageEventContent } from "@vector-im/matrix-bot-sdk";
export const EventType = {
RoomMessage: "m.room.message",
diff --git a/extensions/matrix/src/matrix/probe.ts b/extensions/matrix/src/matrix/probe.ts
index 3bfdd1728..7bd54bdc4 100644
--- a/extensions/matrix/src/matrix/probe.ts
+++ b/extensions/matrix/src/matrix/probe.ts
@@ -49,7 +49,7 @@ export async function probeMatrix(params: {
accessToken: params.accessToken,
localTimeoutMs: params.timeoutMs,
});
- // matrix-bot-sdk uses getUserId() which calls whoami internally
+ // @vector-im/matrix-bot-sdk uses getUserId() which calls whoami internally
const userId = await client.getUserId();
result.ok = true;
result.userId = userId ?? null;
diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts
index c647eedb9..e82e18fb0 100644
--- a/extensions/matrix/src/matrix/send.test.ts
+++ b/extensions/matrix/src/matrix/send.test.ts
@@ -3,7 +3,7 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime } from "clawdbot/plugin-sdk";
import { setMatrixRuntime } from "../runtime.js";
-vi.mock("matrix-bot-sdk", () => ({
+vi.mock("@vector-im/matrix-bot-sdk", () => ({
ConsoleLogger: class {
trace = vi.fn();
debug = vi.fn();
@@ -60,7 +60,7 @@ const makeClient = () => {
sendMessage,
uploadContent,
getUserId: vi.fn().mockResolvedValue("@bot:example.org"),
- } as unknown as import("matrix-bot-sdk").MatrixClient;
+ } as unknown as import("@vector-im/matrix-bot-sdk").MatrixClient;
return { client, sendMessage, uploadContent };
};
diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts
index 264bd6429..1fed4198a 100644
--- a/extensions/matrix/src/matrix/send.ts
+++ b/extensions/matrix/src/matrix/send.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import type { PollInput } from "clawdbot/plugin-sdk";
import { getMatrixRuntime } from "../runtime.js";
@@ -72,7 +72,7 @@ export async function sendMessageMatrix(
? buildThreadRelation(threadId, opts.replyToId)
: buildReplyRelation(opts.replyToId);
const sendContent = async (content: MatrixOutboundContent) => {
- // matrix-bot-sdk uses sendMessage differently
+ // @vector-im/matrix-bot-sdk uses sendMessage differently
const eventId = await client.sendMessage(roomId, content);
return eventId;
};
@@ -172,7 +172,7 @@ export async function sendPollMatrix(
const pollPayload = threadId
? { ...pollContent, "m.relates_to": buildThreadRelation(threadId) }
: pollContent;
- // matrix-bot-sdk sendEvent returns eventId string directly
+ // @vector-im/matrix-bot-sdk sendEvent returns eventId string directly
const eventId = await client.sendEvent(roomId, M_POLL_START, pollPayload);
return {
diff --git a/extensions/matrix/src/matrix/send/client.ts b/extensions/matrix/src/matrix/send/client.ts
index 2faa19091..5b9338054 100644
--- a/extensions/matrix/src/matrix/send/client.ts
+++ b/extensions/matrix/src/matrix/send/client.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { getMatrixRuntime } from "../../runtime.js";
import { getActiveMatrixClient } from "../active-client.js";
@@ -57,7 +57,7 @@ export async function resolveMatrixClient(opts: {
// Ignore crypto prep failures for one-off sends; normal sync will retry.
}
}
- // matrix-bot-sdk uses start() instead of startClient()
+ // @vector-im/matrix-bot-sdk uses start() instead of startClient()
await client.start();
return { client, stopOnDone: true };
}
diff --git a/extensions/matrix/src/matrix/send/media.ts b/extensions/matrix/src/matrix/send/media.ts
index d4cf29805..8c564bddb 100644
--- a/extensions/matrix/src/matrix/send/media.ts
+++ b/extensions/matrix/src/matrix/send/media.ts
@@ -5,7 +5,7 @@ import type {
MatrixClient,
TimedFileInfo,
VideoFileInfo,
-} from "matrix-bot-sdk";
+} from "@vector-im/matrix-bot-sdk";
import { parseBuffer, type IFileInfo } from "music-metadata";
import { getMatrixRuntime } from "../../runtime.js";
diff --git a/extensions/matrix/src/matrix/send/targets.test.ts b/extensions/matrix/src/matrix/send/targets.test.ts
index 18499f895..7173b1cf6 100644
--- a/extensions/matrix/src/matrix/send/targets.test.ts
+++ b/extensions/matrix/src/matrix/send/targets.test.ts
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { EventType } from "./types.js";
let resolveMatrixRoomId: typeof import("./targets.js").resolveMatrixRoomId;
diff --git a/extensions/matrix/src/matrix/send/targets.ts b/extensions/matrix/src/matrix/send/targets.ts
index dde734ba2..6ec6ad6d7 100644
--- a/extensions/matrix/src/matrix/send/targets.ts
+++ b/extensions/matrix/src/matrix/send/targets.ts
@@ -1,4 +1,4 @@
-import type { MatrixClient } from "matrix-bot-sdk";
+import type { MatrixClient } from "@vector-im/matrix-bot-sdk";
import { EventType, type MatrixDirectAccountData } from "./types.js";
diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts
index eb59f8a62..2b91327aa 100644
--- a/extensions/matrix/src/matrix/send/types.ts
+++ b/extensions/matrix/src/matrix/send/types.ts
@@ -6,7 +6,7 @@ import type {
TextualMessageEventContent,
TimedFileInfo,
VideoFileInfo,
-} from "matrix-bot-sdk";
+} from "@vector-im/matrix-bot-sdk";
// Message types
export const MsgType = {
@@ -85,7 +85,7 @@ export type MatrixSendResult = {
};
export type MatrixSendOpts = {
- client?: import("matrix-bot-sdk").MatrixClient;
+ client?: import("@vector-im/matrix-bot-sdk").MatrixClient;
mediaUrl?: string;
accountId?: string;
replyToId?: string;
diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/onboarding.ts
index 28f24b788..80c034d44 100644
--- a/extensions/matrix/src/onboarding.ts
+++ b/extensions/matrix/src/onboarding.ts
@@ -185,7 +185,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = {
`Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`,
],
selectionHint: !sdkReady
- ? "install matrix-bot-sdk"
+ ? "install @vector-im/matrix-bot-sdk"
: configured
? "configured"
: "needs auth",
diff --git a/extensions/matrix/src/types.ts b/extensions/matrix/src/types.ts
index f44f1074d..f03734130 100644
--- a/extensions/matrix/src/types.ts
+++ b/extensions/matrix/src/types.ts
@@ -53,7 +53,7 @@ export type MatrixConfig = {
password?: string;
/** Optional device name when logging in via password. */
deviceName?: string;
- /** Initial sync limit for startup (default: matrix-bot-sdk default). */
+ /** Initial sync limit for startup (default: @vector-im/matrix-bot-sdk default). */
initialSyncLimit?: number;
/** Enable end-to-end encryption (E2EE). Default: false. */
encryption?: boolean;
diff --git a/extensions/memory-core/package.json b/extensions/memory-core/package.json
index c70da1395..af6a3f9cd 100644
--- a/extensions/memory-core/package.json
+++ b/extensions/memory-core/package.json
@@ -9,6 +9,6 @@
]
},
"peerDependencies": {
- "clawdbot": ">=2026.1.25"
+ "clawdbot": ">=2026.1.24-3"
}
}
diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts
index c83867a65..f54422d33 100644
--- a/extensions/msteams/src/reply-dispatcher.ts
+++ b/extensions/msteams/src/reply-dispatcher.ts
@@ -42,7 +42,7 @@ export function createMSTeamsReplyDispatcher(params: {
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
- await params.context.sendActivities([{ type: "typing" }]);
+ await params.context.sendActivity({ type: "typing" });
};
const typingCallbacks = createTypingCallbacks({
start: sendTypingIndicator,
@@ -70,38 +70,38 @@ export function createMSTeamsReplyDispatcher(params: {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
- });
- const messages = renderReplyPayloadsToMessages([payload], {
- textChunkLimit: params.textLimit,
- chunkText: true,
- mediaMode: "split",
- tableMode,
- chunkMode,
- });
- const mediaMaxBytes = resolveChannelMediaMaxBytes({
- cfg: params.cfg,
- resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
- });
- const ids = await sendMSTeamsMessages({
- replyStyle: params.replyStyle,
- adapter: params.adapter,
- appId: params.appId,
- conversationRef: params.conversationRef,
- context: params.context,
- messages,
- // Enable default retry/backoff for throttling/transient failures.
- retry: {},
- onRetry: (event) => {
- params.log.debug("retrying send", {
- replyStyle: params.replyStyle,
- ...event,
- });
- },
- tokenProvider: params.tokenProvider,
- sharePointSiteId: params.sharePointSiteId,
- mediaMaxBytes,
- });
- if (ids.length > 0) params.onSentMessageIds?.(ids);
+ });
+ const messages = renderReplyPayloadsToMessages([payload], {
+ textChunkLimit: params.textLimit,
+ chunkText: true,
+ mediaMode: "split",
+ tableMode,
+ chunkMode,
+ });
+ const mediaMaxBytes = resolveChannelMediaMaxBytes({
+ cfg: params.cfg,
+ resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
+ });
+ const ids = await sendMSTeamsMessages({
+ replyStyle: params.replyStyle,
+ adapter: params.adapter,
+ appId: params.appId,
+ conversationRef: params.conversationRef,
+ context: params.context,
+ messages,
+ // Enable default retry/backoff for throttling/transient failures.
+ retry: {},
+ onRetry: (event) => {
+ params.log.debug("retrying send", {
+ replyStyle: params.replyStyle,
+ ...event,
+ });
+ },
+ tokenProvider: params.tokenProvider,
+ sharePointSiteId: params.sharePointSiteId,
+ mediaMaxBytes,
+ });
+ if (ids.length > 0) params.onSentMessageIds?.(ids);
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
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/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts
new file mode 100644
index 000000000..d37d1a8c3
--- /dev/null
+++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts
@@ -0,0 +1,164 @@
+import fs from "node:fs/promises";
+import path from "node:path";
+import os from "node:os";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { resolveApiKeyForProfile } from "./oauth.js";
+import { ensureAuthProfileStore } from "./store.js";
+import type { AuthProfileStore } from "./types.js";
+
+describe("resolveApiKeyForProfile fallback to main agent", () => {
+ const previousStateDir = process.env.CLAWDBOT_STATE_DIR;
+ const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
+ const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
+ let tmpDir: string;
+ let mainAgentDir: string;
+ let secondaryAgentDir: string;
+
+ beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-fallback-test-"));
+ mainAgentDir = path.join(tmpDir, "agents", "main", "agent");
+ secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent");
+ await fs.mkdir(mainAgentDir, { recursive: true });
+ await fs.mkdir(secondaryAgentDir, { recursive: true });
+
+ // Set environment variables so resolveClawdbotAgentDir() returns mainAgentDir
+ process.env.CLAWDBOT_STATE_DIR = tmpDir;
+ process.env.CLAWDBOT_AGENT_DIR = mainAgentDir;
+ process.env.PI_CODING_AGENT_DIR = mainAgentDir;
+ });
+
+ afterEach(async () => {
+ vi.unstubAllGlobals();
+
+ // Restore original environment
+ if (previousStateDir === undefined) delete process.env.CLAWDBOT_STATE_DIR;
+ else process.env.CLAWDBOT_STATE_DIR = previousStateDir;
+ if (previousAgentDir === undefined) delete process.env.CLAWDBOT_AGENT_DIR;
+ else process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
+ if (previousPiAgentDir === undefined) delete process.env.PI_CODING_AGENT_DIR;
+ else process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
+
+ await fs.rm(tmpDir, { recursive: true, force: true });
+ });
+
+ it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => {
+ const profileId = "anthropic:claude-cli";
+ const now = Date.now();
+ const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
+ const freshTime = now + 60 * 60 * 1000; // 1 hour from now
+
+ // Write expired credentials for secondary agent
+ const secondaryStore: AuthProfileStore = {
+ version: 1,
+ profiles: {
+ [profileId]: {
+ type: "oauth",
+ provider: "anthropic",
+ access: "expired-access-token",
+ refresh: "expired-refresh-token",
+ expires: expiredTime,
+ },
+ },
+ };
+ await fs.writeFile(
+ path.join(secondaryAgentDir, "auth-profiles.json"),
+ JSON.stringify(secondaryStore),
+ );
+
+ // Write fresh credentials for main agent
+ const mainStore: AuthProfileStore = {
+ version: 1,
+ profiles: {
+ [profileId]: {
+ type: "oauth",
+ provider: "anthropic",
+ access: "fresh-access-token",
+ refresh: "fresh-refresh-token",
+ expires: freshTime,
+ },
+ },
+ };
+ await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(mainStore));
+
+ // Mock fetch to simulate OAuth refresh failure
+ const fetchSpy = vi.fn(async () => {
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ });
+ });
+ vi.stubGlobal("fetch", fetchSpy);
+
+ // Load the secondary agent's store (will merge with main agent's store)
+ const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
+
+ // Call resolveApiKeyForProfile with the secondary agent's expired credentials
+ // This should:
+ // 1. Try to refresh the expired token (fails due to mocked fetch)
+ // 2. Fall back to main agent's fresh credentials
+ // 3. Copy those credentials to the secondary agent
+ const result = await resolveApiKeyForProfile({
+ store: loadedSecondaryStore,
+ profileId,
+ agentDir: secondaryAgentDir,
+ });
+
+ expect(result).not.toBeNull();
+ expect(result?.apiKey).toBe("fresh-access-token");
+ expect(result?.provider).toBe("anthropic");
+
+ // Verify the credentials were copied to the secondary agent
+ const updatedSecondaryStore = JSON.parse(
+ await fs.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
+ ) as AuthProfileStore;
+ expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
+ access: "fresh-access-token",
+ expires: freshTime,
+ });
+ });
+
+ it("throws error when both secondary and main agent credentials are expired", async () => {
+ const profileId = "anthropic:claude-cli";
+ const now = Date.now();
+ const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
+
+ // Write expired credentials for both agents
+ const expiredStore: AuthProfileStore = {
+ version: 1,
+ profiles: {
+ [profileId]: {
+ type: "oauth",
+ provider: "anthropic",
+ access: "expired-access-token",
+ refresh: "expired-refresh-token",
+ expires: expiredTime,
+ },
+ },
+ };
+ await fs.writeFile(
+ path.join(secondaryAgentDir, "auth-profiles.json"),
+ JSON.stringify(expiredStore),
+ );
+ await fs.writeFile(path.join(mainAgentDir, "auth-profiles.json"), JSON.stringify(expiredStore));
+
+ // Mock fetch to simulate OAuth refresh failure
+ const fetchSpy = vi.fn(async () => {
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
+ status: 400,
+ headers: { "Content-Type": "application/json" },
+ });
+ });
+ vi.stubGlobal("fetch", fetchSpy);
+
+ const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir);
+
+ // Should throw because both agents have expired credentials
+ await expect(
+ resolveApiKeyForProfile({
+ store: loadedSecondaryStore,
+ profileId,
+ agentDir: secondaryAgentDir,
+ }),
+ ).rejects.toThrow(/OAuth token refresh failed/);
+ });
+});
diff --git a/src/agents/auth-profiles/oauth.ts b/src/agents/auth-profiles/oauth.ts
index 4138cda94..d7b3360de 100644
--- a/src/agents/auth-profiles/oauth.ts
+++ b/src/agents/auth-profiles/oauth.ts
@@ -4,7 +4,7 @@ import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../../config/config.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
-import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js";
+import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
import { formatAuthDoctorHint } from "./doctor.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
@@ -196,6 +196,32 @@ export async function resolveApiKeyForProfile(params: {
// keep original error
}
}
+
+ // Fallback: if this is a secondary agent, try using the main agent's credentials
+ if (params.agentDir) {
+ try {
+ const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir)
+ const mainCred = mainStore.profiles[profileId];
+ if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) {
+ // Main agent has fresh credentials - copy them to this agent and use them
+ refreshedStore.profiles[profileId] = { ...mainCred };
+ saveAuthProfileStore(refreshedStore, params.agentDir);
+ log.info("inherited fresh OAuth credentials from main agent", {
+ profileId,
+ agentDir: params.agentDir,
+ expires: new Date(mainCred.expires).toISOString(),
+ });
+ return {
+ apiKey: buildOAuthApiKey(mainCred.provider, mainCred),
+ provider: mainCred.provider,
+ email: mainCred.email,
+ };
+ }
+ } catch {
+ // keep original error if main agent fallback also fails
+ }
+ }
+
const message = error instanceof Error ? error.message : String(error);
const hint = formatAuthDoctorHint({
cfg,
diff --git a/src/agents/compaction.test.ts b/src/agents/compaction.test.ts
index 1cfacda9a..32511a586 100644
--- a/src/agents/compaction.test.ts
+++ b/src/agents/compaction.test.ts
@@ -103,5 +103,47 @@ describe("pruneHistoryForContextShare", () => {
expect(pruned.droppedChunks).toBe(0);
expect(pruned.messages.length).toBe(messages.length);
expect(pruned.keptTokens).toBe(estimateMessagesTokens(messages));
+ expect(pruned.droppedMessagesList).toEqual([]);
+ });
+
+ it("returns droppedMessagesList containing dropped messages", () => {
+ const messages: AgentMessage[] = [
+ makeMessage(1, 4000),
+ makeMessage(2, 4000),
+ makeMessage(3, 4000),
+ makeMessage(4, 4000),
+ ];
+ const maxContextTokens = 2000; // budget is 1000 tokens (50%)
+ const pruned = pruneHistoryForContextShare({
+ messages,
+ maxContextTokens,
+ maxHistoryShare: 0.5,
+ parts: 2,
+ });
+
+ expect(pruned.droppedChunks).toBeGreaterThan(0);
+ expect(pruned.droppedMessagesList.length).toBe(pruned.droppedMessages);
+
+ // All messages accounted for: kept + dropped = original
+ const allIds = [
+ ...pruned.droppedMessagesList.map((m) => m.timestamp),
+ ...pruned.messages.map((m) => m.timestamp),
+ ].sort((a, b) => a - b);
+ const originalIds = messages.map((m) => m.timestamp).sort((a, b) => a - b);
+ expect(allIds).toEqual(originalIds);
+ });
+
+ it("returns empty droppedMessagesList when no pruning needed", () => {
+ const messages: AgentMessage[] = [makeMessage(1, 100)];
+ const pruned = pruneHistoryForContextShare({
+ messages,
+ maxContextTokens: 100_000,
+ maxHistoryShare: 0.5,
+ parts: 2,
+ });
+
+ expect(pruned.droppedChunks).toBe(0);
+ expect(pruned.droppedMessagesList).toEqual([]);
+ expect(pruned.messages.length).toBe(1);
});
});
diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts
index 2ab4566fd..a88447307 100644
--- a/src/agents/compaction.ts
+++ b/src/agents/compaction.ts
@@ -301,6 +301,7 @@ export function pruneHistoryForContextShare(params: {
parts?: number;
}): {
messages: AgentMessage[];
+ droppedMessagesList: AgentMessage[];
droppedChunks: number;
droppedMessages: number;
droppedTokens: number;
@@ -310,6 +311,7 @@ export function pruneHistoryForContextShare(params: {
const maxHistoryShare = params.maxHistoryShare ?? 0.5;
const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare));
let keptMessages = params.messages;
+ const allDroppedMessages: AgentMessage[] = [];
let droppedChunks = 0;
let droppedMessages = 0;
let droppedTokens = 0;
@@ -323,11 +325,13 @@ export function pruneHistoryForContextShare(params: {
droppedChunks += 1;
droppedMessages += dropped.length;
droppedTokens += estimateMessagesTokens(dropped);
+ allDroppedMessages.push(...dropped);
keptMessages = rest.flat();
}
return {
messages: keptMessages,
+ droppedMessagesList: allDroppedMessages,
droppedChunks,
droppedMessages,
droppedTokens,
diff --git a/src/agents/pi-embedded-runner/extensions.ts b/src/agents/pi-embedded-runner/extensions.ts
index 73deae21d..bb592e930 100644
--- a/src/agents/pi-embedded-runner/extensions.ts
+++ b/src/agents/pi-embedded-runner/extensions.ts
@@ -7,6 +7,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveContextWindowInfo } from "../context-window-guard.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
+import { setCompactionSafeguardRuntime } from "../pi-extensions/compaction-safeguard-runtime.js";
import { setContextPruningRuntime } from "../pi-extensions/context-pruning/runtime.js";
import { computeEffectiveSettings } from "../pi-extensions/context-pruning/settings.js";
import { makeToolPrunablePredicate } from "../pi-extensions/context-pruning/tools.js";
@@ -75,6 +76,10 @@ export function buildEmbeddedExtensionPaths(params: {
}): string[] {
const paths: string[] = [];
if (resolveCompactionMode(params.cfg) === "safeguard") {
+ const compactionCfg = params.cfg?.agents?.defaults?.compaction;
+ setCompactionSafeguardRuntime(params.sessionManager, {
+ maxHistoryShare: compactionCfg?.maxHistoryShare,
+ });
paths.push(resolvePiExtensionPath("compaction-safeguard"));
}
const pruning = buildContextPruningExtension(params);
diff --git a/src/agents/pi-extensions/compaction-safeguard-runtime.ts b/src/agents/pi-extensions/compaction-safeguard-runtime.ts
new file mode 100644
index 000000000..f42cf7abe
--- /dev/null
+++ b/src/agents/pi-extensions/compaction-safeguard-runtime.ts
@@ -0,0 +1,34 @@
+export type CompactionSafeguardRuntimeValue = {
+ maxHistoryShare?: number;
+};
+
+// Session-scoped runtime registry keyed by object identity.
+// Follows the same WeakMap pattern as context-pruning/runtime.ts.
+const REGISTRY = new WeakMap