Compare commits

...

9 Commits

Author SHA1 Message Date
Peter Steinberger
667bee0de0 docs: add Bedrock EC2 role notes (#1625) (thanks @sergical) 2026-01-24 21:16:59 +00:00
Sergiy Dybskiy
cd48aac32b docs: add EC2 instance role setup for Bedrock
- Add EC2 Instance Roles section with workaround for IMDS credential detection
- Include step-by-step IAM role and instance profile setup
- Document required permissions (bedrock:InvokeModel, ListFoundationModels)
- Update example model to Claude Opus 4.5 (latest)

The AWS SDK auto-detects EC2 instance roles via IMDS, but Clawdbot's
credential detection only checks environment variables. The workaround
is to set AWS_PROFILE=default to signal credentials are available.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-24 21:13:13 +00:00
Peter Steinberger
a4f6b3528a fix: cover elevated ask approvals (#1636) 2026-01-24 21:12:46 +00:00
Peter Steinberger
9f8e66359e fix: default direct gateway port + docs (#1603) (thanks @ngutman) 2026-01-24 21:10:54 +00:00
Hunter Miller
8a2720db4c fix(tlon): Fix Zod v4 record() and @urbit/aura v3 API changes (#1631)
* fix(tlon): Fix Zod v4 record() and @urbit/aura v3 API changes

- Fix Zod v4.3.6 bug: single-arg z.record() fails with toJSONSchema()
  - Use two-arg form: z.record(z.string(), schema)
  - Fixes 'Cannot read properties of undefined (reading _zod)' error

- Fix @urbit/aura v3.0.0 API migration:
  - unixToDa() → da.fromUnix()
  - formatUd() → scot('ud', ...)
  - Fixes '(0 , _aura.unixToDa) is not a function' error

These were blocking Tlon plugin loading and outbound messaging.

* fix: add tlon schema/aura tests (#1631) (thanks @arthyn)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 21:09:18 +00:00
Nimrod Gutman
5330595a5a feat(macos): add direct gateway transport 2026-01-24 21:02:13 +00:00
Peter Steinberger
2c5141d7df docs: clarify beta promotion flow 2026-01-24 20:59:41 +00:00
Lucas Czekaj
483fba41b9 feat(discord): add exec approval forwarding to DMs (#1621)
* feat(discord): add exec approval forwarding to DMs

Add support for forwarding exec approval requests to Discord DMs,
allowing users to approve/deny command execution via interactive buttons.

Features:
- New DiscordExecApprovalHandler that connects to gateway and listens
  for exec.approval.requested/resolved events
- Sends DMs with embeds showing command details and 3 buttons:
  Allow once, Always allow, Deny
- Configurable via channels.discord.execApprovals with:
  - enabled: boolean
  - approvers: Discord user IDs to notify
  - agentFilter: only forward for specific agents
  - sessionFilter: only forward for matching session patterns
- Updates message embed when approval is resolved or expires

Also fixes exec completion routing: when async exec completes after
approval, the heartbeat now uses a specialized prompt to ensure the
model relays the result to the user instead of responding HEARTBEAT_OK.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: generic exec approvals forwarding (#1621) (thanks @czekaj)

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-24 20:56:40 +00:00
Ivan Casco
fe7436a1f6 fix(exec): only set security=full when elevated mode is full (#1616) 2026-01-24 20:55:21 +00:00
46 changed files with 2295 additions and 190 deletions

View File

@@ -8,11 +8,15 @@ Docs: https://docs.clawd.bot
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance.
- Docs: update Fly.io guide notes.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
### Fixes
- Web UI: hide internal `message_id` hints in chat bubbles.
- Heartbeat: normalize target identifiers for consistent routing.
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
## 2026.1.23-1

View File

@@ -24,6 +24,11 @@ final class AppState {
case remote
}
enum RemoteTransport: String {
case ssh
case direct
}
var isPaused: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.isPaused, forKey: pauseDefaultsKey) } }
}
@@ -166,6 +171,10 @@ final class AppState {
}
}
var remoteTransport: RemoteTransport {
didSet { self.syncGatewayConfigIfNeeded() }
}
var canvasEnabled: Bool {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
@@ -200,6 +209,10 @@ final class AppState {
}
}
var remoteUrl: String {
didSet { self.syncGatewayConfigIfNeeded() }
}
var remoteIdentity: String {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.remoteIdentity, forKey: remoteIdentityKey) } }
}
@@ -263,13 +276,15 @@ final class AppState {
}
let configRoot = ClawdbotConfigFile.loadDict()
let configGateway = configRoot["gateway"] as? [String: Any]
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
let configRemoteUrl = GatewayRemoteConfig.resolveUrlString(root: configRoot)
let configRemoteTransport = GatewayRemoteConfig.resolveTransport(root: configRoot)
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
self.remoteTransport = configRemoteTransport
self.connectionMode = resolvedConnectionMode
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
if resolvedConnectionMode == .remote,
configRemoteTransport != .direct,
storedRemoteTarget.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty,
let host = AppState.remoteHost(from: configRemoteUrl)
{
@@ -277,6 +292,7 @@ final class AppState {
} else {
self.remoteTarget = storedRemoteTarget
}
self.remoteUrl = configRemoteUrl ?? ""
self.remoteIdentity = UserDefaults.standard.string(forKey: remoteIdentityKey) ?? ""
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
@@ -354,10 +370,11 @@ final class AppState {
private func applyConfigOverrides(_ root: [String: Any]) {
let gateway = root["gateway"] as? [String: Any]
let modeRaw = (gateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let remoteUrl = (gateway?["remote"] as? [String: Any])?["url"] as? String
let remoteUrl = GatewayRemoteConfig.resolveUrlString(root: root)
let hasRemoteUrl = !(remoteUrl?
.trimmingCharacters(in: .whitespacesAndNewlines)
.isEmpty ?? true)
let remoteTransport = GatewayRemoteConfig.resolveTransport(root: root)
let desiredMode: ConnectionMode? = switch modeRaw {
case "local":
@@ -378,8 +395,17 @@ final class AppState {
self.connectionMode = .remote
}
if remoteTransport != self.remoteTransport {
self.remoteTransport = remoteTransport
}
let remoteUrlText = remoteUrl ?? ""
if remoteUrlText != self.remoteUrl {
self.remoteUrl = remoteUrlText
}
let targetMode = desiredMode ?? self.connectionMode
if targetMode == .remote,
remoteTransport != .direct,
let host = AppState.remoteHost(from: remoteUrl)
{
self.updateRemoteTarget(host: host)
@@ -402,6 +428,8 @@ final class AppState {
let connectionMode = self.connectionMode
let remoteTarget = self.remoteTarget
let remoteIdentity = self.remoteIdentity
let remoteTransport = self.remoteTransport
let remoteUrl = self.remoteUrl
let desiredMode: String? = switch connectionMode {
case .local:
"local"
@@ -435,39 +463,63 @@ final class AppState {
var remote = gateway["remote"] as? [String: Any] ?? [:]
var remoteChanged = false
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
if remoteTransport == .direct {
let trimmedUrl = remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmedUrl.isEmpty {
if remote["url"] != nil {
remote.removeValue(forKey: "url")
remoteChanged = true
}
} else {
let normalizedUrl = GatewayRemoteConfig.normalizeGatewayUrlString(trimmedUrl) ?? trimmedUrl
if (remote["url"] as? String) != normalizedUrl {
remote["url"] = normalizedUrl
remoteChanged = true
}
}
if (remote["transport"] as? String) != RemoteTransport.direct.rawValue {
remote["transport"] = RemoteTransport.direct.rawValue
remoteChanged = true
}
}
} else {
if remote["transport"] != nil {
remote.removeValue(forKey: "transport")
remoteChanged = true
}
if let host = remoteHost {
let existingUrl = (remote["url"] as? String)?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
let port = parsedExisting?.port ?? 18789
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
if existingUrl != desiredUrl {
remote["url"] = desiredUrl
remoteChanged = true
}
}
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
if !sanitizedTarget.isEmpty {
if (remote["sshTarget"] as? String) != sanitizedTarget {
remote["sshTarget"] = sanitizedTarget
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
} else if remote["sshTarget"] != nil {
remote.removeValue(forKey: "sshTarget")
remoteChanged = true
}
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedIdentity.isEmpty {
if (remote["sshIdentity"] as? String) != trimmedIdentity {
remote["sshIdentity"] = trimmedIdentity
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
} else if remote["sshIdentity"] != nil {
remote.removeValue(forKey: "sshIdentity")
remoteChanged = true
}
if remoteChanged {
@@ -621,8 +673,10 @@ extension AppState {
state.iconOverride = .system
state.heartbeatsEnabled = true
state.connectionMode = .local
state.remoteTransport = .ssh
state.canvasEnabled = true
state.remoteTarget = "user@example.com"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteIdentity = "~/.ssh/id_ed25519"
state.remoteProjectRoot = "~/Projects/clawdbot"
state.remoteCliPath = ""

View File

@@ -0,0 +1,47 @@
import ClawdbotDiscovery
import Foundation
enum GatewayDiscoveryHelpers {
static func sshTarget(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
let user = NSUserName()
var target = "\(user)@\(host)"
if gateway.sshPort != 22 {
target += ":\(gateway.sshPort)"
}
return target
}
static func directUrl(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
self.directGatewayUrl(
tailnetDns: gateway.tailnetDns,
lanHost: gateway.lanHost,
gatewayPort: gateway.gatewayPort)
}
static func directGatewayUrl(
tailnetDns: String?,
lanHost: String?,
gatewayPort: Int?) -> String?
{
if let tailnetDns = self.sanitizedTailnetHost(tailnetDns) {
return "wss://\(tailnetDns)"
}
guard let lanHost = self.trimmed(lanHost), !lanHost.isEmpty else { return nil }
let port = gatewayPort ?? 18789
return "ws://\(lanHost):\(port)"
}
static func sanitizedTailnetHost(_ host: String?) -> String? {
guard let host = self.trimmed(host), !host.isEmpty else { return nil }
if host.hasSuffix(".internal.") || host.hasSuffix(".internal") {
return nil
}
return host
}
private static func trimmed(_ value: String?) -> String? {
value?.trimmingCharacters(in: .whitespacesAndNewlines)
}
}

View File

@@ -4,6 +4,8 @@ import SwiftUI
struct GatewayDiscoveryInlineList: View {
var discovery: GatewayDiscoveryModel
var currentTarget: String?
var currentUrl: String?
var transport: AppState.RemoteTransport
var onSelect: (GatewayDiscoveryModel.DiscoveredGateway) -> Void
@State private var hoveredGatewayID: GatewayDiscoveryModel.DiscoveredGateway.ID?
@@ -25,9 +27,8 @@ struct GatewayDiscoveryInlineList: View {
} else {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.discovery.gateways.prefix(6)) { gateway in
let target = self.suggestedSSHTarget(gateway)
let selected = (target != nil && self.currentTarget?
.trimmingCharacters(in: .whitespacesAndNewlines) == target)
let display = self.displayInfo(for: gateway)
let selected = display.selected
Button {
withAnimation(.spring(response: 0.25, dampingFraction: 0.9)) {
@@ -40,7 +41,7 @@ struct GatewayDiscoveryInlineList: View {
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
Text(target ?? "Gateway pairing only")
Text(display.label)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -83,27 +84,26 @@ struct GatewayDiscoveryInlineList: View {
.fill(Color(NSColor.controlBackgroundColor)))
}
}
.help("Click a discovered gateway to fill the SSH target.")
.help(self.transport == .direct
? "Click a discovered gateway to fill the gateway URL."
: "Click a discovered gateway to fill the SSH target.")
}
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
let host = self.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost
guard let host else { return nil }
let user = NSUserName()
return GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
}
private func sanitizedTailnetHost(_ host: String?) -> String? {
guard let host else { return nil }
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return nil }
if trimmed.hasSuffix(".internal.") || trimmed.hasSuffix(".internal") {
return nil
private func displayInfo(
for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> (label: String, selected: Bool)
{
switch self.transport {
case .direct:
let url = GatewayDiscoveryHelpers.directUrl(for: gateway)
let label = url ?? "Gateway pairing only"
let selected = url != nil && self.trimmed(self.currentUrl) == url
return (label, selected)
case .ssh:
let target = GatewayDiscoveryHelpers.sshTarget(for: gateway)
let label = target ?? "Gateway pairing only"
let selected = target != nil && self.trimmed(self.currentTarget) == target
return (label, selected)
}
return trimmed
}
private func rowBackground(selected: Bool, hovered: Bool) -> Color {
@@ -111,6 +111,10 @@ struct GatewayDiscoveryInlineList: View {
if hovered { return Color.secondary.opacity(0.08) }
return Color.clear
}
private func trimmed(_ value: String?) -> String {
value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
}
}
struct GatewayDiscoveryMenu: View {

View File

@@ -311,6 +311,19 @@ actor GatewayEndpointStore {
token: token,
password: password))
case .remote:
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
self.cancelRemoteEnsure()
self.setState(.unavailable(
mode: .remote,
reason: "gateway.remote.url missing or invalid for direct transport"))
return
}
self.cancelRemoteEnsure()
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return
}
let port = await self.deps.remotePortIfRunning()
guard let port else {
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
@@ -341,6 +354,25 @@ actor GatewayEndpointStore {
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
guard let port = GatewayRemoteConfig.defaultPort(for: url),
let portInt = UInt16(exactly: port)
else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Invalid gateway.remote.url port"])
}
self.logger.info("remote transport direct; skipping SSH tunnel")
return portInt
}
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
throw NSError(
@@ -401,6 +433,21 @@ actor GatewayEndpointStore {
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let root = ClawdbotConfigFile.loadDict()
if GatewayRemoteConfig.resolveTransport(root: root) == .direct {
guard let url = GatewayRemoteConfig.resolveGatewayUrl(root: root) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "gateway.remote.url missing or invalid"])
}
let token = self.deps.token()
let password = self.deps.password()
self.cancelRemoteEnsure()
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password)
}
self.kickRemoteEnsureIfNeeded(detail: detail)
guard let ensure = self.remoteEnsure else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])

View File

@@ -0,0 +1,64 @@
import Foundation
enum GatewayRemoteConfig {
static func resolveTransport(root: [String: Any]) -> AppState.RemoteTransport {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let raw = remote["transport"] as? String
else {
return .ssh
}
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
return trimmed == AppState.RemoteTransport.direct.rawValue ? .direct : .ssh
}
static func resolveUrlString(root: [String: Any]) -> String? {
guard let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let urlRaw = remote["url"] as? String
else {
return nil
}
let trimmed = urlRaw.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
static func resolveGatewayUrl(root: [String: Any]) -> URL? {
guard let raw = self.resolveUrlString(root: root) else { return nil }
return self.normalizeGatewayUrl(raw)
}
static func normalizeGatewayUrlString(_ raw: String) -> String? {
self.normalizeGatewayUrl(raw)?.absoluteString
}
static func normalizeGatewayUrl(_ raw: String) -> URL? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return nil }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
guard !host.isEmpty else { return nil }
if scheme == "ws", url.port == nil {
guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return url
}
components.port = 18789
return components.url
}
return url
}
static func defaultPort(for url: URL) -> Int? {
if let port = url.port { return port }
let scheme = url.scheme?.lowercased() ?? ""
switch scheme {
case "wss":
return 443
case "ws":
return 18789
default:
return nil
}
}
}

View File

@@ -17,6 +17,7 @@ struct GeneralSettings: View {
@State private var showRemoteAdvanced = false
private let isPreview = ProcessInfo.processInfo.isPreview
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
private var remoteLabelWidth: CGFloat { 88 }
var body: some View {
ScrollView(.vertical) {
@@ -104,7 +105,7 @@ struct GeneralSettings: View {
Picker("Mode", selection: self.$state.connectionMode) {
Text("Not configured").tag(AppState.ConnectionMode.unconfigured)
Text("Local (this Mac)").tag(AppState.ConnectionMode.local)
Text("Remote over SSH").tag(AppState.ConnectionMode.remote)
Text("Remote (another host)").tag(AppState.ConnectionMode.remote)
}
.pickerStyle(.menu)
.labelsHidden()
@@ -136,60 +137,51 @@ struct GeneralSettings: View {
private var remoteCard: some View {
VStack(alignment: .leading, spacing: 10) {
HStack(alignment: .center, spacing: 10) {
Text("SSH")
.font(.callout.weight(.semibold))
.frame(width: 48, 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 || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
self.remoteTransportRow
if self.state.remoteTransport == .ssh {
self.remoteSshRow
} else {
self.remoteDirectRow
}
GatewayDiscoveryInlineList(
discovery: self.gatewayDiscovery,
currentTarget: self.state.remoteTarget)
currentTarget: self.state.remoteTarget,
currentUrl: self.state.remoteUrl,
transport: self.state.remoteTransport)
{ gateway in
self.applyDiscoveredGateway(gateway)
}
.padding(.leading, 58)
.padding(.leading, self.remoteLabelWidth + 10)
self.remoteStatusView
.padding(.leading, 58)
.padding(.leading, self.remoteLabelWidth + 10)
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("Project root") {
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("CLI path") {
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
if self.state.remoteTransport == .ssh {
DisclosureGroup(isExpanded: self.$showRemoteAdvanced) {
VStack(alignment: .leading, spacing: 8) {
LabeledContent("Identity file") {
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("Project root") {
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
LabeledContent("CLI path") {
TextField("/Applications/Clawdbot.app/.../clawdbot", text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: 280)
}
}
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
}
.padding(.top, 4)
} label: {
Text("Advanced")
.font(.callout.weight(.semibold))
}
// Diagnostics
@@ -219,16 +211,89 @@ struct GeneralSettings: View {
}
}
Text("Tip: enable Tailscale for stable remote access.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
if self.state.remoteTransport == .ssh {
Text("Tip: enable Tailscale for stable remote access.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
} else {
Text("Tip: use Tailscale Serve so the gateway has a valid HTTPS cert.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
}
}
.transition(.opacity)
.onAppear { self.gatewayDiscovery.start() }
.onDisappear { self.gatewayDiscovery.stop() }
}
private var remoteTransportRow: some View {
HStack(alignment: .center, spacing: 10) {
Text("Transport")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
Picker("Transport", selection: self.$state.remoteTransport) {
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(maxWidth: 320)
}
}
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")
}
}
.buttonStyle(.borderedProminent)
.disabled(self.remoteStatus == .checking || self.state.remoteTarget
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
}
private var remoteDirectRow: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 10) {
Text("Gateway")
.font(.callout.weight(.semibold))
.frame(width: self.remoteLabelWidth, alignment: .leading)
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.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 || self.state.remoteUrl
.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}
Text("Direct mode requires a ws:// or wss:// URL (Tailscale Serve uses wss://<magicdns>).")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, self.remoteLabelWidth + 10)
}
}
private var controlStatusLine: String {
switch ControlChannel.shared.state {
case .connected: "Connected"
@@ -458,24 +523,36 @@ extension GeneralSettings {
func testRemote() async {
self.remoteStatus = .checking
let settings = CommandResolver.connectionSettings()
guard !settings.target.isEmpty else {
self.remoteStatus = .failed("Set an SSH target first")
return
if self.state.remoteTransport == .direct {
let trimmedUrl = self.state.remoteUrl.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedUrl.isEmpty else {
self.remoteStatus = .failed("Set a gateway URL first")
return
}
guard Self.isValidWsUrl(trimmedUrl) else {
self.remoteStatus = .failed("Gateway URL must start with ws:// or wss://")
return
}
} else {
guard !settings.target.isEmpty else {
self.remoteStatus = .failed("Set an SSH target first")
return
}
// Step 1: basic SSH reachability check
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
}
// Step 1: basic SSH reachability check
let sshResult = await ShellExecutor.run(
command: Self.sshCheckCommand(target: settings.target, identity: settings.identity),
cwd: nil,
env: nil,
timeout: 8)
guard sshResult.ok else {
self.remoteStatus = .failed(self.formatSSHFailure(sshResult, target: settings.target))
return
}
// Step 2: control channel health over tunnel
// Step 2: control channel health check
let originalMode = AppStateStore.shared.connectionMode
do {
try await ControlChannel.shared.configure(mode: .remote(
@@ -502,6 +579,14 @@ extension GeneralSettings {
}
}
private static func isValidWsUrl(_ raw: String) -> Bool {
guard let url = URL(string: raw.trimmingCharacters(in: .whitespacesAndNewlines)) else { return false }
let scheme = url.scheme?.lowercased() ?? ""
guard scheme == "ws" || scheme == "wss" else { return false }
let host = url.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return !host.isEmpty
}
private static func sshCheckCommand(target: String, identity: String) -> [String] {
var args: [String] = [
"/usr/bin/ssh",
@@ -570,12 +655,18 @@ extension GeneralSettings {
let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return }
let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
self.state.remoteUrl = url
}
} else {
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,
host: host,
port: gateway.sshPort)
self.state.remoteCliPath = gateway.cliPath ?? ""
ClawdbotConfigFile.setRemoteGatewayUrl(host: host, port: gateway.gatewayPort)
}
}
}
@@ -598,7 +689,9 @@ extension GeneralSettings {
static func exerciseForTesting() {
let state = AppState(preview: true)
state.connectionMode = .remote
state.remoteTransport = .ssh
state.remoteTarget = "user@host:2222"
state.remoteUrl = "wss://gateway.example.ts.net"
state.remoteIdentity = "/tmp/id_ed25519"
state.remoteProjectRoot = "/tmp/clawdbot"
state.remoteCliPath = "/tmp/clawdbot"

View File

@@ -1,4 +1,5 @@
import AppKit
import Foundation
import Observation
import SwiftUI
@@ -517,11 +518,25 @@ extension MenuSessionsInjector {
switch mode {
case .remote:
platform = "remote"
let target = AppStateStore.shared.remoteTarget
if let parsed = CommandResolver.parseSSHTarget(target) {
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
if AppStateStore.shared.remoteTransport == .direct {
let trimmedUrl = AppStateStore.shared.remoteUrl
.trimmingCharacters(in: .whitespacesAndNewlines)
if let url = URL(string: trimmedUrl), let urlHost = url.host, !urlHost.isEmpty {
if let port = url.port {
host = "\(urlHost):\(port)"
} else {
host = urlHost
}
} else {
host = trimmedUrl.nonEmpty
}
} else {
host = target.nonEmpty
let target = AppStateStore.shared.remoteTarget
if let parsed = CommandResolver.parseSSHTarget(target) {
host = parsed.port == 22 ? parsed.host : "\(parsed.host):\(parsed.port)"
} else {
host = target.nonEmpty
}
}
case .local:
platform = "local"

View File

@@ -25,7 +25,11 @@ extension OnboardingView {
self.preferredGatewayID = gateway.stableID
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
if let host = gateway.tailnetDns ?? gateway.lanHost {
if self.state.remoteTransport == .direct {
if let url = GatewayDiscoveryHelpers.directUrl(for: gateway) {
self.state.remoteUrl = url
}
} else if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let user = NSUserName()
self.state.remoteTarget = GatewayDiscoveryModel.buildSSHTarget(
user: user,

View File

@@ -177,42 +177,67 @@ extension OnboardingView {
VStack(alignment: .leading, spacing: 10) {
Grid(alignment: .leading, horizontalSpacing: 12, verticalSpacing: 8) {
GridRow {
Text("SSH target")
Text("Transport")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
Picker("Transport", selection: self.$state.remoteTransport) {
Text("SSH tunnel").tag(AppState.RemoteTransport.ssh)
Text("Direct (ws/wss)").tag(AppState.RemoteTransport.direct)
}
.pickerStyle(.segmented)
.frame(width: fieldWidth)
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
if self.state.remoteTransport == .direct {
GridRow {
Text("Gateway URL")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("wss://gateway.example.ts.net", text: self.$state.remoteUrl)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField(
"/Applications/Clawdbot.app/.../clawdbot",
text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
if self.state.remoteTransport == .ssh {
GridRow {
Text("SSH target")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("user@host[:port]", text: self.$state.remoteTarget)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("Identity file")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/Users/you/.ssh/id_ed25519", text: self.$state.remoteIdentity)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("Project root")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField("/home/you/Projects/clawdbot", text: self.$state.remoteProjectRoot)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
GridRow {
Text("CLI path")
.font(.callout.weight(.semibold))
.frame(width: labelWidth, alignment: .leading)
TextField(
"/Applications/Clawdbot.app/.../clawdbot",
text: self.$state.remoteCliPath)
.textFieldStyle(.roundedBorder)
.frame(width: fieldWidth)
}
}
}
Text("Tip: keep Tailscale enabled so your gateway stays reachable.")
Text(self.state.remoteTransport == .direct
? "Tip: use Tailscale Serve so the gateway has a valid HTTPS cert."
: "Tip: keep Tailscale enabled so your gateway stays reachable.")
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -225,7 +250,10 @@ extension OnboardingView {
}
func gatewaySubtitle(for gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
if let host = gateway.tailnetDns ?? gateway.lanHost {
if self.state.remoteTransport == .direct {
return GatewayDiscoveryHelpers.directUrl(for: gateway) ?? "Gateway pairing only"
}
if let host = GatewayDiscoveryHelpers.sanitizedTailnetHost(gateway.tailnetDns) ?? gateway.lanHost {
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
return "\(host)\(portSuffix)"
}

View File

@@ -175,4 +175,10 @@ import Testing
customBindHost: "192.168.1.10")
#expect(host == "192.168.1.10")
}
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
#expect(url?.port == 18789)
#expect(url?.absoluteString == "ws://gateway:18789")
}
}

View File

@@ -75,10 +75,10 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
auth: "aws-sdk",
models: [
{
id: "anthropic.claude-3-7-sonnet-20250219-v1:0",
name: "Claude 3.7 Sonnet (Bedrock)",
id: "anthropic.claude-opus-4-5-20251101-v1:0",
name: "Claude Opus 4.5 (Bedrock)",
reasoning: true,
input: ["text"],
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200000,
maxTokens: 8192
@@ -89,12 +89,75 @@ export AWS_BEARER_TOKEN_BEDROCK="..."
},
agents: {
defaults: {
model: { primary: "amazon-bedrock/anthropic.claude-3-7-sonnet-20250219-v1:0" }
model: { primary: "amazon-bedrock/anthropic.claude-opus-4-5-20251101-v1:0" }
}
}
}
```
## EC2 Instance Roles
When running Clawdbot on an EC2 instance with an IAM role attached, the AWS SDK
will automatically use the instance metadata service (IMDS) for authentication.
However, Clawdbot's credential detection currently only checks for environment
variables, not IMDS credentials.
**Workaround:** Set `AWS_PROFILE=default` to signal that AWS credentials are
available. The actual authentication still uses the instance role via IMDS.
```bash
# Add to ~/.bashrc or your shell profile
export AWS_PROFILE=default
export AWS_REGION=us-east-1
```
**Required IAM permissions** for the EC2 instance role:
- `bedrock:InvokeModel`
- `bedrock:InvokeModelWithResponseStream`
- `bedrock:ListFoundationModels` (for automatic discovery)
Or attach the managed policy `AmazonBedrockFullAccess`.
**Quick setup:**
```bash
# 1. Create IAM role and instance profile
aws iam create-role --role-name EC2-Bedrock-Access \
--assume-role-policy-document '{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Service": "ec2.amazonaws.com"},
"Action": "sts:AssumeRole"
}]
}'
aws iam attach-role-policy --role-name EC2-Bedrock-Access \
--policy-arn arn:aws:iam::aws:policy/AmazonBedrockFullAccess
aws iam create-instance-profile --instance-profile-name EC2-Bedrock-Access
aws iam add-role-to-instance-profile \
--instance-profile-name EC2-Bedrock-Access \
--role-name EC2-Bedrock-Access
# 2. Attach to your EC2 instance
aws ec2 associate-iam-instance-profile \
--instance-id i-xxxxx \
--iam-instance-profile Name=EC2-Bedrock-Access
# 3. On the EC2 instance, enable discovery
clawdbot config set models.bedrockDiscovery.enabled true
clawdbot config set models.bedrockDiscovery.region us-east-1
# 4. Set the workaround env vars
echo 'export AWS_PROFILE=default' >> ~/.bashrc
echo 'export AWS_REGION=us-east-1' >> ~/.bashrc
source ~/.bashrc
# 5. Verify models are discovered
clawdbot models list
```
## Notes
- Bedrock requires **model access** enabled in your AWS account/region.

View File

@@ -2825,13 +2825,14 @@ Auth and Tailscale:
Remote client defaults (CLI):
- `gateway.remote.url` sets the default Gateway WebSocket URL for CLI calls when `gateway.mode = "remote"`.
- `gateway.remote.transport` selects the macOS remote transport (`ssh` default, `direct` for ws/wss). When `direct`, `gateway.remote.url` must be `ws://` or `wss://`. `ws://host` defaults to port `18789`.
- `gateway.remote.token` supplies the token for remote calls (leave unset for no auth).
- `gateway.remote.password` supplies the password for remote calls (leave unset for no auth).
macOS app behavior:
- Clawdbot.app watches `~/.clawdbot/clawdbot.json` and switches modes live when `gateway.mode` or `gateway.remote.url` changes.
- If `gateway.mode` is unset but `gateway.remote.url` is set, the macOS app treats it as remote mode.
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` in remote mode) back to the config file.
- When you change connection mode in the macOS app, it writes `gateway.mode` (and `gateway.remote.url` + `gateway.remote.transport` in remote mode) back to the config file.
```json5
{
@@ -2846,6 +2847,21 @@ macOS app behavior:
}
```
Direct transport example (macOS app):
```json5
{
gateway: {
mode: "remote",
remote: {
transport: "direct",
url: "wss://gateway.example.ts.net",
token: "your-token"
}
}
}
```
### `gateway.reload` (Config hot reload)
The Gateway watches `~/.clawdbot/clawdbot.json` (or `CLAWDBOT_CONFIG_PATH`) and applies changes automatically.

View File

@@ -19,6 +19,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Can I migrate my setup to a new machine (Mac mini) without redoing onboarding?](#can-i-migrate-my-setup-to-a-new-machine-mac-mini-without-redoing-onboarding)
- [Where do I see what’s new in the latest version?](#where-do-i-see-whats-new-in-the-latest-version)
- [I can't access docs.clawd.bot (SSL error). What now?](#i-cant-access-docsclawdbot-ssl-error-what-now)
- [What’s the difference between stable and beta?](#whats-the-difference-between-stable-and-beta)
- [How do I install the beta version, and what’s the difference between beta and dev?](#how-do-i-install-the-beta-version-and-whats-the-difference-between-beta-and-dev)
- [The docs didn’t answer my question — how do I get a better answer?](#the-docs-didnt-answer-my-question--how-do-i-get-a-better-answer)
- [How do I install Clawdbot on Linux?](#how-do-i-install-clawdbot-on-linux)
@@ -42,6 +43,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [Can multiple people use one WhatsApp number with different Clawdbots?](#can-multiple-people-use-one-whatsapp-number-with-different-clawdbots)
- [Can I run a "fast chat" agent and an "Opus for coding" agent?](#can-i-run-a-fast-chat-agent-and-an-opus-for-coding-agent)
- [Does Homebrew work on Linux?](#does-homebrew-work-on-linux)
- [What’s the difference between the hackable (git) install and npm install?](#whats-the-difference-between-the-hackable-git-install-and-npm-install)
- [Can I switch between npm and git installs later?](#can-i-switch-between-npm-and-git-installs-later)
- [Should I run the Gateway on my laptop or a VPS?](#should-i-run-the-gateway-on-my-laptop-or-a-vps)
- [Skills and automation](#skills-and-automation)
@@ -322,9 +324,22 @@ Please help us unblock it by reporting here: https://spa.xfinity.com/check_url_s
If you still can't reach the site, the docs are mirrored on GitHub:
https://github.com/clawdbot/clawdbot/tree/main/docs
### What’s the difference between stable and beta?
**Stable** and **beta** are **npm dist‑tags**, not separate code lines:
- `latest` = stable
- `beta` = early build for testing
We ship builds to **beta**, test them, and once a build is solid we **promote
that same version to `latest`**. That’s why beta and stable can point at the
**same version**.
See what changed:
https://github.com/clawdbot/clawdbot/blob/main/CHANGELOG.md
### How do I install the beta version, and what’s the difference between beta and dev?
**Beta** is a prerelease tag (`vYYYY.M.D-beta.N`) published to the npm dist‑tag `beta`.
**Beta** is the npm dist‑tag `beta` (may match `latest`).
**Dev** is the moving head of `main` (git); when published, it uses the npm dist‑tag `dev`.
One‑liners (macOS/Linux):
@@ -543,6 +558,15 @@ brew install <formula>
If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells.
Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set.
### What’s the difference between the hackable (git) install and npm install?
- **Hackable (git) install:** full source checkout, editable, best for contributors.
You run builds locally and can patch code/docs.
- **npm install:** global CLI install, no repo, best for “just run it.”
Updates come from npm dist‑tags.
Docs: [Getting started](/start/getting-started), [Updating](/install/updating).
### Can I switch between npm and git installs later?
Yes. Install the other flavor, then run Doctor so the gateway service points at the new entrypoint.

View File

@@ -11,10 +11,13 @@ Last updated: 2026-01-21
Clawdbot ships three update channels:
- **stable**: tagged releases (`vYYYY.M.D` or `vYYYY.M.D-<patch>`). npm dist-tag: `latest`.
- **beta**: prerelease tags (`vYYYY.M.D-beta.N`). npm dist-tag: `beta`.
- **stable**: npm dist-tag `latest`.
- **beta**: npm dist-tag `beta` (builds under test).
- **dev**: moving head of `main` (git). npm dist-tag: `dev` (when published).
We ship builds to **beta**, test them, then **promote a vetted build to `latest`**
without changing the version number — dist-tags are the source of truth for npm installs.
## Switching channels
Git checkout:
@@ -25,7 +28,7 @@ clawdbot update --channel beta
clawdbot update --channel dev
```
- `stable`/`beta` check out the latest matching tag.
- `stable`/`beta` check out the latest matching tag (often the same tag).
- `dev` switches to `main` and rebases on the upstream.
npm/pnpm global install:
@@ -56,12 +59,11 @@ When you switch channels with `clawdbot update`, Clawdbot also syncs plugin sour
## Tagging best practices
- Stable: tag each release (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
- Beta: use `vYYYY.M.D-beta.N` (increment `N`).
- Tag releases you want git checkouts to land on (`vYYYY.M.D` or `vYYYY.M.D-<patch>`).
- Keep tags immutable: never move or reuse a tag.
- Publish dist-tags alongside git tags:
- npm dist-tags remain the source of truth for npm installs:
- `latest` → stable
- `beta` → prerelease
- `beta` → candidate build
- `dev` → main snapshot (optional)
## macOS app availability

View File

@@ -6,9 +6,10 @@ read_when:
# Elevated Mode (/elevated directives)
## What it does
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full` (approvals still apply).
- `/elevated on` runs on the gateway host and keeps exec approvals (same as `/elevated ask`).
- `/elevated full` runs on the gateway host **and** auto-approves exec (skips exec approvals).
- `/elevated ask` runs on the gateway host but keeps exec approvals (same as `/elevated on`).
- `on`/`ask` do **not** force `exec.security=full`; configured security/ask policy still applies.
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
- Directive forms: `/elevated on|off|ask|full`, `/elev on|off|ask|full`.
- Only `on|off|ask|full` are accepted; anything else returns a hint and does not change state.
@@ -18,8 +19,8 @@ read_when:
- **Per-session state**: `/elevated on|off|ask|full` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on|ask|full` inside a message applies to that message only.
- **Groups**: In group chats, elevated directives are only honored when the agent is mentioned. Command-only messages that bypass mention requirements are treated as mentioned.
- **Host execution**: elevated forces `exec` onto the gateway host with full security.
- **Approvals**: `full` skips exec approvals; `on`/`ask` still honor them.
- **Host execution**: elevated forces `exec` onto the gateway host; `full` also sets `security=full`.
- **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.

View File

@@ -157,6 +157,36 @@ Actions:
- **Always allow** → add to allowlist + run
- **Deny** → block
## Approval forwarding to chat channels
You can forward exec approval prompts to any chat channel (including plugin channels) and approve
them with `/approve`. This uses the normal outbound delivery pipeline.
Config:
```json5
{
approvals: {
exec: {
enabled: true,
mode: "session", // "session" | "targets" | "both"
agentFilter: ["main"],
sessionFilter: ["discord"], // substring or regex
targets: [
{ channel: "slack", to: "U12345678" },
{ channel: "telegram", to: "123456789" }
]
}
}
}
```
Reply in chat:
```
/approve <id> allow-once
/approve <id> allow-always
/approve <id> deny
```
### macOS IPC flow
```
Gateway -> Node Service (WS)

View File

@@ -24,7 +24,7 @@ Background sessions are scoped per agent; `process` only sees sessions from the
- `security` (`deny | allowlist | full`): enforcement mode for `gateway`/`node`
- `ask` (`off | on-miss | always`): approval prompts for `gateway`/`node`
- `node` (string): node id/name for `host=node`
- `elevated` (bool): alias for `host=gateway` + `security=full` when sandboxed and allowed
- `elevated` (bool): request elevated mode (gateway host); `security=full` is only forced when elevated resolves to `full`
Notes:
- `host` defaults to `sandbox`.

View File

@@ -61,6 +61,7 @@ Text + native (when enabled):
- `/skill <name> [input]` (run a skill by name)
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/allowlist` (list/add/remove allowlist entries)
- `/approve <id> allow-once|allow-always|deny` (resolve exec approval prompts)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/whoami` (show your sender id; alias: `/id`)
- `/subagents list|stop|log|info|send` (inspect, stop, log, or message sub-agent runs for the current session)

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { TlonAuthorizationSchema, TlonConfigSchema } from "./config-schema.js";
describe("Tlon config schema", () => {
it("accepts channelRules with string keys", () => {
const parsed = TlonAuthorizationSchema.parse({
channelRules: {
"chat/~zod/test": {
mode: "open",
allowedShips: ["~zod"],
},
},
});
expect(parsed.channelRules?.["chat/~zod/test"]?.mode).toBe("open");
});
it("accepts accounts with string keys", () => {
const parsed = TlonConfigSchema.parse({
accounts: {
primary: {
ship: "~zod",
url: "https://example.com",
code: "code-123",
},
},
});
expect(parsed.accounts?.primary?.ship).toBe("~zod");
});
});

View File

@@ -10,7 +10,7 @@ export const TlonChannelRuleSchema = z.object({
});
export const TlonAuthorizationSchema = z.object({
channelRules: z.record(TlonChannelRuleSchema).optional(),
channelRules: z.record(z.string(), TlonChannelRuleSchema).optional(),
});
export const TlonAccountSchema = z.object({
@@ -37,7 +37,7 @@ export const TlonConfigSchema = z.object({
showModelSignature: z.boolean().optional(),
authorization: TlonAuthorizationSchema.optional(),
defaultAuthorizedShips: z.array(ShipSchema).optional(),
accounts: z.record(TlonAccountSchema).optional(),
accounts: z.record(z.string(), TlonAccountSchema).optional(),
});
export const tlonChannelConfigSchema = buildChannelConfigSchema(TlonConfigSchema);

View File

@@ -0,0 +1,38 @@
import { afterEach, describe, expect, it, vi } from "vitest";
vi.mock("@urbit/aura", () => ({
scot: vi.fn(() => "mocked-ud"),
da: {
fromUnix: vi.fn(() => 123n),
},
}));
describe("sendDm", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("uses aura v3 helpers for the DM id", async () => {
const { sendDm } = await import("./send.js");
const aura = await import("@urbit/aura");
const scot = vi.mocked(aura.scot);
const fromUnix = vi.mocked(aura.da.fromUnix);
const sentAt = 1_700_000_000_000;
vi.spyOn(Date, "now").mockReturnValue(sentAt);
const poke = vi.fn(async () => ({}));
const result = await sendDm({
api: { poke },
fromShip: "~zod",
toShip: "~nec",
text: "hi",
});
expect(fromUnix).toHaveBeenCalledWith(sentAt);
expect(scot).toHaveBeenCalledWith("ud", 123n);
expect(poke).toHaveBeenCalledTimes(1);
expect(result.messageId).toBe("~zod/mocked-ud");
});
});

View File

@@ -1,4 +1,4 @@
import { unixToDa, formatUd } from "@urbit/aura";
import { scot, da } from "@urbit/aura";
export type TlonPokeApi = {
poke: (params: { app: string; mark: string; json: unknown }) => Promise<unknown>;
@@ -14,7 +14,7 @@ type SendTextParams = {
export async function sendDm({ api, fromShip, toShip, text }: SendTextParams) {
const story = [{ inline: [text] }];
const sentAt = Date.now();
const idUd = formatUd(unixToDa(sentAt));
const idUd = scot('ud', da.fromUnix(sentAt));
const id = `${fromShip}/${idUd}`;
const delta = {

View File

@@ -150,4 +150,35 @@ describe("exec approvals", () => {
expect(result.details.status).toBe("completed");
expect(calls).not.toContain("exec.approval.request");
});
it("requires approval for elevated ask when allowlist misses", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const calls: string[] = [];
let resolveApproval: (() => void) | undefined;
const approvalSeen = new Promise<void>((resolve) => {
resolveApproval = resolve;
});
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
if (method === "exec.approval.request") {
resolveApproval?.();
return { decision: "deny" };
}
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
ask: "on-miss",
security: "allowlist",
approvalRunningNoticeMs: 0,
elevated: { enabled: true, allowed: true, defaultLevel: "ask" },
});
const result = await tool.execute("call4", { command: "echo ok", elevated: true });
expect(result.details.status).toBe("approval-pending");
await approvalSeen;
expect(calls).toContain("exec.approval.request");
});
});

View File

@@ -791,7 +791,7 @@ export function createExecTool(
const configuredSecurity = defaults?.security ?? (host === "sandbox" ? "deny" : "allowlist");
const requestedSecurity = normalizeExecSecurity(params.security);
let security = minSecurity(configuredSecurity, requestedSecurity ?? configuredSecurity);
if (elevatedRequested) {
if (elevatedRequested && elevatedMode === "full") {
security = "full";
}
const configuredAsk = defaults?.ask ?? "on-miss";

View File

@@ -164,6 +164,13 @@ function buildChatCommands(): ChatCommandDefinition[] {
acceptsArgs: true,
scope: "text",
}),
defineChatCommand({
key: "approve",
nativeName: "approve",
description: "Approve or deny exec requests.",
textAlias: "/approve",
acceptsArgs: true,
}),
defineChatCommand({
key: "context",
nativeName: "context",

View File

@@ -0,0 +1,83 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
import { callGateway } from "../../gateway/call.js";
vi.mock("../../gateway/call.js", () => ({
callGateway: vi.fn(),
}));
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "whatsapp",
Surface: "whatsapp",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "whatsapp",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("/approve command", () => {
it("rejects invalid usage", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/approve", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Usage: /approve");
});
it("submits approval", async () => {
const cfg = {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as ClawdbotConfig;
const params = buildParams("/approve abc allow-once", cfg, { SenderId: "123" });
const mockCallGateway = vi.mocked(callGateway);
mockCallGateway.mockResolvedValueOnce({ ok: true });
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Exec approval allow-once submitted");
expect(mockCallGateway).toHaveBeenCalledWith(
expect.objectContaining({
method: "exec.approval.resolve",
params: { id: "abc", decision: "allow-once" },
}),
);
});
});

View File

@@ -0,0 +1,101 @@
import { callGateway } from "../../gateway/call.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import { logVerbose } from "../../globals.js";
import type { CommandHandler } from "./commands-types.js";
const COMMAND = "/approve";
const DECISION_ALIASES: Record<string, "allow-once" | "allow-always" | "deny"> = {
allow: "allow-once",
once: "allow-once",
"allow-once": "allow-once",
allowonce: "allow-once",
always: "allow-always",
"allow-always": "allow-always",
allowalways: "allow-always",
deny: "deny",
reject: "deny",
block: "deny",
};
type ParsedApproveCommand =
| { ok: true; id: string; decision: "allow-once" | "allow-always" | "deny" }
| { ok: false; error: string };
function parseApproveCommand(raw: string): ParsedApproveCommand | null {
const trimmed = raw.trim();
if (!trimmed.toLowerCase().startsWith(COMMAND)) return null;
const rest = trimmed.slice(COMMAND.length).trim();
if (!rest) {
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
}
const tokens = rest.split(/\s+/).filter(Boolean);
if (tokens.length < 2) {
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
}
const first = tokens[0].toLowerCase();
const second = tokens[1].toLowerCase();
if (DECISION_ALIASES[first]) {
return {
ok: true,
decision: DECISION_ALIASES[first],
id: tokens.slice(1).join(" ").trim(),
};
}
if (DECISION_ALIASES[second]) {
return {
ok: true,
decision: DECISION_ALIASES[second],
id: tokens[0],
};
}
return { ok: false, error: "Usage: /approve <id> allow-once|allow-always|deny" };
}
function buildResolvedByLabel(params: Parameters<CommandHandler>[0]): string {
const channel = params.command.channel;
const sender = params.command.senderId ?? "unknown";
return `${channel}:${sender}`;
}
export const handleApproveCommand: CommandHandler = async (params, allowTextCommands) => {
if (!allowTextCommands) return null;
const normalized = params.command.commandBodyNormalized;
const parsed = parseApproveCommand(normalized);
if (!parsed) return null;
if (!params.command.isAuthorizedSender) {
logVerbose(
`Ignoring /approve from unauthorized sender: ${params.command.senderId || "<unknown>"}`,
);
return { shouldContinue: false };
}
if (!parsed.ok) {
return { shouldContinue: false, reply: { text: parsed.error } };
}
const resolvedBy = buildResolvedByLabel(params);
try {
await callGateway({
method: "exec.approval.resolve",
params: { id: parsed.id, decision: parsed.decision },
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: `Chat approval (${resolvedBy})`,
mode: GATEWAY_CLIENT_MODES.BACKEND,
});
} catch (err) {
return {
shouldContinue: false,
reply: {
text: `❌ Failed to submit approval: ${String(err)}`,
},
};
}
return {
shouldContinue: false,
reply: { text: `✅ Exec approval ${parsed.decision} submitted for ${parsed.id}.` },
};
};

View File

@@ -14,6 +14,7 @@ import {
handleWhoamiCommand,
} from "./commands-info.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
import { handleApproveCommand } from "./commands-approve.js";
import { handleSubagentsCommand } from "./commands-subagents.js";
import { handleModelsCommand } from "./commands-models.js";
import { handleTtsCommands } from "./commands-tts.js";
@@ -45,6 +46,7 @@ const HANDLERS: CommandHandler[] = [
handleCommandsListCommand,
handleStatusCommand,
handleAllowlistCommand,
handleApproveCommand,
handleContextCommand,
handleWhoamiCommand,
handleSubagentsCommand,

View File

@@ -0,0 +1,33 @@
import { describe, expect, it, vi } from "vitest";
describe("gateway.remote.transport", () => {
it("accepts direct transport", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
gateway: {
remote: {
transport: "direct",
url: "wss://gateway.example.ts.net",
},
},
});
expect(res.ok).toBe(true);
});
it("rejects unknown transport", async () => {
vi.resetModules();
const { validateConfigObject } = await import("./config.js");
const res = validateConfigObject({
gateway: {
remote: {
transport: "udp",
},
},
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.issues[0]?.path).toBe("gateway.remote.transport");
}
});
});

View File

@@ -0,0 +1,29 @@
export type ExecApprovalForwardingMode = "session" | "targets" | "both";
export type ExecApprovalForwardTarget = {
/** Channel id (e.g. "discord", "slack", or plugin channel id). */
channel: string;
/** Destination id (channel id, user id, etc. depending on channel). */
to: string;
/** Optional account id for multi-account channels. */
accountId?: string;
/** Optional thread id to reply inside a thread. */
threadId?: string | number;
};
export type ExecApprovalForwardingConfig = {
/** Enable forwarding exec approvals to chat channels. Default: false. */
enabled?: boolean;
/** Delivery mode (session=origin chat, targets=config targets, both=both). Default: session. */
mode?: ExecApprovalForwardingMode;
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];
/** Only forward approvals matching these session key patterns (substring or regex). */
sessionFilter?: string[];
/** Explicit delivery targets (used when mode includes targets). */
targets?: ExecApprovalForwardTarget[];
};
export type ApprovalsConfig = {
exec?: ExecApprovalForwardingConfig;
};

View File

@@ -1,4 +1,5 @@
import type { AgentBinding, AgentsConfig } from "./types.agents.js";
import type { ApprovalsConfig } from "./types.approvals.js";
import type { AuthConfig } from "./types.auth.js";
import type { DiagnosticsConfig, LoggingConfig, SessionConfig, WebConfig } from "./types.base.js";
import type { BrowserConfig } from "./types.browser.js";
@@ -84,6 +85,7 @@ export type ClawdbotConfig = {
audio?: AudioConfig;
messages?: MessagesConfig;
commands?: CommandsConfig;
approvals?: ApprovalsConfig;
session?: SessionConfig;
web?: WebConfig;
channels?: ChannelsConfig;

View File

@@ -72,6 +72,17 @@ export type DiscordActionConfig = {
channels?: boolean;
};
export type DiscordExecApprovalConfig = {
/** Enable exec approval forwarding to Discord DMs. Default: false. */
enabled?: boolean;
/** Discord user IDs to receive approval prompts. Required if enabled. */
approvers?: Array<string | number>;
/** Only forward approvals for these agent IDs. Omit = all agents. */
agentFilter?: string[];
/** Only forward approvals matching these session key patterns (substring or regex). */
sessionFilter?: string[];
};
export type DiscordAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
@@ -124,6 +135,8 @@ export type DiscordAccountConfig = {
guilds?: Record<string, DiscordGuildEntry>;
/** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Exec approval forwarding configuration. */
execApprovals?: DiscordExecApprovalConfig;
};
export type DiscordConfig = {

View File

@@ -80,6 +80,8 @@ export type GatewayTailscaleConfig = {
export type GatewayRemoteConfig = {
/** Remote Gateway WebSocket URL (ws:// or wss://). */
url?: string;
/** Transport for macOS remote connections (ssh tunnel or direct WS). */
transport?: "ssh" | "direct";
/** Token for remote auth (when the gateway requires token auth). */
token?: string;
/** Password for remote auth (when the gateway requires password auth). */

View File

@@ -2,6 +2,7 @@
export * from "./types.agent-defaults.js";
export * from "./types.agents.js";
export * from "./types.approvals.js";
export * from "./types.auth.js";
export * from "./types.base.js";
export * from "./types.browser.js";

View File

@@ -0,0 +1,28 @@
import { z } from "zod";
const ExecApprovalForwardTargetSchema = z
.object({
channel: z.string().min(1),
to: z.string().min(1),
accountId: z.string().optional(),
threadId: z.union([z.string(), z.number()]).optional(),
})
.strict();
const ExecApprovalForwardingSchema = z
.object({
enabled: z.boolean().optional(),
mode: z.union([z.literal("session"), z.literal("targets"), z.literal("both")]).optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
targets: z.array(ExecApprovalForwardTargetSchema).optional(),
})
.strict()
.optional();
export const ApprovalsSchema = z
.object({
exec: ExecApprovalForwardingSchema,
})
.strict()
.optional();

View File

@@ -244,6 +244,15 @@ export const DiscordAccountSchema = z
dm: DiscordDmSchema.optional(),
guilds: z.record(z.string(), DiscordGuildSchema.optional()).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema,
execApprovals: z
.object({
enabled: z.boolean().optional(),
approvers: z.array(z.union([z.string(), z.number()])).optional(),
agentFilter: z.array(z.string()).optional(),
sessionFilter: z.array(z.string()).optional(),
})
.strict()
.optional(),
})
.strict();

View File

@@ -1,5 +1,6 @@
import { z } from "zod";
import { ToolsSchema } from "./zod-schema.agent-runtime.js";
import { ApprovalsSchema } from "./zod-schema.approvals.js";
import { AgentsSchema, AudioSchema, BindingsSchema, BroadcastSchema } from "./zod-schema.agents.js";
import { HexColorSchema, ModelsConfigSchema } from "./zod-schema.core.js";
import { HookMappingSchema, HooksGmailSchema, InternalHooksSchema } from "./zod-schema.hooks.js";
@@ -220,6 +221,7 @@ export const ClawdbotSchema = z
.optional(),
messages: MessagesSchema,
commands: CommandsSchema,
approvals: ApprovalsSchema,
session: SessionSchema,
cron: z
.object({
@@ -332,6 +334,7 @@ export const ClawdbotSchema = z
remote: z
.object({
url: z.string().optional(),
transport: z.union([z.literal("ssh"), z.literal("direct")]).optional(),
token: z.string().optional(),
password: z.string().optional(),
tlsFingerprint: z.string().optional(),

View File

@@ -0,0 +1,199 @@
import { describe, expect, it } from "vitest";
import {
buildExecApprovalCustomId,
parseExecApprovalData,
type ExecApprovalRequest,
DiscordExecApprovalHandler,
} from "./exec-approvals.js";
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
describe("buildExecApprovalCustomId", () => {
it("encodes approval id and action", () => {
const customId = buildExecApprovalCustomId("abc-123", "allow-once");
expect(customId).toBe("execapproval:id=abc-123;action=allow-once");
});
it("encodes special characters in approval id", () => {
const customId = buildExecApprovalCustomId("abc=123;test", "deny");
expect(customId).toBe("execapproval:id=abc%3D123%3Btest;action=deny");
});
});
describe("parseExecApprovalData", () => {
it("parses valid data", () => {
const result = parseExecApprovalData({ id: "abc-123", action: "allow-once" });
expect(result).toEqual({ approvalId: "abc-123", action: "allow-once" });
});
it("parses encoded data", () => {
const result = parseExecApprovalData({
id: "abc%3D123%3Btest",
action: "allow-always",
});
expect(result).toEqual({ approvalId: "abc=123;test", action: "allow-always" });
});
it("rejects invalid action", () => {
const result = parseExecApprovalData({ id: "abc-123", action: "invalid" });
expect(result).toBeNull();
});
it("rejects missing id", () => {
const result = parseExecApprovalData({ action: "deny" });
expect(result).toBeNull();
});
it("rejects missing action", () => {
const result = parseExecApprovalData({ id: "abc-123" });
expect(result).toBeNull();
});
it("rejects null/undefined input", () => {
expect(parseExecApprovalData(null as any)).toBeNull();
expect(parseExecApprovalData(undefined as any)).toBeNull();
});
it("accepts all valid actions", () => {
expect(parseExecApprovalData({ id: "x", action: "allow-once" })?.action).toBe("allow-once");
expect(parseExecApprovalData({ id: "x", action: "allow-always" })?.action).toBe("allow-always");
expect(parseExecApprovalData({ id: "x", action: "deny" })?.action).toBe("deny");
});
});
describe("roundtrip encoding", () => {
it("encodes and decodes correctly", () => {
const approvalId = "test-approval-with=special;chars&more";
const action = "allow-always" as const;
const customId = buildExecApprovalCustomId(approvalId, action);
// Parse the key=value pairs from the custom ID
const parts = customId.split(";");
const data: Record<string, string> = {};
for (const part of parts) {
const match = part.match(/^([^:]+:)?([^=]+)=(.+)$/);
if (match) {
data[match[2]] = match[3];
}
}
const result = parseExecApprovalData(data);
expect(result).toEqual({ approvalId, action });
});
});
describe("DiscordExecApprovalHandler.shouldHandle", () => {
function createHandler(config: DiscordExecApprovalConfig) {
return new DiscordExecApprovalHandler({
token: "test-token",
accountId: "default",
config,
cfg: {},
});
}
function createRequest(
overrides: Partial<ExecApprovalRequest["request"]> = {},
): ExecApprovalRequest {
return {
id: "test-id",
request: {
command: "echo hello",
cwd: "/home/user",
host: "gateway",
agentId: "test-agent",
sessionKey: "agent:test-agent:discord:123",
...overrides,
},
createdAtMs: Date.now(),
expiresAtMs: Date.now() + 60000,
};
}
it("returns false when disabled", () => {
const handler = createHandler({ enabled: false, approvers: ["123"] });
expect(handler.shouldHandle(createRequest())).toBe(false);
});
it("returns false when no approvers", () => {
const handler = createHandler({ enabled: true, approvers: [] });
expect(handler.shouldHandle(createRequest())).toBe(false);
});
it("returns true with minimal config", () => {
const handler = createHandler({ enabled: true, approvers: ["123"] });
expect(handler.shouldHandle(createRequest())).toBe(true);
});
it("filters by agent ID", () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
agentFilter: ["allowed-agent"],
});
expect(handler.shouldHandle(createRequest({ agentId: "allowed-agent" }))).toBe(true);
expect(handler.shouldHandle(createRequest({ agentId: "other-agent" }))).toBe(false);
expect(handler.shouldHandle(createRequest({ agentId: null }))).toBe(false);
});
it("filters by session key substring", () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
sessionFilter: ["discord"],
});
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe(
true,
);
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:telegram:123" }))).toBe(
false,
);
expect(handler.shouldHandle(createRequest({ sessionKey: null }))).toBe(false);
});
it("filters by session key regex", () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
sessionFilter: ["^agent:.*:discord:"],
});
expect(handler.shouldHandle(createRequest({ sessionKey: "agent:test:discord:123" }))).toBe(
true,
);
expect(handler.shouldHandle(createRequest({ sessionKey: "other:test:discord:123" }))).toBe(
false,
);
});
it("combines agent and session filters", () => {
const handler = createHandler({
enabled: true,
approvers: ["123"],
agentFilter: ["my-agent"],
sessionFilter: ["discord"],
});
expect(
handler.shouldHandle(
createRequest({
agentId: "my-agent",
sessionKey: "agent:my-agent:discord:123",
}),
),
).toBe(true);
expect(
handler.shouldHandle(
createRequest({
agentId: "other-agent",
sessionKey: "agent:other:discord:123",
}),
),
).toBe(false);
expect(
handler.shouldHandle(
createRequest({
agentId: "my-agent",
sessionKey: "agent:my-agent:telegram:123",
}),
),
).toBe(false);
});
});

View File

@@ -0,0 +1,549 @@
import { Button, type ButtonInteraction, type ComponentData } from "@buape/carbon";
import { ButtonStyle, Routes } from "discord-api-types/v10";
import type { ClawdbotConfig } from "../../config/config.js";
import { GatewayClient } from "../../gateway/client.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js";
import type { EventFrame } from "../../gateway/protocol/index.js";
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
import { createDiscordClient } from "../send.shared.js";
import { logDebug, logError } from "../../logger.js";
import type { DiscordExecApprovalConfig } from "../../config/types.discord.js";
import type { RuntimeEnv } from "../../runtime.js";
const EXEC_APPROVAL_KEY = "execapproval";
export type ExecApprovalRequest = {
id: string;
request: {
command: string;
cwd?: string | null;
host?: string | null;
security?: string | null;
ask?: string | null;
agentId?: string | null;
resolvedPath?: string | null;
sessionKey?: string | null;
};
createdAtMs: number;
expiresAtMs: number;
};
export type ExecApprovalResolved = {
id: string;
decision: ExecApprovalDecision;
resolvedBy?: string | null;
ts: number;
};
type PendingApproval = {
discordMessageId: string;
discordChannelId: string;
timeoutId: NodeJS.Timeout;
};
function encodeCustomIdValue(value: string): string {
return encodeURIComponent(value);
}
function decodeCustomIdValue(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
export function buildExecApprovalCustomId(
approvalId: string,
action: ExecApprovalDecision,
): string {
return [`${EXEC_APPROVAL_KEY}:id=${encodeCustomIdValue(approvalId)}`, `action=${action}`].join(
";",
);
}
export function parseExecApprovalData(
data: ComponentData,
): { approvalId: string; action: ExecApprovalDecision } | null {
if (!data || typeof data !== "object") return null;
const coerce = (value: unknown) =>
typeof value === "string" || typeof value === "number" ? String(value) : "";
const rawId = coerce(data.id);
const rawAction = coerce(data.action);
if (!rawId || !rawAction) return null;
const action = rawAction as ExecApprovalDecision;
if (action !== "allow-once" && action !== "allow-always" && action !== "deny") {
return null;
}
return {
approvalId: decodeCustomIdValue(rawId),
action,
};
}
function formatExecApprovalEmbed(request: ExecApprovalRequest) {
const commandText = request.request.command;
const commandPreview =
commandText.length > 1000 ? `${commandText.slice(0, 1000)}...` : commandText;
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - Date.now()) / 1000));
const fields: Array<{ name: string; value: string; inline: boolean }> = [
{
name: "Command",
value: `\`\`\`\n${commandPreview}\n\`\`\``,
inline: false,
},
];
if (request.request.cwd) {
fields.push({
name: "Working Directory",
value: request.request.cwd,
inline: true,
});
}
if (request.request.host) {
fields.push({
name: "Host",
value: request.request.host,
inline: true,
});
}
if (request.request.agentId) {
fields.push({
name: "Agent",
value: request.request.agentId,
inline: true,
});
}
return {
title: "Exec Approval Required",
description: "A command needs your approval.",
color: 0xffa500, // Orange
fields,
footer: { text: `Expires in ${expiresIn}s | ID: ${request.id}` },
timestamp: new Date().toISOString(),
};
}
function formatResolvedEmbed(
request: ExecApprovalRequest,
decision: ExecApprovalDecision,
resolvedBy?: string | null,
) {
const commandText = request.request.command;
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
const decisionLabel =
decision === "allow-once"
? "Allowed (once)"
: decision === "allow-always"
? "Allowed (always)"
: "Denied";
const color = decision === "deny" ? 0xed4245 : decision === "allow-always" ? 0x5865f2 : 0x57f287;
return {
title: `Exec Approval: ${decisionLabel}`,
description: resolvedBy ? `Resolved by ${resolvedBy}` : "Resolved",
color,
fields: [
{
name: "Command",
value: `\`\`\`\n${commandPreview}\n\`\`\``,
inline: false,
},
],
footer: { text: `ID: ${request.id}` },
timestamp: new Date().toISOString(),
};
}
function formatExpiredEmbed(request: ExecApprovalRequest) {
const commandText = request.request.command;
const commandPreview = commandText.length > 500 ? `${commandText.slice(0, 500)}...` : commandText;
return {
title: "Exec Approval: Expired",
description: "This approval request has expired.",
color: 0x99aab5, // Gray
fields: [
{
name: "Command",
value: `\`\`\`\n${commandPreview}\n\`\`\``,
inline: false,
},
],
footer: { text: `ID: ${request.id}` },
timestamp: new Date().toISOString(),
};
}
export type DiscordExecApprovalHandlerOpts = {
token: string;
accountId: string;
config: DiscordExecApprovalConfig;
gatewayUrl?: string;
cfg: ClawdbotConfig;
runtime?: RuntimeEnv;
onResolve?: (id: string, decision: ExecApprovalDecision) => Promise<void>;
};
export class DiscordExecApprovalHandler {
private gatewayClient: GatewayClient | null = null;
private pending = new Map<string, PendingApproval>();
private requestCache = new Map<string, ExecApprovalRequest>();
private opts: DiscordExecApprovalHandlerOpts;
private started = false;
constructor(opts: DiscordExecApprovalHandlerOpts) {
this.opts = opts;
}
shouldHandle(request: ExecApprovalRequest): boolean {
const config = this.opts.config;
if (!config.enabled) return false;
if (!config.approvers || config.approvers.length === 0) return false;
// Check agent filter
if (config.agentFilter?.length) {
if (!request.request.agentId) return false;
if (!config.agentFilter.includes(request.request.agentId)) return false;
}
// Check session filter (substring match)
if (config.sessionFilter?.length) {
const session = request.request.sessionKey;
if (!session) return false;
const matches = config.sessionFilter.some((p) => {
try {
return session.includes(p) || new RegExp(p).test(session);
} catch {
return session.includes(p);
}
});
if (!matches) return false;
}
return true;
}
async start(): Promise<void> {
if (this.started) return;
this.started = true;
const config = this.opts.config;
if (!config.enabled) {
logDebug("discord exec approvals: disabled");
return;
}
if (!config.approvers || config.approvers.length === 0) {
logDebug("discord exec approvals: no approvers configured");
return;
}
logDebug("discord exec approvals: starting handler");
this.gatewayClient = new GatewayClient({
url: this.opts.gatewayUrl ?? "ws://127.0.0.1:18789",
clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT,
clientDisplayName: "Discord Exec Approvals",
mode: GATEWAY_CLIENT_MODES.BACKEND,
scopes: ["operator.approvals"],
onEvent: (evt) => this.handleGatewayEvent(evt),
onHelloOk: () => {
logDebug("discord exec approvals: connected to gateway");
},
onConnectError: (err) => {
logError(`discord exec approvals: connect error: ${err.message}`);
},
onClose: (code, reason) => {
logDebug(`discord exec approvals: gateway closed: ${code} ${reason}`);
},
});
this.gatewayClient.start();
}
async stop(): Promise<void> {
if (!this.started) return;
this.started = false;
// Clear all pending timeouts
for (const pending of this.pending.values()) {
clearTimeout(pending.timeoutId);
}
this.pending.clear();
this.requestCache.clear();
this.gatewayClient?.stop();
this.gatewayClient = null;
logDebug("discord exec approvals: stopped");
}
private handleGatewayEvent(evt: EventFrame): void {
if (evt.event === "exec.approval.requested") {
const request = evt.payload as ExecApprovalRequest;
void this.handleApprovalRequested(request);
} else if (evt.event === "exec.approval.resolved") {
const resolved = evt.payload as ExecApprovalResolved;
void this.handleApprovalResolved(resolved);
}
}
private async handleApprovalRequested(request: ExecApprovalRequest): Promise<void> {
if (!this.shouldHandle(request)) return;
logDebug(`discord exec approvals: received request ${request.id}`);
this.requestCache.set(request.id, request);
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
const embed = formatExecApprovalEmbed(request);
// Build action rows with buttons
const components = [
{
type: 1, // ACTION_ROW
components: [
{
type: 2, // BUTTON
style: ButtonStyle.Success,
label: "Allow once",
custom_id: buildExecApprovalCustomId(request.id, "allow-once"),
},
{
type: 2, // BUTTON
style: ButtonStyle.Primary,
label: "Always allow",
custom_id: buildExecApprovalCustomId(request.id, "allow-always"),
},
{
type: 2, // BUTTON
style: ButtonStyle.Danger,
label: "Deny",
custom_id: buildExecApprovalCustomId(request.id, "deny"),
},
],
},
];
const approvers = this.opts.config.approvers ?? [];
for (const approver of approvers) {
const userId = String(approver);
try {
// Create DM channel
const dmChannel = (await discordRequest(
() =>
rest.post(Routes.userChannels(), {
body: { recipient_id: userId },
}) as Promise<{ id: string }>,
"dm-channel",
)) as { id: string };
if (!dmChannel?.id) {
logError(`discord exec approvals: failed to create DM for user ${userId}`);
continue;
}
// Send message with embed and buttons
const message = (await discordRequest(
() =>
rest.post(Routes.channelMessages(dmChannel.id), {
body: {
embeds: [embed],
components,
},
}) as Promise<{ id: string; channel_id: string }>,
"send-approval",
)) as { id: string; channel_id: string };
if (!message?.id) {
logError(`discord exec approvals: failed to send message to user ${userId}`);
continue;
}
// Set up timeout
const timeoutMs = Math.max(0, request.expiresAtMs - Date.now());
const timeoutId = setTimeout(() => {
void this.handleApprovalTimeout(request.id);
}, timeoutMs);
this.pending.set(request.id, {
discordMessageId: message.id,
discordChannelId: dmChannel.id,
timeoutId,
});
logDebug(`discord exec approvals: sent approval ${request.id} to user ${userId}`);
} catch (err) {
logError(`discord exec approvals: failed to notify user ${userId}: ${String(err)}`);
}
}
}
private async handleApprovalResolved(resolved: ExecApprovalResolved): Promise<void> {
const pending = this.pending.get(resolved.id);
if (!pending) return;
clearTimeout(pending.timeoutId);
this.pending.delete(resolved.id);
const request = this.requestCache.get(resolved.id);
this.requestCache.delete(resolved.id);
if (!request) return;
logDebug(`discord exec approvals: resolved ${resolved.id} with ${resolved.decision}`);
await this.updateMessage(
pending.discordChannelId,
pending.discordMessageId,
formatResolvedEmbed(request, resolved.decision, resolved.resolvedBy),
);
}
private async handleApprovalTimeout(approvalId: string): Promise<void> {
const pending = this.pending.get(approvalId);
if (!pending) return;
this.pending.delete(approvalId);
const request = this.requestCache.get(approvalId);
this.requestCache.delete(approvalId);
if (!request) return;
logDebug(`discord exec approvals: timeout for ${approvalId}`);
await this.updateMessage(
pending.discordChannelId,
pending.discordMessageId,
formatExpiredEmbed(request),
);
}
private async updateMessage(
channelId: string,
messageId: string,
embed: ReturnType<typeof formatExpiredEmbed>,
): Promise<void> {
try {
const { rest, request: discordRequest } = createDiscordClient(
{ token: this.opts.token, accountId: this.opts.accountId },
this.opts.cfg,
);
await discordRequest(
() =>
rest.patch(Routes.channelMessage(channelId, messageId), {
body: {
embeds: [embed],
components: [], // Remove buttons
},
}),
"update-approval",
);
} catch (err) {
logError(`discord exec approvals: failed to update message: ${String(err)}`);
}
}
async resolveApproval(approvalId: string, decision: ExecApprovalDecision): Promise<boolean> {
if (!this.gatewayClient) {
logError("discord exec approvals: gateway client not connected");
return false;
}
logDebug(`discord exec approvals: resolving ${approvalId} with ${decision}`);
try {
await this.gatewayClient.request("exec.approval.resolve", {
id: approvalId,
decision,
});
logDebug(`discord exec approvals: resolved ${approvalId} successfully`);
return true;
} catch (err) {
logError(`discord exec approvals: resolve failed: ${String(err)}`);
return false;
}
}
}
export type ExecApprovalButtonContext = {
handler: DiscordExecApprovalHandler;
};
export class ExecApprovalButton extends Button {
label = "execapproval";
customId = `${EXEC_APPROVAL_KEY}:seed=1`;
style = ButtonStyle.Primary;
private ctx: ExecApprovalButtonContext;
constructor(ctx: ExecApprovalButtonContext) {
super();
this.ctx = ctx;
}
async run(interaction: ButtonInteraction, data: ComponentData): Promise<void> {
const parsed = parseExecApprovalData(data);
if (!parsed) {
try {
await interaction.update({
content: "This approval is no longer valid.",
components: [],
});
} catch {
// Interaction may have expired
}
return;
}
const decisionLabel =
parsed.action === "allow-once"
? "Allowed (once)"
: parsed.action === "allow-always"
? "Allowed (always)"
: "Denied";
// Update the message immediately to show the decision
try {
await interaction.update({
content: `Submitting decision: **${decisionLabel}**...`,
components: [], // Remove buttons
});
} catch {
// Interaction may have expired, try to continue anyway
}
const ok = await this.ctx.handler.resolveApproval(parsed.approvalId, parsed.action);
if (!ok) {
try {
await interaction.followUp({
content:
"Failed to submit approval decision. The request may have expired or already been resolved.",
ephemeral: true,
});
} catch {
// Interaction may have expired
}
}
// On success, the handleApprovalResolved event will update the message with the final result
}
}
export function createExecApprovalButton(ctx: ExecApprovalButtonContext): Button {
return new ExecApprovalButton(ctx);
}

View File

@@ -37,6 +37,7 @@ import {
createDiscordCommandArgFallbackButton,
createDiscordNativeCommand,
} from "./native-command.js";
import { createExecApprovalButton, DiscordExecApprovalHandler } from "./exec-approvals.js";
export type MonitorDiscordOpts = {
token?: string;
@@ -406,6 +407,31 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
}),
);
// Initialize exec approvals handler if enabled
const execApprovalsConfig = discordCfg.execApprovals ?? {};
const execApprovalsHandler = execApprovalsConfig.enabled
? new DiscordExecApprovalHandler({
token,
accountId: account.accountId,
config: execApprovalsConfig,
cfg,
runtime,
})
: null;
const components = [
createDiscordCommandArgFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
];
if (execApprovalsHandler) {
components.push(createExecApprovalButton({ handler: execApprovalsHandler }));
}
const client = new Client(
{
baseUrl: "http://localhost",
@@ -418,14 +444,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
{
commands,
listeners: [],
components: [
createDiscordCommandArgFallbackButton({
cfg,
discordConfig: discordCfg,
accountId: account.accountId,
sessionPrefix,
}),
],
components,
},
[
new GatewayPlugin({
@@ -510,6 +529,11 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
runtime.log?.(`logged in to discord${botUserId ? ` as ${botUserId}` : ""}`);
// Start exec approvals handler after client is ready
if (execApprovalsHandler) {
await execApprovalsHandler.start();
}
const gateway = client.getPlugin<GatewayPlugin>("gateway");
const gatewayEmitter = getDiscordGatewayEmitter(gateway);
const stopGatewayLogging = attachDiscordGatewayLogging({
@@ -575,6 +599,9 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) {
if (helloTimeoutId) clearTimeout(helloTimeoutId);
gatewayEmitter?.removeListener("debug", onGatewayDebug);
abortSignal?.removeEventListener("abort", onAbort);
if (execApprovalsHandler) {
await execApprovalsHandler.stop();
}
}
}

View File

@@ -1,4 +1,5 @@
import type { ExecApprovalDecision } from "../../infra/exec-approvals.js";
import type { ExecApprovalForwarder } from "../../infra/exec-approval-forwarder.js";
import type { ExecApprovalManager } from "../exec-approval-manager.js";
import {
ErrorCodes,
@@ -9,7 +10,10 @@ import {
} from "../protocol/index.js";
import type { GatewayRequestHandlers } from "./types.js";
export function createExecApprovalHandlers(manager: ExecApprovalManager): GatewayRequestHandlers {
export function createExecApprovalHandlers(
manager: ExecApprovalManager,
opts?: { forwarder?: ExecApprovalForwarder },
): GatewayRequestHandlers {
return {
"exec.approval.request": async ({ params, respond, context }) => {
if (!validateExecApprovalRequestParams(params)) {
@@ -69,6 +73,16 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
},
{ dropIfSlow: true },
);
void opts?.forwarder
?.handleRequested({
id: record.id,
request: record.request,
createdAtMs: record.createdAtMs,
expiresAtMs: record.expiresAtMs,
})
.catch((err) => {
context.logGateway?.error?.(`exec approvals: forward request failed: ${String(err)}`);
});
const decision = await decisionPromise;
respond(
true,
@@ -112,6 +126,11 @@ export function createExecApprovalHandlers(manager: ExecApprovalManager): Gatewa
{ id: p.id, decision, resolvedBy, ts: Date.now() },
{ dropIfSlow: true },
);
void opts?.forwarder
?.handleResolved({ id: p.id, decision, resolvedBy, ts: Date.now() })
.catch((err) => {
context.logGateway?.error?.(`exec approvals: forward resolve failed: ${String(err)}`);
});
respond(true, { ok: true }, undefined);
},
};

View File

@@ -43,6 +43,7 @@ import {
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
import { createExecApprovalForwarder } from "../infra/exec-approval-forwarder.js";
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
import { createChannelManager } from "./server-channels.js";
import { createAgentEventHandler } from "./server-chat.js";
@@ -396,7 +397,10 @@ export async function startGatewayServer(
void cron.start().catch((err) => logCron.error(`failed to start: ${String(err)}`));
const execApprovalManager = new ExecApprovalManager();
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager);
const execApprovalForwarder = createExecApprovalForwarder();
const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager, {
forwarder: execApprovalForwarder,
});
const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port;

View File

@@ -0,0 +1,77 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { createExecApprovalForwarder } from "./exec-approval-forwarder.js";
const baseRequest = {
id: "req-1",
request: {
command: "echo hello",
agentId: "main",
sessionKey: "agent:main:main",
},
createdAtMs: 1000,
expiresAtMs: 6000,
};
afterEach(() => {
vi.useRealTimers();
});
describe("exec approval forwarder", () => {
it("forwards to session target and resolves", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: { exec: { enabled: true, mode: "session" } },
} as ClawdbotConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => ({ channel: "slack", to: "U1" }),
});
await forwarder.handleRequested(baseRequest);
expect(deliver).toHaveBeenCalledTimes(1);
await forwarder.handleResolved({
id: baseRequest.id,
decision: "allow-once",
resolvedBy: "slack:U1",
ts: 2000,
});
expect(deliver).toHaveBeenCalledTimes(2);
await vi.runAllTimersAsync();
expect(deliver).toHaveBeenCalledTimes(2);
});
it("forwards to explicit targets and expires", async () => {
vi.useFakeTimers();
const deliver = vi.fn().mockResolvedValue([]);
const cfg = {
approvals: {
exec: {
enabled: true,
mode: "targets",
targets: [{ channel: "telegram", to: "123" }],
},
},
} as ClawdbotConfig;
const forwarder = createExecApprovalForwarder({
getConfig: () => cfg,
deliver,
nowMs: () => 1000,
resolveSessionTarget: () => null,
});
await forwarder.handleRequested(baseRequest);
expect(deliver).toHaveBeenCalledTimes(1);
await vi.runAllTimersAsync();
expect(deliver).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,282 @@
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { loadSessionStore, resolveStorePath } from "../config/sessions.js";
import type {
ExecApprovalForwardingConfig,
ExecApprovalForwardTarget,
} from "../config/types.approvals.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { parseAgentSessionKey } from "../routing/session-key.js";
import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js";
import type { ExecApprovalDecision } from "./exec-approvals.js";
import { deliverOutboundPayloads } from "./outbound/deliver.js";
import { resolveSessionDeliveryTarget } from "./outbound/targets.js";
const log = createSubsystemLogger("gateway/exec-approvals");
export type ExecApprovalRequest = {
id: string;
request: {
command: string;
cwd?: string | null;
host?: string | null;
security?: string | null;
ask?: string | null;
agentId?: string | null;
resolvedPath?: string | null;
sessionKey?: string | null;
};
createdAtMs: number;
expiresAtMs: number;
};
export type ExecApprovalResolved = {
id: string;
decision: ExecApprovalDecision;
resolvedBy?: string | null;
ts: number;
};
type ForwardTarget = ExecApprovalForwardTarget & { source: "session" | "target" };
type PendingApproval = {
request: ExecApprovalRequest;
targets: ForwardTarget[];
timeoutId: NodeJS.Timeout | null;
};
export type ExecApprovalForwarder = {
handleRequested: (request: ExecApprovalRequest) => Promise<void>;
handleResolved: (resolved: ExecApprovalResolved) => Promise<void>;
stop: () => void;
};
export type ExecApprovalForwarderDeps = {
getConfig?: () => ClawdbotConfig;
deliver?: typeof deliverOutboundPayloads;
nowMs?: () => number;
resolveSessionTarget?: (params: {
cfg: ClawdbotConfig;
request: ExecApprovalRequest;
}) => ExecApprovalForwardTarget | null;
};
const DEFAULT_MODE = "session" as const;
function normalizeMode(mode?: ExecApprovalForwardingConfig["mode"]) {
return mode ?? DEFAULT_MODE;
}
function matchSessionFilter(sessionKey: string, patterns: string[]): boolean {
return patterns.some((pattern) => {
try {
return sessionKey.includes(pattern) || new RegExp(pattern).test(sessionKey);
} catch {
return sessionKey.includes(pattern);
}
});
}
function shouldForward(params: {
config?: ExecApprovalForwardingConfig;
request: ExecApprovalRequest;
}): boolean {
const config = params.config;
if (!config?.enabled) return false;
if (config.agentFilter?.length) {
const agentId =
params.request.request.agentId ??
parseAgentSessionKey(params.request.request.sessionKey)?.agentId;
if (!agentId) return false;
if (!config.agentFilter.includes(agentId)) return false;
}
if (config.sessionFilter?.length) {
const sessionKey = params.request.request.sessionKey;
if (!sessionKey) return false;
if (!matchSessionFilter(sessionKey, config.sessionFilter)) return false;
}
return true;
}
function buildTargetKey(target: ExecApprovalForwardTarget): string {
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
const accountId = target.accountId ?? "";
const threadId = target.threadId ?? "";
return [channel, target.to, accountId, threadId].join(":");
}
function buildRequestMessage(request: ExecApprovalRequest, nowMs: number) {
const lines: string[] = ["🔒 Exec approval required", `ID: ${request.id}`];
lines.push(`Command: ${request.request.command}`);
if (request.request.cwd) lines.push(`CWD: ${request.request.cwd}`);
if (request.request.host) lines.push(`Host: ${request.request.host}`);
if (request.request.agentId) lines.push(`Agent: ${request.request.agentId}`);
if (request.request.security) lines.push(`Security: ${request.request.security}`);
if (request.request.ask) lines.push(`Ask: ${request.request.ask}`);
const expiresIn = Math.max(0, Math.round((request.expiresAtMs - nowMs) / 1000));
lines.push(`Expires in: ${expiresIn}s`);
lines.push("Reply with: /approve <id> allow-once|allow-always|deny");
return lines.join("\n");
}
function decisionLabel(decision: ExecApprovalDecision): string {
if (decision === "allow-once") return "allowed once";
if (decision === "allow-always") return "allowed always";
return "denied";
}
function buildResolvedMessage(resolved: ExecApprovalResolved) {
const base = `✅ Exec approval ${decisionLabel(resolved.decision)}.`;
const by = resolved.resolvedBy ? ` Resolved by ${resolved.resolvedBy}.` : "";
return `${base}${by} ID: ${resolved.id}`;
}
function buildExpiredMessage(request: ExecApprovalRequest) {
return `⏱️ Exec approval expired. ID: ${request.id}`;
}
function defaultResolveSessionTarget(params: {
cfg: ClawdbotConfig;
request: ExecApprovalRequest;
}): ExecApprovalForwardTarget | null {
const sessionKey = params.request.request.sessionKey?.trim();
if (!sessionKey) return null;
const parsed = parseAgentSessionKey(sessionKey);
const agentId = parsed?.agentId ?? params.request.request.agentId ?? "main";
const storePath = resolveStorePath(params.cfg.session?.store, { agentId });
const store = loadSessionStore(storePath);
const entry = store[sessionKey];
if (!entry) return null;
const target = resolveSessionDeliveryTarget({ entry, requestedChannel: "last" });
if (!target.channel || !target.to) return null;
if (!isDeliverableMessageChannel(target.channel)) return null;
return {
channel: target.channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
};
}
async function deliverToTargets(params: {
cfg: ClawdbotConfig;
targets: ForwardTarget[];
text: string;
deliver: typeof deliverOutboundPayloads;
shouldSend?: () => boolean;
}) {
const deliveries = params.targets.map(async (target) => {
if (params.shouldSend && !params.shouldSend()) return;
const channel = normalizeMessageChannel(target.channel) ?? target.channel;
if (!isDeliverableMessageChannel(channel)) return;
try {
await params.deliver({
cfg: params.cfg,
channel,
to: target.to,
accountId: target.accountId,
threadId: target.threadId,
payloads: [{ text: params.text }],
});
} catch (err) {
log.error(`exec approvals: failed to deliver to ${channel}:${target.to}: ${String(err)}`);
}
});
await Promise.allSettled(deliveries);
}
export function createExecApprovalForwarder(
deps: ExecApprovalForwarderDeps = {},
): ExecApprovalForwarder {
const getConfig = deps.getConfig ?? loadConfig;
const deliver = deps.deliver ?? deliverOutboundPayloads;
const nowMs = deps.nowMs ?? Date.now;
const resolveSessionTarget = deps.resolveSessionTarget ?? defaultResolveSessionTarget;
const pending = new Map<string, PendingApproval>();
const handleRequested = async (request: ExecApprovalRequest) => {
const cfg = getConfig();
const config = cfg.approvals?.exec;
if (!shouldForward({ config, request })) return;
const mode = normalizeMode(config?.mode);
const targets: ForwardTarget[] = [];
const seen = new Set<string>();
if (mode === "session" || mode === "both") {
const sessionTarget = resolveSessionTarget({ cfg, request });
if (sessionTarget) {
const key = buildTargetKey(sessionTarget);
if (!seen.has(key)) {
seen.add(key);
targets.push({ ...sessionTarget, source: "session" });
}
}
}
if (mode === "targets" || mode === "both") {
const explicitTargets = config?.targets ?? [];
for (const target of explicitTargets) {
const key = buildTargetKey(target);
if (seen.has(key)) continue;
seen.add(key);
targets.push({ ...target, source: "target" });
}
}
if (targets.length === 0) return;
const expiresInMs = Math.max(0, request.expiresAtMs - nowMs());
const timeoutId = setTimeout(() => {
void (async () => {
const entry = pending.get(request.id);
if (!entry) return;
pending.delete(request.id);
const expiredText = buildExpiredMessage(request);
await deliverToTargets({ cfg, targets: entry.targets, text: expiredText, deliver });
})();
}, expiresInMs);
timeoutId.unref?.();
const pendingEntry: PendingApproval = { request, targets, timeoutId };
pending.set(request.id, pendingEntry);
if (pending.get(request.id) !== pendingEntry) return;
const text = buildRequestMessage(request, nowMs());
await deliverToTargets({
cfg,
targets,
text,
deliver,
shouldSend: () => pending.get(request.id) === pendingEntry,
});
};
const handleResolved = async (resolved: ExecApprovalResolved) => {
const entry = pending.get(resolved.id);
if (!entry) return;
if (entry.timeoutId) clearTimeout(entry.timeoutId);
pending.delete(resolved.id);
const cfg = getConfig();
const text = buildResolvedMessage(resolved);
await deliverToTargets({ cfg, targets: entry.targets, text, deliver });
};
const stop = () => {
for (const entry of pending.values()) {
if (entry.timeoutId) clearTimeout(entry.timeoutId);
}
pending.clear();
};
return { handleRequested, handleResolved, stop };
}
export function shouldForwardExecApproval(params: {
config?: ExecApprovalForwardingConfig;
request: ExecApprovalRequest;
}): boolean {
return shouldForward(params);
}

View File

@@ -35,6 +35,7 @@ import {
} from "../config/sessions.js";
import type { AgentDefaultsConfig } from "../config/types.agent-defaults.js";
import { formatErrorMessage } from "../infra/errors.js";
import { peekSystemEvents } from "../infra/system-events.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { getQueueSize } from "../process/command-queue.js";
import { CommandLane } from "../process/lanes.js";
@@ -88,6 +89,14 @@ export type HeartbeatSummary = {
const DEFAULT_HEARTBEAT_TARGET = "last";
const ACTIVE_HOURS_TIME_PATTERN = /^([01]\d|2[0-3]|24):([0-5]\d)$/;
// Prompt used when an async exec has completed and the result should be relayed to the user.
// This overrides the standard heartbeat prompt to ensure the model responds with the exec result
// instead of just "HEARTBEAT_OK".
const EXEC_EVENT_PROMPT =
"An async command you ran earlier has completed. The result is shown in the system messages above. " +
"Please relay the command output to the user in a helpful way. If the command succeeded, share the relevant output. " +
"If it failed, explain what went wrong.";
function resolveActiveHoursTimezone(cfg: ClawdbotConfig, raw?: string): string {
const trimmed = raw?.trim();
if (!trimmed || trimmed === "user") {
@@ -453,11 +462,13 @@ export async function runHeartbeatOnce(opts: {
// Skip heartbeat if HEARTBEAT.md exists but has no actionable content.
// This saves API calls/costs when the file is effectively empty (only comments/headers).
// EXCEPTION: Don't skip for exec events - they have pending system events to process.
const isExecEventReason = opts.reason === "exec-event";
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
try {
const heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent)) {
if (isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && !isExecEventReason) {
emitHeartbeatEvent({
status: "skipped",
reason: "empty-heartbeat-file",
@@ -483,12 +494,20 @@ export async function runHeartbeatOnce(opts: {
: { showOk: false, showAlerts: true, useIndicator: true };
const { sender } = resolveHeartbeatSenderContext({ cfg, entry, delivery });
const responsePrefix = resolveEffectiveMessagesConfig(cfg, agentId).responsePrefix;
const prompt = resolveHeartbeatPrompt(cfg, heartbeat);
// Check if this is an exec event with pending exec completion system events.
// If so, use a specialized prompt that instructs the model to relay the result
// instead of the standard heartbeat prompt with "reply HEARTBEAT_OK".
const isExecEvent = opts.reason === "exec-event";
const pendingEvents = isExecEvent ? peekSystemEvents(sessionKey) : [];
const hasExecCompletion = pendingEvents.some((evt) => evt.includes("Exec finished"));
const prompt = hasExecCompletion ? EXEC_EVENT_PROMPT : resolveHeartbeatPrompt(cfg, heartbeat);
const ctx = {
Body: prompt,
From: sender,
To: sender,
Provider: "heartbeat",
Provider: hasExecCompletion ? "exec-event" : "heartbeat",
SessionKey: sessionKey,
};
if (!visibility.showAlerts && !visibility.showOk && !visibility.useIndicator) {
@@ -558,7 +577,19 @@ export async function runHeartbeatOnce(opts: {
const ackMaxChars = resolveHeartbeatAckMaxChars(cfg, heartbeat);
const normalized = normalizeHeartbeatReply(replyPayload, responsePrefix, ackMaxChars);
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia;
// For exec completion events, don't skip even if the response looks like HEARTBEAT_OK.
// The model should be responding with exec results, not ack tokens.
// Also, if normalized.text is empty due to token stripping but we have exec completion,
// fall back to the original reply text.
const execFallbackText =
hasExecCompletion && !normalized.text.trim() && replyPayload.text?.trim()
? replyPayload.text.trim()
: null;
if (execFallbackText) {
normalized.text = execFallbackText;
normalized.shouldSkip = false;
}
const shouldSkipMain = normalized.shouldSkip && !normalized.hasMedia && !hasExecCompletion;
if (shouldSkipMain && reasoningPayloads.length === 0) {
await restoreHeartbeatUpdatedAt({
storePath,