Compare commits

..
Author SHA1 Message Date
Peter Steinberger d1ccc1e542 fix: align prompt failover fallback gating (#1136) (thanks @cheeeee) 2026-01-18 03:31:38 +00:00
Mykyta Bozhenko 62324eed0b fix(agent): Enable model fallback for prompt-phase quota/rate limit errors
When a prompt submission fails with quota or rate limit errors, throw
FailoverError instead of the raw promptError. This enables the model
fallback system to try alternative models.

Previously, rate limit errors during the prompt phase (before streaming)
were thrown directly, bypassing fallback. Only response-phase errors
triggered model fallback.

Now checks if fallback models are configured and the error is failover-
eligible. If so, wraps in FailoverError to trigger the fallback chain.
2026-01-18 03:15:28 +00:00
241 changed files with 3298 additions and 11757 deletions

No files matched your search

+7 -31
View File
@@ -2,51 +2,25 @@
Docs: https://docs.clawd.bot
## 2026.1.18-3
### Changes
- Exec: add host/security/ask routing for gateway + node exec.
- macOS: migrate exec approvals to `~/.clawdbot/exec-approvals.json` with per-agent allowlists and skill auto-allow toggle.
- macOS: add approvals socket UI server + node exec lifecycle events.
- Slash commands: replace `/cost` with `/usage off|tokens|full` to control per-response usage footer; `/usage` no longer aliases `/status`. (Supersedes #1140) — thanks @Nachx639.
- Docs: refresh exec/elevated/exec-approvals docs for the new flow. https://docs.clawd.bot/tools/exec-approvals
- CLI: add `clawdbot acp` ACP bridge for IDE integrations.
### Fixes
- Tools: return a companion-app-required message when node exec is requested with no paired node.
## 2026.1.18-2
### Fixes
- Tests: stabilize plugin SDK resolution and embedded agent timeouts.
## 2026.1.17-6
### Changes
- Plugins: add exclusive plugin slots with a dedicated memory slot selector.
- Memory: ship core memory tools + CLI as the bundled `memory-core` plugin.
- Docs: document plugin slots and memory plugin behavior.
- Plugins: add the bundled BlueBubbles channel plugin (disabled by default).
- Plugins: migrate bundled messaging extensions to the plugin SDK; resolve plugin-sdk imports in loader.
- Plugins: migrate the Zalo plugin to the shared plugin SDK runtime.
- Plugins: migrate the Zalo Personal plugin to the shared plugin SDK runtime.
### Fixes
- Agents: trigger model fallback for prompt-phase failover errors, respecting per-agent overrides. (#1136) — thanks @cheeeee.
## 2026.1.17-5
### Changes
- Memory: add hybrid BM25 + vector search (FTS5) with weighted merging and fallback.
- Memory: add SQLite embedding cache to speed up reindexing and frequent updates.
- CLI: surface FTS + embedding cache state in `clawdbot memory status`.
- Memory: render progress immediately, color batch statuses in verbose logs, and poll OpenAI batch status every 2s by default.
- Plugins: allow optional agent tools with explicit allowlists and add plugin tool authoring guide. https://docs.clawd.bot/plugins/agent-tools
- Tools: centralize plugin tool policy helpers.
- Commands: add `/subagents info` and show sub-agent counts in `/status`.
- Docs: clarify plugin agent tool configuration. https://docs.clawd.bot/plugins/agent-tools
- CLI: surface memory search state in `clawdbot status` and detailed FTS + embedding cache state in `clawdbot memory status`.
### Fixes
- Voice call: include request query in Twilio webhook verification when publicUrl is set. (#864)
## 2026.1.18-1
## 2026.1.17-4
### Changes
- Tools: allow `sessions_spawn` to override thinking level for sub-agent runs.
@@ -62,11 +36,13 @@ Docs: https://docs.clawd.bot
- macOS: bundle Textual resources in packaged app builds to avoid code block crashes. (#1006)
- Tools: return a companion-app-required message when `system.run` is requested without a supporting node.
- Discord: only emit slow listener warnings after 30s.
## 2026.1.17-3
### Changes
- Memory: add OpenAI Batch API indexing for embeddings when configured.
- Memory: enable OpenAI batch indexing by default for OpenAI embeddings.
- Sessions: persist origin metadata across connectors for generic session explainers.
### Fixes
- Memory: retry transient 5xx errors (Cloudflare) during embedding indexing.
+1 -1
View File
@@ -249,7 +249,7 @@ Send these in WhatsApp/Telegram/Slack/Microsoft Teams/WebChat (group commands ar
- `/compact` — compact session context (summary)
- `/think <level>` — off|minimal|low|medium|high|xhigh (GPT-5.2 + Codex models only)
- `/verbose on|off`
- `/usage off|tokens|full` — per-response usage footer
- `/cost on|off` — append per-response token/cost usage lines
- `/restart` — restart the gateway (owner-only in groups)
- `/activation mention|always` — group activation toggle (groups only)
+3 -11
View File
@@ -170,15 +170,8 @@ final class AppState {
didSet { self.ifNotPreview { UserDefaults.standard.set(self.canvasEnabled, forKey: canvasEnabledKey) } }
}
var execApprovalMode: ExecApprovalQuickMode {
didSet {
self.ifNotPreview {
ExecApprovalsStore.updateDefaults { defaults in
defaults.security = self.execApprovalMode.security
defaults.ask = self.execApprovalMode.ask
}
}
}
var systemRunPolicy: SystemRunPolicy {
didSet { self.ifNotPreview { MacNodeConfigFile.setSystemRunPolicy(self.systemRunPolicy) } }
}
/// Tracks whether the Canvas panel is currently visible (not persisted).
@@ -281,8 +274,7 @@ final class AppState {
self.remoteProjectRoot = UserDefaults.standard.string(forKey: remoteProjectRootKey) ?? ""
self.remoteCliPath = UserDefaults.standard.string(forKey: remoteCliPathKey) ?? ""
self.canvasEnabled = UserDefaults.standard.object(forKey: canvasEnabledKey) as? Bool ?? true
let execDefaults = ExecApprovalsStore.resolveDefaults()
self.execApprovalMode = ExecApprovalQuickMode.from(security: execDefaults.security, ask: execDefaults.ask)
self.systemRunPolicy = SystemRunPolicy.load()
self.peekabooBridgeEnabled = UserDefaults.standard
.object(forKey: peekabooBridgeEnabledKey) as? Bool ?? true
if !self.isPreview {
@@ -1,566 +0,0 @@
import Foundation
import OSLog
import Security
enum ExecSecurity: String, CaseIterable, Codable, Identifiable {
case deny
case allowlist
case full
var id: String { self.rawValue }
var title: String {
switch self {
case .deny: "Deny"
case .allowlist: "Allowlist"
case .full: "Always Allow"
}
}
}
enum ExecApprovalQuickMode: String, CaseIterable, Identifiable {
case deny
case ask
case allow
var id: String { self.rawValue }
var title: String {
switch self {
case .deny: "Deny"
case .ask: "Always Ask"
case .allow: "Always Allow"
}
}
var security: ExecSecurity {
switch self {
case .deny: .deny
case .ask: .allowlist
case .allow: .full
}
}
var ask: ExecAsk {
switch self {
case .deny: .off
case .ask: .onMiss
case .allow: .off
}
}
static func from(security: ExecSecurity, ask: ExecAsk) -> ExecApprovalQuickMode {
switch security {
case .deny:
return .deny
case .full:
return .allow
case .allowlist:
return .ask
}
}
}
enum ExecAsk: String, CaseIterable, Codable, Identifiable {
case off
case onMiss = "on-miss"
case always
var id: String { self.rawValue }
var title: String {
switch self {
case .off: "Never Ask"
case .onMiss: "Ask on Allowlist Miss"
case .always: "Always Ask"
}
}
}
enum ExecApprovalDecision: String, Codable, Sendable {
case allowOnce = "allow-once"
case allowAlways = "allow-always"
case deny
}
struct ExecAllowlistEntry: Codable, Hashable {
var pattern: String
var lastUsedAt: Double? = nil
var lastUsedCommand: String? = nil
var lastResolvedPath: String? = nil
}
struct ExecApprovalsDefaults: Codable {
var security: ExecSecurity?
var ask: ExecAsk?
var askFallback: ExecSecurity?
var autoAllowSkills: Bool?
}
struct ExecApprovalsAgent: Codable {
var security: ExecSecurity?
var ask: ExecAsk?
var askFallback: ExecSecurity?
var autoAllowSkills: Bool?
var allowlist: [ExecAllowlistEntry]?
var isEmpty: Bool {
security == nil && ask == nil && askFallback == nil && autoAllowSkills == nil && (allowlist?.isEmpty ?? true)
}
}
struct ExecApprovalsSocketConfig: Codable {
var path: String?
var token: String?
}
struct ExecApprovalsFile: Codable {
var version: Int
var socket: ExecApprovalsSocketConfig?
var defaults: ExecApprovalsDefaults?
var agents: [String: ExecApprovalsAgent]?
}
struct ExecApprovalsResolved {
let url: URL
let socketPath: String
let token: String
let defaults: ExecApprovalsResolvedDefaults
let agent: ExecApprovalsResolvedDefaults
let allowlist: [ExecAllowlistEntry]
var file: ExecApprovalsFile
}
struct ExecApprovalsResolvedDefaults {
var security: ExecSecurity
var ask: ExecAsk
var askFallback: ExecSecurity
var autoAllowSkills: Bool
}
enum ExecApprovalsStore {
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
private static let defaultSecurity: ExecSecurity = .deny
private static let defaultAsk: ExecAsk = .onMiss
private static let defaultAskFallback: ExecSecurity = .deny
private static let defaultAutoAllowSkills = false
static func fileURL() -> URL {
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.json")
}
static func socketPath() -> String {
ClawdbotPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
}
static func loadFile() -> ExecApprovalsFile {
let url = self.fileURL()
guard FileManager.default.fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
do {
let data = try Data(contentsOf: url)
let decoded = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
if decoded.version != 1 {
return ExecApprovalsFile(version: 1, socket: decoded.socket, defaults: decoded.defaults, agents: decoded.agents)
}
return decoded
} catch {
self.logger.warning("exec approvals load failed: \(error.localizedDescription, privacy: .public)")
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
}
}
static func saveFile(_ file: ExecApprovalsFile) {
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(file)
let url = self.fileURL()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
self.logger.error("exec approvals save failed: \(error.localizedDescription, privacy: .public)")
}
}
static func ensureFile() -> ExecApprovalsFile {
var file = self.loadFile()
if file.socket == nil { file.socket = ExecApprovalsSocketConfig(path: nil, token: nil) }
let path = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if path.isEmpty {
file.socket?.path = self.socketPath()
}
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if token.isEmpty {
file.socket?.token = self.generateToken()
}
if file.agents == nil { file.agents = [:] }
self.saveFile(file)
return file
}
static func resolve(agentId: String?) -> ExecApprovalsResolved {
var file = self.ensureFile()
let defaults = file.defaults ?? ExecApprovalsDefaults()
let resolvedDefaults = ExecApprovalsResolvedDefaults(
security: defaults.security ?? self.defaultSecurity,
ask: defaults.ask ?? self.defaultAsk,
askFallback: defaults.askFallback ?? self.defaultAskFallback,
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
: "default"
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
let resolvedAgent = ExecApprovalsResolvedDefaults(
security: agentEntry.security ?? resolvedDefaults.security,
ask: agentEntry.ask ?? resolvedDefaults.ask,
askFallback: agentEntry.askFallback ?? resolvedDefaults.askFallback,
autoAllowSkills: agentEntry.autoAllowSkills ?? resolvedDefaults.autoAllowSkills)
let allowlist = (agentEntry.allowlist ?? [])
.map { entry in
ExecAllowlistEntry(
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
lastResolvedPath: entry.lastResolvedPath)
}
.filter { !$0.pattern.isEmpty }
let socketPath = self.expandPath(file.socket?.path ?? self.socketPath())
let token = file.socket?.token ?? ""
return ExecApprovalsResolved(
url: self.fileURL(),
socketPath: socketPath,
token: token,
defaults: resolvedDefaults,
agent: resolvedAgent,
allowlist: allowlist,
file: file)
}
static func resolveDefaults() -> ExecApprovalsResolvedDefaults {
let file = self.ensureFile()
let defaults = file.defaults ?? ExecApprovalsDefaults()
return ExecApprovalsResolvedDefaults(
security: defaults.security ?? self.defaultSecurity,
ask: defaults.ask ?? self.defaultAsk,
askFallback: defaults.askFallback ?? self.defaultAskFallback,
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
}
static func saveDefaults(_ defaults: ExecApprovalsDefaults) {
self.updateFile { file in
file.defaults = defaults
}
}
static func updateDefaults(_ mutate: (inout ExecApprovalsDefaults) -> Void) {
self.updateFile { file in
var defaults = file.defaults ?? ExecApprovalsDefaults()
mutate(&defaults)
file.defaults = defaults
}
}
static func saveAgent(_ agent: ExecApprovalsAgent, agentId: String?) {
self.updateFile { file in
var agents = file.agents ?? [:]
let key = self.agentKey(agentId)
if agent.isEmpty {
agents.removeValue(forKey: key)
} else {
agents[key] = agent
}
file.agents = agents.isEmpty ? nil : agents
}
}
static func addAllowlistEntry(agentId: String?, pattern: String) {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
var allowlist = entry.allowlist ?? []
if allowlist.contains(where: { $0.pattern == trimmed }) { return }
allowlist.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: Date().timeIntervalSince1970 * 1000))
entry.allowlist = allowlist
agents[key] = entry
file.agents = agents
}
}
static func recordAllowlistUse(
agentId: String?,
pattern: String,
command: String,
resolvedPath: String?)
{
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
guard item.pattern == pattern else { return item }
return ExecAllowlistEntry(
pattern: item.pattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000,
lastUsedCommand: command,
lastResolvedPath: resolvedPath)
}
entry.allowlist = allowlist
agents[key] = entry
file.agents = agents
}
}
static func updateAllowlist(agentId: String?, allowlist: [ExecAllowlistEntry]) {
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
let cleaned = allowlist
.map { item in
ExecAllowlistEntry(
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand,
lastResolvedPath: item.lastResolvedPath)
}
.filter { !$0.pattern.isEmpty }
entry.allowlist = cleaned
agents[key] = entry
file.agents = agents
}
}
static func updateAgentSettings(agentId: String?, mutate: (inout ExecApprovalsAgent) -> Void) {
self.updateFile { file in
let key = self.agentKey(agentId)
var agents = file.agents ?? [:]
var entry = agents[key] ?? ExecApprovalsAgent()
mutate(&entry)
if entry.isEmpty {
agents.removeValue(forKey: key)
} else {
agents[key] = entry
}
file.agents = agents.isEmpty ? nil : agents
}
}
private static func updateFile(_ mutate: (inout ExecApprovalsFile) -> Void) {
var file = self.ensureFile()
mutate(&file)
self.saveFile(file)
}
private static func generateToken() -> String {
var bytes = [UInt8](repeating: 0, count: 24)
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
if status == errSecSuccess {
return Data(bytes)
.base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
return UUID().uuidString
}
private static func expandPath(_ raw: String) -> String {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed == "~" {
return FileManager.default.homeDirectoryForCurrentUser.path
}
if trimmed.hasPrefix("~/") {
let suffix = trimmed.dropFirst(2)
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(String(suffix)).path
}
return trimmed
}
private static func agentKey(_ agentId: String?) -> String {
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return trimmed.isEmpty ? "default" : trimmed
}
}
struct ExecCommandResolution: Sendable {
let rawExecutable: String
let resolvedPath: String?
let executableName: String
let cwd: String?
static func resolve(command: [String], cwd: String?, env: [String: String]?) -> ExecCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
let hasPathSeparator = expanded.contains("/") || expanded.contains("\\")
let resolvedPath: String? = {
if hasPathSeparator {
if expanded.hasPrefix("/") {
return expanded
}
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
}
let searchPaths = self.searchPaths(from: env)
return CommandResolver.findExecutable(named: expanded, searchPaths: searchPaths)
}()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return ExecCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
}
private static func searchPaths(from env: [String: String]?) -> [String] {
let raw = env?["PATH"]
if let raw, !raw.isEmpty {
return raw.split(separator: ":").map(String.init)
}
return CommandResolver.preferredPaths()
}
}
enum ExecCommandFormatter {
static func displayString(for argv: [String]) -> String {
argv.map { arg in
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "\"\"" }
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
if !needsQuotes { return trimmed }
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}.joined(separator: " ")
}
}
enum ExecAllowlistMatcher {
static func match(entries: [ExecAllowlistEntry], resolution: ExecCommandResolution?) -> ExecAllowlistEntry? {
guard let resolution, !entries.isEmpty else { return nil }
let rawExecutable = resolution.rawExecutable
let resolvedPath = resolution.resolvedPath
let executableName = resolution.executableName
for entry in entries {
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
if pattern.isEmpty { continue }
let hasPath = pattern.contains("/") || pattern.contains("~") || pattern.contains("\\")
if hasPath {
let target = resolvedPath ?? rawExecutable
if self.matches(pattern: pattern, target: target) { return entry }
} else if self.matches(pattern: pattern, target: executableName) {
return entry
}
}
return nil
}
private static func matches(pattern: String, target: String) -> Bool {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
let normalizedPattern = self.normalizeMatchTarget(expanded)
let normalizedTarget = self.normalizeMatchTarget(target)
guard let regex = self.regex(for: normalizedPattern) else { return false }
let range = NSRange(location: 0, length: normalizedTarget.utf16.count)
return regex.firstMatch(in: normalizedTarget, options: [], range: range) != nil
}
private static func normalizeMatchTarget(_ value: String) -> String {
value.replacingOccurrences(of: "\\\\", with: "/").lowercased()
}
private static func regex(for pattern: String) -> NSRegularExpression? {
var regex = "^"
var idx = pattern.startIndex
while idx < pattern.endIndex {
let ch = pattern[idx]
if ch == "*" {
let next = pattern.index(after: idx)
if next < pattern.endIndex, pattern[next] == "*" {
regex += ".*"
idx = pattern.index(after: next)
} else {
regex += "[^/]*"
idx = next
}
continue
}
if ch == "?" {
regex += "."
idx = pattern.index(after: idx)
continue
}
regex += NSRegularExpression.escapedPattern(for: String(ch))
idx = pattern.index(after: idx)
}
regex += "$"
return try? NSRegularExpression(pattern: regex, options: [.caseInsensitive])
}
}
struct ExecEventPayload: Codable, Sendable {
var sessionKey: String
var runId: String
var host: String
var command: String?
var exitCode: Int?
var timedOut: Bool?
var success: Bool?
var output: String?
var reason: String?
static func truncateOutput(_ raw: String, maxChars: Int = 20_000) -> String? {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.count <= maxChars { return trimmed }
let suffix = trimmed.suffix(maxChars)
return "… (truncated) \(suffix)"
}
}
actor SkillBinsCache {
static let shared = SkillBinsCache()
private var bins: Set<String> = []
private var lastRefresh: Date?
private let refreshInterval: TimeInterval = 90
func currentBins(force: Bool = false) async -> Set<String> {
if force || self.isStale() {
await self.refresh()
}
return self.bins
}
func refresh() async {
do {
let report = try await GatewayConnection.shared.skillsStatus()
var next = Set<String>()
for skill in report.skills {
for bin in skill.requirements.bins {
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { next.insert(trimmed) }
}
}
self.bins = next
self.lastRefresh = Date()
} catch {
if self.lastRefresh == nil {
self.bins = []
}
}
}
private func isStale() -> Bool {
guard let lastRefresh else { return true }
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
}
}
@@ -1,359 +0,0 @@
import AppKit
import ClawdbotKit
import Darwin
import Foundation
import OSLog
struct ExecApprovalPromptRequest: Codable, Sendable {
var command: String
var cwd: String?
var host: String?
var security: String?
var ask: String?
var agentId: String?
var resolvedPath: String?
}
private struct ExecApprovalSocketRequest: Codable {
var type: String
var token: String
var id: String
var request: ExecApprovalPromptRequest
}
private struct ExecApprovalSocketDecision: Codable {
var type: String
var id: String
var decision: ExecApprovalDecision
}
enum ExecApprovalsSocketClient {
private struct TimeoutError: LocalizedError {
var message: String
var errorDescription: String? { message }
}
static func requestDecision(
socketPath: String,
token: String,
request: ExecApprovalPromptRequest,
timeoutMs: Int = 15_000) async -> ExecApprovalDecision?
{
let trimmedPath = socketPath.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedToken = token.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedPath.isEmpty, !trimmedToken.isEmpty else { return nil }
do {
return try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: {
TimeoutError(message: "exec approvals socket timeout")
}, operation: {
try await Task.detached {
try self.requestDecisionSync(
socketPath: trimmedPath,
token: trimmedToken,
request: request)
}.value
})
} catch {
return nil
}
}
private static func requestDecisionSync(
socketPath: String,
token: String,
request: ExecApprovalPromptRequest) throws -> ExecApprovalDecision?
{
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else {
throw NSError(domain: "ExecApprovals", code: 1, userInfo: [
NSLocalizedDescriptionKey: "socket create failed",
])
}
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
if socketPath.utf8.count >= maxLen {
throw NSError(domain: "ExecApprovals", code: 2, userInfo: [
NSLocalizedDescriptionKey: "socket path too long",
])
}
socketPath.withCString { cstr in
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
strncpy(raw, cstr, maxLen - 1)
}
}
let size = socklen_t(MemoryLayout.size(ofValue: addr))
let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
connect(fd, rebound, size)
}
}
if result != 0 {
throw NSError(domain: "ExecApprovals", code: 3, userInfo: [
NSLocalizedDescriptionKey: "socket connect failed",
])
}
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
let message = ExecApprovalSocketRequest(
type: "request",
token: token,
id: UUID().uuidString,
request: request)
let data = try JSONEncoder().encode(message)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
let lineData = line.data(using: .utf8)
else { return nil }
let response = try JSONDecoder().decode(ExecApprovalSocketDecision.self, from: lineData)
return response.decision
}
private static func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
var buffer = Data()
while buffer.count < maxBytes {
let chunk = try handle.read(upToCount: 4096) ?? Data()
if chunk.isEmpty { break }
buffer.append(chunk)
if buffer.contains(0x0A) { break }
}
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
guard !buffer.isEmpty else { return nil }
return String(data: buffer, encoding: .utf8)
}
let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8)
}
}
@MainActor
final class ExecApprovalsPromptServer {
static let shared = ExecApprovalsPromptServer()
private var server: ExecApprovalsSocketServer?
func start() {
guard self.server == nil else { return }
let approvals = ExecApprovalsStore.resolve(agentId: nil)
let server = ExecApprovalsSocketServer(
socketPath: approvals.socketPath,
token: approvals.token,
onPrompt: { request in
await ExecApprovalsPromptPresenter.prompt(request)
})
server.start()
self.server = server
}
func stop() {
self.server?.stop()
self.server = nil
}
}
private enum ExecApprovalsPromptPresenter {
@MainActor
static func prompt(_ request: ExecApprovalPromptRequest) -> ExecApprovalDecision {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow this command?"
var details = "Clawdbot wants to run:\n\n\(request.command)"
let trimmedCwd = request.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
let trimmedAgent = request.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedAgent.isEmpty {
details += "\n\nAgent:\n\(trimmedAgent)"
}
let trimmedPath = request.resolvedPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPath.isEmpty {
details += "\n\nExecutable:\n\(trimmedPath)"
}
let trimmedHost = request.host?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedHost.isEmpty {
details += "\n\nHost:\n\(trimmedHost)"
}
if let security = request.security?.trimmingCharacters(in: .whitespacesAndNewlines), !security.isEmpty {
details += "\n\nSecurity:\n\(security)"
}
if let ask = request.ask?.trimmingCharacters(in: .whitespacesAndNewlines), !ask.isEmpty {
details += "\nAsk mode:\n\(ask)"
}
details += "\n\nThis runs on this machine."
alert.informativeText = details
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow")
switch alert.runModal() {
case .alertFirstButtonReturn:
return .allowOnce
case .alertSecondButtonReturn:
return .allowAlways
default:
return .deny
}
}
}
private final class ExecApprovalsSocketServer {
private let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals.socket")
private let socketPath: String
private let token: String
private let onPrompt: @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision
private var socketFD: Int32 = -1
private var acceptTask: Task<Void, Never>?
private var isRunning = false
init(
socketPath: String,
token: String,
onPrompt: @escaping @Sendable (ExecApprovalPromptRequest) async -> ExecApprovalDecision)
{
self.socketPath = socketPath
self.token = token
self.onPrompt = onPrompt
}
func start() {
guard !self.isRunning else { return }
self.isRunning = true
self.acceptTask = Task.detached { [weak self] in
await self?.runAcceptLoop()
}
}
func stop() {
self.isRunning = false
self.acceptTask?.cancel()
self.acceptTask = nil
if self.socketFD >= 0 {
close(self.socketFD)
self.socketFD = -1
}
if !self.socketPath.isEmpty {
unlink(self.socketPath)
}
}
private func runAcceptLoop() async {
let fd = self.openSocket()
guard fd >= 0 else {
self.isRunning = false
return
}
self.socketFD = fd
while self.isRunning {
var addr = sockaddr_un()
var len = socklen_t(MemoryLayout.size(ofValue: addr))
let client = withUnsafeMutablePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
accept(fd, rebound, &len)
}
}
if client < 0 {
if errno == EINTR { continue }
break
}
Task.detached { [weak self] in
await self?.handleClient(fd: client)
}
}
}
private func openSocket() -> Int32 {
let fd = socket(AF_UNIX, SOCK_STREAM, 0)
guard fd >= 0 else {
self.logger.error("exec approvals socket create failed")
return -1
}
unlink(self.socketPath)
var addr = sockaddr_un()
addr.sun_family = sa_family_t(AF_UNIX)
let maxLen = MemoryLayout.size(ofValue: addr.sun_path)
if self.socketPath.utf8.count >= maxLen {
self.logger.error("exec approvals socket path too long")
close(fd)
return -1
}
self.socketPath.withCString { cstr in
withUnsafeMutablePointer(to: &addr.sun_path) { ptr in
let raw = UnsafeMutableRawPointer(ptr).assumingMemoryBound(to: Int8.self)
memset(raw, 0, maxLen)
strncpy(raw, cstr, maxLen - 1)
}
}
let size = socklen_t(MemoryLayout.size(ofValue: addr))
let result = withUnsafePointer(to: &addr) { ptr in
ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { rebound in
bind(fd, rebound, size)
}
}
if result != 0 {
self.logger.error("exec approvals socket bind failed")
close(fd)
return -1
}
if listen(fd, 16) != 0 {
self.logger.error("exec approvals socket listen failed")
close(fd)
return -1
}
chmod(self.socketPath, 0o600)
self.logger.info("exec approvals socket listening at \(self.socketPath, privacy: .public)")
return fd
}
private func handleClient(fd: Int32) async {
let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
do {
guard let line = try self.readLine(from: handle, maxBytes: 256_000),
let data = line.data(using: .utf8)
else {
return
}
let request = try JSONDecoder().decode(ExecApprovalSocketRequest.self, from: data)
guard request.type == "request", request.token == self.token else {
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: .deny)
let data = try JSONEncoder().encode(response)
var payload = data
payload.append(0x0A)
try handle.write(contentsOf: payload)
return
}
let decision = await self.onPrompt(request.request)
let response = ExecApprovalSocketDecision(type: "decision", id: request.id, decision: decision)
let responseData = try JSONEncoder().encode(response)
var payload = responseData
payload.append(0x0A)
try handle.write(contentsOf: payload)
} catch {
self.logger.error("exec approvals socket handling failed: \(error.localizedDescription, privacy: .public)")
}
}
private func readLine(from handle: FileHandle, maxBytes: Int) throws -> String? {
var buffer = Data()
while buffer.count < maxBytes {
let chunk = try handle.read(upToCount: 4096) ?? Data()
if chunk.isEmpty { break }
buffer.append(chunk)
if buffer.contains(0x0A) { break }
}
guard let newlineIndex = buffer.firstIndex(of: 0x0A) else {
guard !buffer.isEmpty else { return nil }
return String(data: buffer, encoding: .utf8)
}
let lineData = buffer.subdata(in: 0..<newlineIndex)
return String(data: lineData, encoding: .utf8)
}
}
@@ -0,0 +1,199 @@
import Foundation
import OSLog
enum MacNodeConfigFile {
private static let logger = Logger(subsystem: "com.clawdbot", category: "mac-node-config")
static func url() -> URL {
ClawdbotPaths.stateDirURL.appendingPathComponent("macos-node.json")
}
static func loadDict() -> [String: Any] {
let url = self.url()
guard FileManager.default.fileExists(atPath: url.path) else { return [:] }
do {
let data = try Data(contentsOf: url)
guard let root = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
self.logger.warning("mac node config JSON root invalid")
return [:]
}
return root
} catch {
self.logger.warning("mac node config read failed: \(error.localizedDescription, privacy: .public)")
return [:]
}
}
static func saveDict(_ dict: [String: Any]) {
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
try FileManager.default.createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
try? FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
} catch {
self.logger.error("mac node config save failed: \(error.localizedDescription, privacy: .public)")
}
}
private static func systemRunSection(from root: [String: Any]) -> [String: Any] {
root["systemRun"] as? [String: Any] ?? [:]
}
private static func updateSystemRunSection(_ mutate: (inout [String: Any]) -> Void) {
var root = self.loadDict()
var systemRun = self.systemRunSection(from: root)
mutate(&systemRun)
if systemRun.isEmpty {
root.removeValue(forKey: "systemRun")
} else {
root["systemRun"] = systemRun
}
self.saveDict(root)
}
private static func agentSection(_ systemRun: [String: Any], agentId: String) -> [String: Any]? {
let agents = systemRun["agents"] as? [String: Any]
return agents?[agentId] as? [String: Any]
}
private static func updateAgentSection(
_ systemRun: inout [String: Any],
agentId: String,
mutate: (inout [String: Any]) -> Void)
{
var agents = systemRun["agents"] as? [String: Any] ?? [:]
var entry = agents[agentId] as? [String: Any] ?? [:]
mutate(&entry)
if entry.isEmpty {
agents.removeValue(forKey: agentId)
} else {
agents[agentId] = entry
}
if agents.isEmpty {
systemRun.removeValue(forKey: "agents")
} else {
systemRun["agents"] = agents
}
}
static func systemRunPolicy(agentId: String? = nil) -> SystemRunPolicy? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
let raw = agent["policy"] as? String
if let raw, let policy = SystemRunPolicy(rawValue: raw) { return policy }
}
let raw = systemRun["policy"] as? String
guard let raw, let policy = SystemRunPolicy(rawValue: raw) else { return nil }
return policy
}
static func setSystemRunPolicy(_ policy: SystemRunPolicy, agentId: String? = nil) {
self.updateSystemRunSection { systemRun in
if let agentId {
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
entry["policy"] = policy.rawValue
}
return
}
systemRun["policy"] = policy.rawValue
}
}
static func systemRunAutoAllowSkills(agentId: String?) -> Bool? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
if let value = agent["autoAllowSkills"] as? Bool { return value }
}
return systemRun["autoAllowSkills"] as? Bool
}
static func setSystemRunAutoAllowSkills(_ enabled: Bool, agentId: String?) {
self.updateSystemRunSection { systemRun in
if let agentId {
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
entry["autoAllowSkills"] = enabled
}
return
}
systemRun["autoAllowSkills"] = enabled
}
}
static func systemRunAllowlist(agentId: String?) -> [SystemRunAllowlistEntry]? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
let raw: [Any]? = {
if let agentId, let agent = self.agentSection(systemRun, agentId: agentId) {
return agent["allowlist"] as? [Any]
}
return systemRun["allowlist"] as? [Any]
}()
guard let raw else { return nil }
if raw.allSatisfy({ $0 is String }) {
let legacy = raw.compactMap { $0 as? String }
return legacy.compactMap { key in
let pattern = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !pattern.isEmpty else { return nil }
return SystemRunAllowlistEntry(
pattern: pattern,
enabled: true,
matchKind: .argv,
source: .manual)
}
}
return raw.compactMap { item in
guard let dict = item as? [String: Any] else { return nil }
return SystemRunAllowlistEntry(dict: dict)
}
}
static func setSystemRunAllowlist(_ allowlist: [SystemRunAllowlistEntry], agentId: String?) {
let cleaned = allowlist
.map { $0 }
.filter { !$0.pattern.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
let raw = cleaned.map { $0.asDict() }
self.updateSystemRunSection { systemRun in
if let agentId {
self.updateAgentSection(&systemRun, agentId: agentId) { entry in
if raw.isEmpty {
entry.removeValue(forKey: "allowlist")
} else {
entry["allowlist"] = raw
}
}
return
}
if raw.isEmpty {
systemRun.removeValue(forKey: "allowlist")
} else {
systemRun["allowlist"] = raw
}
}
}
static func systemRunAllowlistStrings() -> [String]? {
let root = self.loadDict()
let systemRun = self.systemRunSection(from: root)
return systemRun["allowlist"] as? [String]
}
static func setSystemRunAllowlistStrings(_ allowlist: [String]) {
let cleaned = allowlist
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
.filter { !$0.isEmpty }
self.updateSystemRunSection { systemRun in
if cleaned.isEmpty {
systemRun.removeValue(forKey: "allowlist")
} else {
systemRun["allowlist"] = cleaned
}
}
}
}
@@ -256,7 +256,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
}
TerminationSignalWatcher.shared.start()
NodePairingApprovalPrompter.shared.start()
ExecApprovalsPromptServer.shared.start()
MacNodeModeCoordinator.shared.start()
VoiceWakeGlobalSettingsSync.shared.start()
Task { PresenceReporter.shared.start() }
@@ -281,7 +280,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
PresenceReporter.shared.stop()
NodePairingApprovalPrompter.shared.stop()
ExecApprovalsPromptServer.shared.stop()
MacNodeModeCoordinator.shared.stop()
TerminationSignalWatcher.shared.stop()
VoiceWakeGlobalSettingsSync.shared.stop()
@@ -31,10 +31,10 @@ struct MenuContent: View {
self._updateStatus = Bindable(wrappedValue: updater?.updateStatus ?? UpdateStatus.disabled)
}
private var execApprovalModeBinding: Binding<ExecApprovalQuickMode> {
private var systemRunPolicyBinding: Binding<SystemRunPolicy> {
Binding(
get: { self.state.execApprovalMode },
set: { self.state.execApprovalMode = $0 })
get: { self.state.systemRunPolicy },
set: { self.state.systemRunPolicy = $0 })
}
var body: some View {
@@ -74,12 +74,12 @@ struct MenuContent: View {
Toggle(isOn: self.$cameraEnabled) {
Label("Allow Camera", systemImage: "camera")
}
Picker(selection: self.execApprovalModeBinding) {
ForEach(ExecApprovalQuickMode.allCases) { mode in
Text(mode.title).tag(mode)
Picker(selection: self.systemRunPolicyBinding) {
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
} label: {
Label("Exec Approvals", systemImage: "terminal")
Label("Node Run Commands", systemImage: "terminal")
}
Toggle(isOn: Binding(get: { self.state.canvasEnabled }, set: { self.state.canvasEnabled = $0 })) {
Label("Allow Canvas", systemImage: "rectangle.and.pencil.and.ellipsis")
@@ -43,6 +43,7 @@ final class MacNodeModeCoordinator {
private func run() async {
var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool?
var lastSystemRunPolicy: SystemRunPolicy?
let defaults = UserDefaults.standard
while !Task.isCancelled {
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
@@ -59,6 +60,15 @@ final class MacNodeModeCoordinator {
try? await Task.sleep(nanoseconds: 200_000_000)
}
let systemRunPolicy = SystemRunPolicy.load()
if lastSystemRunPolicy == nil {
lastSystemRunPolicy = systemRunPolicy
} else if lastSystemRunPolicy != systemRunPolicy {
lastSystemRunPolicy = systemRunPolicy
await self.session.disconnect()
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000)
@@ -79,13 +89,8 @@ final class MacNodeModeCoordinator {
if let mainSessionKey {
await self?.runtime.updateMainSessionKey(mainSessionKey)
}
await self?.runtime.setEventSender { [weak self] event, payload in
guard let self else { return }
try? await self.session.sendEvent(event: event, payloadJSON: payload)
}
},
onDisconnected: { [weak self] reason in
await self?.runtime.setEventSender(nil)
onDisconnected: { reason in
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
},
onInvoke: { [weak self] req in
@@ -156,10 +161,13 @@ final class MacNodeModeCoordinator {
ClawdbotCanvasA2UICommand.reset.rawValue,
MacNodeScreenCommand.record.rawValue,
ClawdbotSystemCommand.notify.rawValue,
ClawdbotSystemCommand.which.rawValue,
ClawdbotSystemCommand.run.rawValue,
]
if SystemRunPolicy.load() != .never {
commands.append(ClawdbotSystemCommand.which.rawValue)
commands.append(ClawdbotSystemCommand.run.rawValue)
}
let capsSet = Set(caps)
if capsSet.contains(ClawdbotCapability.camera.rawValue) {
commands.append(ClawdbotCameraCommand.list.rawValue)
@@ -8,7 +8,6 @@ actor MacNodeRuntime {
private let makeMainActorServices: () async -> any MacNodeRuntimeMainActorServices
private var cachedMainActorServices: (any MacNodeRuntimeMainActorServices)?
private var mainSessionKey: String = "main"
private var eventSender: (@Sendable (String, String?) async -> Void)?
init(
makeMainActorServices: @escaping () async -> any MacNodeRuntimeMainActorServices = {
@@ -24,10 +23,6 @@ actor MacNodeRuntime {
self.mainSessionKey = trimmed
}
func setEventSender(_ sender: (@Sendable (String, String?) async -> Void)?) {
self.eventSender = sender
}
func handleInvoke(_ req: BridgeInvokeRequest) async -> BridgeInvokeResponse {
let command = req.command
if self.isCanvasCommand(command), !Self.canvasEnabled() {
@@ -435,19 +430,14 @@ actor MacNodeRuntime {
let trimmedAgent = params.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let agentId = trimmedAgent.isEmpty ? nil : trimmedAgent
let approvals = ExecApprovalsStore.resolve(agentId: agentId)
let security = approvals.agent.security
let ask = approvals.agent.ask
let askFallback = approvals.agent.askFallback
let autoAllowSkills = approvals.agent.autoAllowSkills
let sessionKey = (params.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
? params.sessionKey!.trimmingCharacters(in: .whitespacesAndNewlines)
: self.mainSessionKey
let runId = UUID().uuidString
let resolution = ExecCommandResolution.resolve(command: command, cwd: params.cwd, env: params.env)
let allowlistMatch = security == .allowlist
? ExecAllowlistMatcher.match(entries: approvals.allowlist, resolution: resolution)
: nil
let policy = SystemRunPolicy.load(agentId: agentId)
let allowlistEntries = SystemRunAllowlistStore.load(agentId: agentId)
let resolution = SystemRunCommandResolution.resolve(command: command, cwd: params.cwd)
let allowlistMatch = SystemRunAllowlistStore.match(
command: command,
resolution: resolution,
entries: allowlistEntries)
let autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
let skillAllow: Bool
if autoAllowSkills, let name = resolution?.executableName {
let bins = await SkillBinsCache.shared.currentBins()
@@ -456,90 +446,55 @@ actor MacNodeRuntime {
skillAllow = false
}
if security == .deny {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command),
reason: "security=deny"))
let shouldPrompt: Bool = {
if policy == .never { return false }
if allowlistMatch != nil { return false }
if skillAllow { return false }
return policy == .ask
}()
switch policy {
case .never:
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DISABLED: security=deny")
}
let requiresAsk: Bool = {
if ask == .always { return true }
if ask == .onMiss && security == .allowlist && allowlistMatch == nil && !skillAllow { return true }
return false
}()
if requiresAsk {
let decision = await ExecApprovalsSocketClient.requestDecision(
socketPath: approvals.socketPath,
token: approvals.token,
request: ExecApprovalPromptRequest(
command: ExecCommandFormatter.displayString(for: command),
message: "SYSTEM_RUN_DISABLED: policy=never")
case .always:
break
case .ask:
if shouldPrompt {
let services = await self.mainActorServices()
let decision = await services.confirmSystemRun(context: SystemRunPromptContext(
command: SystemRunAllowlist.displayString(for: command),
cwd: params.cwd,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: agentId,
resolvedPath: resolution?.resolvedPath))
switch decision {
case .deny?:
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command),
reason: "user-denied"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied")
case nil:
if askFallback == .deny || (askFallback == .allowlist && allowlistMatch == nil && !skillAllow) {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command),
reason: "approval-required"))
executablePath: resolution?.resolvedPath))
switch decision {
case .allowOnce:
break
case .allowAlways:
if let resolvedPath = resolution?.resolvedPath, !resolvedPath.isEmpty {
_ = SystemRunAllowlistStore.add(pattern: resolvedPath, agentId: agentId)
} else if let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines),
!raw.isEmpty
{
_ = SystemRunAllowlistStore.add(pattern: raw, agentId: agentId)
}
case .deny:
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval required")
message: "SYSTEM_RUN_DENIED: user denied")
}
case .allowAlways?:
if security == .allowlist {
let pattern = resolution?.resolvedPath ??
resolution?.rawExecutable ??
command.first?.trimmingCharacters(in: .whitespacesAndNewlines) ??
""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
}
}
case .allowOnce?:
break
}
}
if let match = allowlistMatch {
ExecApprovalsStore.recordAllowlistUse(
agentId: agentId,
pattern: match.pattern,
command: ExecCommandFormatter.displayString(for: command),
resolvedPath: resolution?.resolvedPath)
SystemRunAllowlistStore.markUsed(
entryId: match.id,
command: command,
resolvedPath: resolution?.resolvedPath,
agentId: agentId)
}
let env = Self.sanitizedEnv(params.env)
@@ -548,14 +503,6 @@ actor MacNodeRuntime {
let authorized = await PermissionManager
.status([.screenRecording])[.screenRecording] ?? false
if !authorized {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command),
reason: "permission:screenRecording"))
return Self.errorResponse(
req,
code: .unavailable,
@@ -564,30 +511,11 @@ actor MacNodeRuntime {
}
let timeoutSec = params.timeoutMs.flatMap { Double($0) / 1000.0 }
await self.emitExecEvent(
"exec.started",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command)))
let result = await ShellExecutor.runDetailed(
command: command,
cwd: params.cwd,
env: env,
timeout: timeoutSec)
let combined = [result.stdout, result.stderr, result.errorMessage].filter { !$0.isEmpty }.joined(separator: "\n")
await self.emitExecEvent(
"exec.finished",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: ExecCommandFormatter.displayString(for: command),
exitCode: result.exitCode,
timedOut: result.timedOut,
success: result.success,
output: ExecEventPayload.truncateOutput(combined)))
struct RunPayload: Encodable {
var exitCode: Int?
@@ -635,16 +563,6 @@ actor MacNodeRuntime {
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
}
private func emitExecEvent(_ event: String, payload: ExecEventPayload) async {
guard let sender = self.eventSender else { return }
guard let data = try? JSONEncoder().encode(payload),
let json = String(data: data, encoding: .utf8)
else {
return
}
await sender(event, json)
}
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -711,6 +629,10 @@ actor MacNodeRuntime {
UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false
}
private nonisolated static func systemRunPolicy() -> SystemRunPolicy {
SystemRunPolicy.load()
}
private static let blockedEnvKeys: Set<String> = [
"PATH",
"NODE_OPTIONS",
@@ -1,7 +1,21 @@
import AppKit
import ClawdbotKit
import CoreLocation
import Foundation
enum SystemRunDecision: Sendable {
case allowOnce
case allowAlways
case deny
}
struct SystemRunPromptContext: Sendable {
let command: String
let cwd: String?
let agentId: String?
let executablePath: String?
}
@MainActor
protocol MacNodeRuntimeMainActorServices: Sendable {
func recordScreen(
@@ -17,6 +31,8 @@ protocol MacNodeRuntimeMainActorServices: Sendable {
desiredAccuracy: ClawdbotLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision
}
@MainActor
@@ -58,4 +74,38 @@ final class LiveMacNodeRuntimeMainActorServices: MacNodeRuntimeMainActorServices
timeoutMs: timeoutMs)
}
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
let alert = NSAlert()
alert.alertStyle = .warning
alert.messageText = "Allow this command?"
var details = "Clawdbot wants to run:\n\n\(context.command)"
let trimmedCwd = context.cwd?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedCwd.isEmpty {
details += "\n\nWorking directory:\n\(trimmedCwd)"
}
let trimmedAgent = context.agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedAgent.isEmpty {
details += "\n\nAgent:\n\(trimmedAgent)"
}
let trimmedPath = context.executablePath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !trimmedPath.isEmpty {
details += "\n\nExecutable:\n\(trimmedPath)"
}
details += "\n\nThis runs on this Mac via node mode."
alert.informativeText = details
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Always Allow")
alert.addButton(withTitle: "Don't Allow")
switch alert.runModal() {
case .alertFirstButtonReturn:
return .allowOnce
case .alertSecondButtonReturn:
return .allowAlways
default:
return .deny
}
}
}
@@ -0,0 +1,267 @@
import Foundation
enum SystemRunAllowlistMatchKind: String {
case glob
case argv
}
enum SystemRunAllowlistSource: String {
case manual
case skill
}
struct SystemRunAllowlistEntry: Identifiable, Hashable {
let id: String
var pattern: String
var enabled: Bool
var matchKind: SystemRunAllowlistMatchKind
var source: SystemRunAllowlistSource?
var skillId: String?
var lastUsedAt: Date?
var lastUsedCommand: String?
var lastUsedPath: String?
init(
id: String = UUID().uuidString,
pattern: String,
enabled: Bool = true,
matchKind: SystemRunAllowlistMatchKind = .glob,
source: SystemRunAllowlistSource? = .manual,
skillId: String? = nil,
lastUsedAt: Date? = nil,
lastUsedCommand: String? = nil,
lastUsedPath: String? = nil)
{
self.id = id
self.pattern = pattern
self.enabled = enabled
self.matchKind = matchKind
self.source = source
self.skillId = skillId
self.lastUsedAt = lastUsedAt
self.lastUsedCommand = lastUsedCommand
self.lastUsedPath = lastUsedPath
}
init?(dict: [String: Any]) {
let id = dict["id"] as? String ?? UUID().uuidString
let pattern = (dict["pattern"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if pattern.isEmpty { return nil }
let enabled = dict["enabled"] as? Bool ?? true
let matchRaw = dict["matchKind"] as? String
let matchKind = SystemRunAllowlistMatchKind(rawValue: matchRaw ?? "") ?? .glob
let sourceRaw = dict["source"] as? String
let source = SystemRunAllowlistSource(rawValue: sourceRaw ?? "")
let skillId = (dict["skillId"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
let lastUsedAt = (dict["lastUsedAt"] as? Double).map { Date(timeIntervalSince1970: $0) }
let lastUsedCommand = dict["lastUsedCommand"] as? String
let lastUsedPath = dict["lastUsedPath"] as? String
self.init(
id: id,
pattern: pattern,
enabled: enabled,
matchKind: matchKind,
source: source,
skillId: skillId?.isEmpty == true ? nil : skillId,
lastUsedAt: lastUsedAt,
lastUsedCommand: lastUsedCommand,
lastUsedPath: lastUsedPath)
}
func asDict() -> [String: Any] {
var dict: [String: Any] = [
"id": self.id,
"pattern": self.pattern,
"enabled": self.enabled,
"matchKind": self.matchKind.rawValue,
]
if let source = self.source { dict["source"] = source.rawValue }
if let skillId = self.skillId { dict["skillId"] = skillId }
if let lastUsedAt = self.lastUsedAt { dict["lastUsedAt"] = lastUsedAt.timeIntervalSince1970 }
if let lastUsedCommand = self.lastUsedCommand { dict["lastUsedCommand"] = lastUsedCommand }
if let lastUsedPath = self.lastUsedPath { dict["lastUsedPath"] = lastUsedPath }
return dict
}
}
struct SystemRunCommandResolution: Sendable {
let rawExecutable: String
let resolvedPath: String?
let executableName: String
let cwd: String?
static func resolve(command: [String], cwd: String?) -> SystemRunCommandResolution? {
guard let raw = command.first?.trimmingCharacters(in: .whitespacesAndNewlines), !raw.isEmpty else {
return nil
}
let expanded = raw.hasPrefix("~") ? (raw as NSString).expandingTildeInPath : raw
let hasPathSeparator = expanded.contains("/")
let resolvedPath: String? = {
if hasPathSeparator {
if expanded.hasPrefix("/") {
return expanded
}
let base = cwd?.trimmingCharacters(in: .whitespacesAndNewlines)
let root = (base?.isEmpty == false) ? base! : FileManager.default.currentDirectoryPath
return URL(fileURLWithPath: root).appendingPathComponent(expanded).path
}
return CommandResolver.findExecutable(named: expanded)
}()
let name = resolvedPath.map { URL(fileURLWithPath: $0).lastPathComponent } ?? expanded
return SystemRunCommandResolution(rawExecutable: expanded, resolvedPath: resolvedPath, executableName: name, cwd: cwd)
}
}
enum SystemRunAllowlistStore {
static func load(agentId: String?) -> [SystemRunAllowlistEntry] {
if let entries = MacNodeConfigFile.systemRunAllowlist(agentId: agentId) {
return entries
}
return []
}
static func save(_ entries: [SystemRunAllowlistEntry], agentId: String?) {
MacNodeConfigFile.setSystemRunAllowlist(entries, agentId: agentId)
}
static func add(pattern: String, agentId: String?, source: SystemRunAllowlistSource = .manual) -> SystemRunAllowlistEntry {
var entries = self.load(agentId: agentId)
let entry = SystemRunAllowlistEntry(pattern: pattern, enabled: true, matchKind: .glob, source: source)
entries.append(entry)
self.save(entries, agentId: agentId)
return entry
}
static func update(_ entry: SystemRunAllowlistEntry, agentId: String?) {
var entries = self.load(agentId: agentId)
guard let index = entries.firstIndex(where: { $0.id == entry.id }) else { return }
entries[index] = entry
self.save(entries, agentId: agentId)
}
static func remove(entryId: String, agentId: String?) {
let entries = self.load(agentId: agentId).filter { $0.id != entryId }
self.save(entries, agentId: agentId)
}
static func markUsed(entryId: String, command: [String], resolvedPath: String?, agentId: String?) {
var entries = self.load(agentId: agentId)
guard let index = entries.firstIndex(where: { $0.id == entryId }) else { return }
entries[index].lastUsedAt = Date()
entries[index].lastUsedCommand = SystemRunAllowlist.displayString(for: command)
entries[index].lastUsedPath = resolvedPath
self.save(entries, agentId: agentId)
}
static func match(
command: [String],
resolution: SystemRunCommandResolution?,
entries: [SystemRunAllowlistEntry]) -> SystemRunAllowlistEntry?
{
guard !entries.isEmpty else { return nil }
let argvKey = SystemRunAllowlist.legacyKey(for: command)
let resolvedPath = resolution?.resolvedPath
let executableName = resolution?.executableName
let rawExecutable = resolution?.rawExecutable
for entry in entries {
guard entry.enabled else { continue }
switch entry.matchKind {
case .argv:
if argvKey == entry.pattern { return entry }
case .glob:
let pattern = entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines)
if pattern.isEmpty { continue }
let hasPath = pattern.contains("/") || pattern.contains("~")
if hasPath {
let target = resolvedPath ?? rawExecutable
if let target, SystemRunGlob.matches(pattern: pattern, target: target) {
return entry
}
} else if let name = executableName, SystemRunGlob.matches(pattern: pattern, target: name) {
return entry
}
}
}
return nil
}
}
enum SystemRunGlob {
static func matches(pattern rawPattern: String, target: String) -> Bool {
let trimmed = rawPattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
let expanded = trimmed.hasPrefix("~") ? (trimmed as NSString).expandingTildeInPath : trimmed
guard let regex = self.regex(for: expanded) else { return false }
let range = NSRange(location: 0, length: target.utf16.count)
return regex.firstMatch(in: target, options: [], range: range) != nil
}
private static func regex(for pattern: String) -> NSRegularExpression? {
var regex = "^"
var idx = pattern.startIndex
while idx < pattern.endIndex {
let ch = pattern[idx]
if ch == "*" {
let next = pattern.index(after: idx)
if next < pattern.endIndex, pattern[next] == "*" {
regex += ".*"
idx = pattern.index(after: next)
} else {
regex += "[^/]*"
idx = next
}
continue
}
if ch == "?" {
regex += "."
idx = pattern.index(after: idx)
continue
}
regex += NSRegularExpression.escapedPattern(for: String(ch))
idx = pattern.index(after: idx)
}
regex += "$"
return try? NSRegularExpression(pattern: regex)
}
}
actor SkillBinsCache {
static let shared = SkillBinsCache()
private var bins: Set<String> = []
private var lastRefresh: Date?
private let refreshInterval: TimeInterval = 90
func currentBins(force: Bool = false) async -> Set<String> {
if force || self.isStale() {
await self.refresh()
}
return self.bins
}
func refresh() async {
do {
let report = try await GatewayConnection.shared.skillsStatus()
var next = Set<String>()
for skill in report.skills {
for bin in skill.requirements.bins {
let trimmed = bin.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { next.insert(trimmed) }
}
}
self.bins = next
self.lastRefresh = Date()
} catch {
if self.lastRefresh == nil {
self.bins = []
}
}
}
private func isStale() -> Bool {
guard let lastRefresh else { return true }
return Date().timeIntervalSince(lastRefresh) > self.refreshInterval
}
}
@@ -0,0 +1,78 @@
import Foundation
enum SystemRunPolicy: String, CaseIterable, Identifiable {
case never
case ask
case always
var id: String { self.rawValue }
var title: String {
switch self {
case .never:
"Never"
case .ask:
"Always Ask"
case .always:
"Always Allow"
}
}
static func load(agentId: String? = nil, from defaults: UserDefaults = .standard) -> SystemRunPolicy {
if let policy = MacNodeConfigFile.systemRunPolicy(agentId: agentId) {
return policy
}
if let policy = MacNodeConfigFile.systemRunPolicy() {
return policy
}
if let raw = defaults.string(forKey: systemRunPolicyKey),
let policy = SystemRunPolicy(rawValue: raw)
{
MacNodeConfigFile.setSystemRunPolicy(policy)
return policy
}
if let legacy = defaults.object(forKey: systemRunEnabledKey) as? Bool {
let policy: SystemRunPolicy = legacy ? .ask : .never
MacNodeConfigFile.setSystemRunPolicy(policy)
return policy
}
let fallback: SystemRunPolicy = .ask
MacNodeConfigFile.setSystemRunPolicy(fallback)
return fallback
}
}
enum SystemRunAllowlist {
static func legacyKey(for argv: [String]) -> String {
let trimmed = argv.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
guard !trimmed.isEmpty else { return "" }
if let data = try? JSONEncoder().encode(trimmed),
let json = String(data: data, encoding: .utf8)
{
return json
}
return trimmed.joined(separator: " ")
}
static func displayString(for argv: [String]) -> String {
argv.map { arg in
let trimmed = arg.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "\"\"" }
let needsQuotes = trimmed.contains { $0.isWhitespace || $0 == "\"" }
if !needsQuotes { return trimmed }
let escaped = trimmed.replacingOccurrences(of: "\"", with: "\\\"")
return "\"\(escaped)\""
}.joined(separator: " ")
}
static func loadLegacy(from defaults: UserDefaults = .standard) -> Set<String> {
if let allowlist = MacNodeConfigFile.systemRunAllowlistStrings() {
return Set(allowlist)
}
if let legacy = defaults.stringArray(forKey: systemRunAllowlistKey), !legacy.isEmpty {
MacNodeConfigFile.setSystemRunAllowlistStrings(legacy)
return Set(legacy)
}
return []
}
}
@@ -3,14 +3,14 @@ import Observation
import SwiftUI
struct SystemRunSettingsView: View {
@State private var model = ExecApprovalsSettingsModel()
@State private var tab: ExecApprovalsSettingsTab = .policy
@State private var model = SystemRunSettingsModel()
@State private var tab: SystemRunSettingsTab = .policy
@State private var newPattern: String = ""
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .center, spacing: 12) {
Text("Exec approvals")
Text("Node Run Commands")
.font(.body)
Spacer(minLength: 0)
if self.model.agentIds.count > 1 {
@@ -28,12 +28,12 @@ struct SystemRunSettingsView: View {
}
Picker("", selection: self.$tab) {
ForEach(ExecApprovalsSettingsTab.allCases) { tab in
ForEach(SystemRunSettingsTab.allCases) { tab in
Text(tab.title).tag(tab)
}
}
.pickerStyle(.segmented)
.frame(width: 320)
.frame(width: 280)
if self.tab == .policy {
self.policyView
@@ -48,41 +48,19 @@ struct SystemRunSettingsView: View {
}
private var policyView: some View {
VStack(alignment: .leading, spacing: 8) {
VStack(alignment: .leading, spacing: 6) {
Picker("", selection: Binding(
get: { self.model.security },
set: { self.model.setSecurity($0) }))
get: { self.model.policy },
set: { self.model.setPolicy($0) }))
{
ForEach(ExecSecurity.allCases) { security in
Text(security.title).tag(security)
ForEach(SystemRunPolicy.allCases) { policy in
Text(policy.title).tag(policy)
}
}
.labelsHidden()
.pickerStyle(.menu)
Picker("", selection: Binding(
get: { self.model.ask },
set: { self.model.setAsk($0) }))
{
ForEach(ExecAsk.allCases) { ask in
Text(ask.title).tag(ask)
}
}
.labelsHidden()
.pickerStyle(.menu)
Picker("", selection: Binding(
get: { self.model.askFallback },
set: { self.model.setAskFallback($0) }))
{
ForEach(ExecSecurity.allCases) { mode in
Text("Fallback: \(mode.title)").tag(mode)
}
}
.labelsHidden()
.pickerStyle(.menu)
Text("Security controls whether system.run can execute on this Mac when paired as a node. Ask controls prompt behavior; fallback is used when no companion UI is reachable.")
Text("Controls remote command execution on this Mac when it is paired as a node. \"Always Ask\" prompts on each command; \"Always Allow\" runs without prompts; \"Never\" disables system.run.")
.font(.footnote)
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
@@ -102,7 +80,7 @@ struct SystemRunSettingsView: View {
}
HStack(spacing: 8) {
TextField("Add allowlist pattern (case-insensitive globs)", text: self.$newPattern)
TextField("Add allowlist pattern (supports globs)", text: self.$newPattern)
.textFieldStyle(.roundedBorder)
Button("Add") {
let pattern = self.newPattern.trimmingCharacters(in: .whitespacesAndNewlines)
@@ -120,12 +98,12 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ExecAllowlistRow(
ForEach(Array(self.model.entries.enumerated()), id: \.element.id) { index, _ in
SystemRunAllowlistRow(
entry: Binding(
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
set: { self.model.updateEntry($0) }),
onRemove: { self.model.removeEntry($0.id) })
}
}
}
@@ -133,7 +111,7 @@ struct SystemRunSettingsView: View {
}
}
private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable {
private enum SystemRunSettingsTab: String, CaseIterable, Identifiable {
case policy
case allowlist
@@ -141,15 +119,15 @@ private enum ExecApprovalsSettingsTab: String, CaseIterable, Identifiable {
var title: String {
switch self {
case .policy: "Access"
case .policy: "Policy"
case .allowlist: "Allowlist"
}
}
}
struct ExecAllowlistRow: View {
@Binding var entry: ExecAllowlistEntry
let onRemove: () -> Void
struct SystemRunAllowlistRow: View {
@Binding var entry: SystemRunAllowlistEntry
let onRemove: (SystemRunAllowlistEntry) -> Void
@State private var draftPattern: String = ""
private static let relativeFormatter: RelativeDateTimeFormatter = {
@@ -161,11 +139,20 @@ struct ExecAllowlistRow: View {
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 8) {
Toggle("", isOn: self.$entry.enabled)
.labelsHidden()
TextField("Pattern", text: self.patternBinding)
.textFieldStyle(.roundedBorder)
if self.entry.matchKind == .argv {
Text("Legacy")
.font(.caption)
.foregroundStyle(.secondary)
}
Button(role: .destructive) {
self.onRemove()
self.onRemove(self.entry)
} label: {
Image(systemName: "trash")
}
@@ -173,8 +160,7 @@ struct ExecAllowlistRow: View {
}
if let lastUsedAt = self.entry.lastUsedAt {
let date = Date(timeIntervalSince1970: lastUsedAt / 1000.0)
Text("Last used \(Self.relativeFormatter.localizedString(for: date, relativeTo: Date()))")
Text("Last used \(Self.relativeFormatter.localizedString(for: lastUsedAt, relativeTo: Date()))")
.font(.caption)
.foregroundStyle(.secondary)
} else if let lastUsedCommand = self.entry.lastUsedCommand, !lastUsedCommand.isEmpty {
@@ -194,21 +180,22 @@ struct ExecAllowlistRow: View {
set: { newValue in
self.draftPattern = newValue
self.entry.pattern = newValue
if self.entry.matchKind == .argv {
self.entry.matchKind = .glob
}
})
}
}
@MainActor
@Observable
final class ExecApprovalsSettingsModel {
final class SystemRunSettingsModel {
var agentIds: [String] = []
var selectedAgentId: String = "main"
var defaultAgentId: String = "main"
var security: ExecSecurity = .deny
var ask: ExecAsk = .onMiss
var askFallback: ExecSecurity = .deny
var policy: SystemRunPolicy = .ask
var autoAllowSkills = false
var entries: [ExecAllowlistEntry] = []
var entries: [SystemRunAllowlistEntry] = []
var skillBins: [String] = []
func refresh() async {
@@ -254,63 +241,43 @@ final class ExecApprovalsSettingsModel {
}
func loadSettings(for agentId: String) {
let resolved = ExecApprovalsStore.resolve(agentId: agentId)
self.security = resolved.agent.security
self.ask = resolved.agent.ask
self.askFallback = resolved.agent.askFallback
self.autoAllowSkills = resolved.agent.autoAllowSkills
self.entries = resolved.allowlist
self.policy = SystemRunPolicy.load(agentId: agentId)
self.autoAllowSkills = MacNodeConfigFile.systemRunAutoAllowSkills(agentId: agentId) ?? false
self.entries = SystemRunAllowlistStore.load(agentId: agentId)
.sorted { $0.pattern.localizedCaseInsensitiveCompare($1.pattern) == .orderedAscending }
}
func setSecurity(_ security: ExecSecurity) {
self.security = security
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.security = security
}
self.syncQuickMode()
}
func setAsk(_ ask: ExecAsk) {
self.ask = ask
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.ask = ask
}
self.syncQuickMode()
}
func setAskFallback(_ mode: ExecSecurity) {
self.askFallback = mode
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.askFallback = mode
func setPolicy(_ policy: SystemRunPolicy) {
self.policy = policy
MacNodeConfigFile.setSystemRunPolicy(policy, agentId: self.selectedAgentId)
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
AppStateStore.shared.systemRunPolicy = policy
}
}
func setAutoAllowSkills(_ enabled: Bool) {
self.autoAllowSkills = enabled
ExecApprovalsStore.updateAgentSettings(agentId: self.selectedAgentId) { entry in
entry.autoAllowSkills = enabled
}
MacNodeConfigFile.setSystemRunAutoAllowSkills(enabled, agentId: self.selectedAgentId)
Task { await self.refreshSkillBins(force: enabled) }
}
func addEntry(_ pattern: String) {
let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return }
self.entries.append(ExecAllowlistEntry(pattern: trimmed, lastUsedAt: nil))
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
let entry = SystemRunAllowlistEntry(pattern: trimmed, enabled: true, matchKind: .glob, source: .manual)
self.entries.append(entry)
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
}
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard self.entries.indices.contains(index) else { return }
func updateEntry(_ entry: SystemRunAllowlistEntry) {
guard let index = self.entries.firstIndex(where: { $0.id == entry.id }) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
}
func removeEntry(at index: Int) {
guard self.entries.indices.contains(index) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
func removeEntry(_ id: String) {
self.entries.removeAll { $0.id == id }
SystemRunAllowlistStore.save(self.entries, agentId: self.selectedAgentId)
}
func refreshSkillBins(force: Bool = false) async {
@@ -321,10 +288,4 @@ final class ExecApprovalsSettingsModel {
let bins = await SkillBinsCache.shared.currentBins(force: force)
self.skillBins = bins.sorted()
}
private func syncQuickMode() {
if self.selectedAgentId == self.defaultAgentId || self.agentIds.count <= 1 {
AppStateStore.shared.execApprovalMode = ExecApprovalQuickMode.from(security: self.security, ask: self.ask)
}
}
}
@@ -1,49 +0,0 @@
import Foundation
import Testing
@testable import Clawdbot
struct ExecAllowlistTests {
@Test func matchUsesResolvedPath() {
let entry = ExecAllowlistEntry(pattern: "/opt/homebrew/bin/rg")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchUsesBasenameForSimplePattern() {
let entry = ExecAllowlistEntry(pattern: "rg")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchIsCaseInsensitive() {
let entry = ExecAllowlistEntry(pattern: "RG")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
@Test func matchSupportsGlobStar() {
let entry = ExecAllowlistEntry(pattern: "/opt/**/rg")
let resolution = ExecCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = ExecAllowlistMatcher.match(entries: [entry], resolution: resolution)
#expect(match?.pattern == entry.pattern)
}
}
@@ -74,6 +74,10 @@ struct MacNodeRuntimeTests {
{
CLLocation(latitude: 0, longitude: 0)
}
func confirmSystemRun(context: SystemRunPromptContext) async -> SystemRunDecision {
.allowOnce
}
}
let services = await MainActor.run { FakeMainActorServices() }
@@ -0,0 +1,43 @@
import Foundation
import Testing
@testable import Clawdbot
struct SystemRunAllowlistTests {
@Test func matchUsesResolvedPath() {
let entry = SystemRunAllowlistEntry(pattern: "/opt/homebrew/bin/rg", enabled: true, matchKind: .glob)
let resolution = SystemRunCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = SystemRunAllowlistStore.match(
command: ["rg"],
resolution: resolution,
entries: [entry])
#expect(match?.id == entry.id)
}
@Test func matchUsesBasenameForSimplePattern() {
let entry = SystemRunAllowlistEntry(pattern: "rg", enabled: true, matchKind: .glob)
let resolution = SystemRunCommandResolution(
rawExecutable: "rg",
resolvedPath: "/opt/homebrew/bin/rg",
executableName: "rg",
cwd: nil)
let match = SystemRunAllowlistStore.match(
command: ["rg"],
resolution: resolution,
entries: [entry])
#expect(match?.id == entry.id)
}
@Test func matchUsesLegacyArgvKey() {
let key = SystemRunAllowlist.legacyKey(for: ["echo", "hi"])
let entry = SystemRunAllowlistEntry(pattern: key, enabled: true, matchKind: .argv)
let match = SystemRunAllowlistStore.match(
command: ["echo", "hi"],
resolution: nil,
entries: [entry])
#expect(match?.id == entry.id)
}
}
@@ -25,7 +25,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var timeoutMs: Int?
public var needsScreenRecording: Bool?
public var agentId: String?
public var sessionKey: String?
public init(
command: [String],
@@ -33,8 +32,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
env: [String: String]? = nil,
timeoutMs: Int? = nil,
needsScreenRecording: Bool? = nil,
agentId: String? = nil,
sessionKey: String? = nil)
agentId: String? = nil)
{
self.command = command
self.cwd = cwd
@@ -42,7 +40,6 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
self.timeoutMs = timeoutMs
self.needsScreenRecording = needsScreenRecording
self.agentId = agentId
self.sessionKey = sessionKey
}
}
-194
View File
@@ -1,194 +0,0 @@
# Clawdbot ACP Bridge
This document describes how the Clawdbot ACP (Agent Client Protocol) bridge works,
how it maps ACP sessions to Gateway sessions, and how IDEs should invoke it.
## Overview
`clawdbot acp` exposes an ACP agent over stdio and forwards prompts to a running
Clawdbot Gateway over WebSocket. It keeps ACP session ids mapped to Gateway
session keys so IDEs can reconnect to the same agent transcript or reset it on
request.
Key goals:
- Minimal ACP surface area (stdio, NDJSON).
- Stable session mapping across reconnects.
- Works with existing Gateway session store (list/resolve/reset).
- Safe defaults (isolated ACP session keys by default).
## How can I use this
Use ACP when an IDE or tooling speaks Agent Client Protocol and you want it to
drive a Clawdbot Gateway session.
Quick steps:
1. Run a Gateway (local or remote).
2. Configure the Gateway target (`gateway.remote.url` + auth) or pass flags.
3. Point the IDE to run `clawdbot acp` over stdio.
Example config:
```bash
clawdbot config set gateway.remote.url wss://gateway-host:18789
clawdbot config set gateway.remote.token <token>
```
Example run:
```bash
clawdbot acp --url wss://gateway-host:18789 --token <token>
```
## Selecting agents
ACP does not pick agents directly. It routes by the Gateway session key.
Use agent-scoped session keys to target a specific agent:
```bash
clawdbot acp --session agent:main:main
clawdbot acp --session agent:design:main
clawdbot acp --session agent:qa:bug-123
```
Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
## Zed editor setup
Add a custom ACP agent in `~/.config/zed/settings.json`:
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": ["acp"],
"env": {}
}
}
}
```
To target a specific Gateway or agent:
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": [
"acp",
"--url", "wss://gateway-host:18789",
"--token", "<token>",
"--session", "agent:design:main"
],
"env": {}
}
}
}
```
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
## Execution Model
- ACP client spawns `clawdbot acp` and speaks ACP messages over stdio.
- The bridge connects to the Gateway using existing auth config (or CLI flags).
- ACP `prompt` translates to Gateway `chat.send`.
- Gateway streaming events are translated back into ACP streaming events.
- ACP `cancel` maps to Gateway `chat.abort` for the active run.
## Session Mapping
By default each ACP session is mapped to a dedicated Gateway session key:
- `acp:<uuid>` unless overridden.
You can override or reuse sessions in two ways:
1) CLI defaults
```bash
clawdbot acp --session agent:main:main
clawdbot acp --session-label "support inbox"
clawdbot acp --reset-session
```
2) ACP metadata per session
```json
{
"_meta": {
"sessionKey": "agent:main:main",
"sessionLabel": "support inbox",
"resetSession": true,
"requireExisting": false
}
}
```
Rules:
- `sessionKey`: direct Gateway session key.
- `sessionLabel`: resolve an existing session by label.
- `resetSession`: mint a new transcript for the key before first use.
- `requireExisting`: fail if the key/label does not exist.
### Session Listing
ACP `listSessions` maps to Gateway `sessions.list` and returns a filtered
summary suitable for IDE session pickers. `_meta.limit` can cap the number of
sessions returned.
## Prompt Translation
ACP prompt inputs are converted into a Gateway `chat.send`:
- `text` and `resource` blocks become prompt text.
- `resource_link` with image mime types become attachments.
- The working directory can be prefixed into the prompt (default on, can be
disabled with `--no-prefix-cwd`).
Gateway streaming events are translated into ACP `message` and `tool_call`
updates. Terminal Gateway states map to ACP `done` with stop reasons:
- `complete` -> `stop`
- `aborted` -> `cancel`
- `error` -> `error`
## Auth + Gateway Discovery
`clawdbot acp` resolves the Gateway URL and auth from CLI flags or config:
- `--url` / `--token` / `--password` take precedence.
- Otherwise use configured `gateway.remote.*` settings.
## Operational Notes
- ACP sessions are stored in memory for the bridge process lifetime.
- Gateway session state is persisted by the Gateway itself.
- `--verbose` logs ACP/Gateway bridge events to stderr (never stdout).
- ACP runs can be canceled and the active run id is tracked per session.
## Compatibility
- ACP bridge uses `@agentclientprotocol/sdk` (currently 0.13.x).
- Works with ACP clients that implement `initialize`, `newSession`,
`loadSession`, `prompt`, `cancel`, and `listSessions`.
## Testing
- Unit: `src/acp/session.test.ts` covers run id lifecycle.
- Full gate: `pnpm lint && pnpm build && pnpm test && pnpm docs:build`.
## Related Docs
- CLI usage: `docs/cli/acp.md`
- Session model: `docs/concepts/session.md`
- Session management internals: `docs/reference/session-management-compaction.md`
-64
View File
@@ -1,64 +0,0 @@
---
summary: "iMessage via BlueBubbles macOS server (REST send/receive, typing, reactions, pairing)."
read_when:
- Setting up BlueBubbles channel
- Troubleshooting webhook pairing
---
# BlueBubbles (macOS REST)
Status: bundled plugin (disabled by default) that talks to the BlueBubbles macOS server over HTTP.
## Overview
- Runs on macOS via the BlueBubbles helper app (`https://bluebubbles.app`).
- Clawdbot talks to it through its REST API (`GET /api/v1/ping`, `POST /message/text`, `POST /chat/:id/*`).
- Incoming messages arrive via webhooks; outgoing replies, typing indicators, read receipts, and tapbacks are REST calls.
- Attachments and stickers are ingested as inbound media (and surfaced to the agent when possible).
- Pairing/allowlist works the same way as other channels (`/start/pairing` etc) with `channels.bluebubbles.allowFrom` + pairing codes.
- Reactions are surfaced as system events just like Slack/Telegram so agents can “mention” them before replying.
## Quick start
1. Install the BlueBubbles server on your Mac (follows the app store instructions at `https://bluebubbles.app/install`).
2. In the BlueBubbles config, enable the web API and set a password for `guid`/`password`.
3. Configure Clawdbot:
```json5
{
channels: {
bluebubbles: {
enabled: true,
serverUrl: "http://bluebubbles-host:1234",
password: "example-password",
webhookPath: "/bluebubbles-webhook",
actions: { reactions: true }
}
}
}
```
4. Point BlueBubbles webhooks to your gateway (example: `http://your-gateway-host/bluebubbles-webhook?password=<password>`).
5. Start the gateway; it will register the webhook handler and start pairing.
## Configuration notes
- `channels.bluebubbles.serverUrl`: base URL of the BlueBubbles REST API.
- `channels.bluebubbles.password`: password that BlueBubbles expects on every request (`?password=...` or header).
- `channels.bluebubbles.webhookPath`: HTTP path the gateway exposes for BlueBubbles webhooks.
- `channels.bluebubbles.dmPolicy` / `groupPolicy` + `allowFrom`/`groupAllowFrom` behave like other channels; pairing/allowlist info is stored in `/pairing`.
- `channels.bluebubbles.actions.reactions` toggles whether the gateway enqueues system events for reactions/tapbacks.
- `channels.bluebubbles.textChunkLimit` overrides the default 4k limit.
- `channels.bluebubbles.mediaMaxMb` controls the max size of inbound attachments saved for analysis (default 8MB).
## How it works
- Outbound replies: `sendMessageBlueBubbles` resolves a chat GUID via `/api/v1/chat/query` and posts to `/api/v1/message/text`. Typing (`/api/v1/chat/<guid>/typing`) and read receipts (`/api/v1/chat/<guid>/read`) are sent before/after responses.
- Webhooks: BlueBubbles POSTs JSON payloads with `type` and `data`. The plugin ignores non-message events (typing indicator, read status) and extracts `chatGuid` from `data.chats[0].guid`.
- Reactions/tapbacks generate `BlueBubbles reaction added/removed` system events so agents can mention them. Agents can also trigger tapbacks via the `react` action with `messageId`, `emoji`, and a `to`/`chatGuid`.
- Attachments are downloaded via the REST API and stored in the inbound media cache; text-less messages are converted into `<media:...>` placeholders so the agent knows something was sent.
## Security
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
- Keep the API password and webhook endpoint secret (treat them like credentials).
- Enable HTTPS + firewall rules on the BlueBubbles server if exposing it outside your LAN.
## Troubleshooting
- If Voice/typing events stop working, check the BlueBubbles webhook logs and verify the gateway path matches `channels.bluebubbles.webhookPath`.
- Pairing codes expire after one hour; use `clawdbot pairing list bluebubbles` and `clawdbot pairing approve bluebubbles <code>`.
- Reactions require the BlueBubbles private API (`POST /api/v1/message/react`); ensure the server version exposes it.
For general channel workflow reference, see [/channels/index] and the [[plugins|/plugin]] guide.
-1
View File
@@ -17,7 +17,6 @@ Text is supported everywhere; media and reactions vary by channel.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [iMessage](/channels/imessage) — macOS only; native integration.
- [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default).
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately).
- [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately).
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately).
-143
View File
@@ -1,143 +0,0 @@
---
summary: "Run the ACP bridge for IDE integrations"
read_when:
- Setting up ACP-based IDE integrations
- Debugging ACP session routing to the Gateway
---
# acp
Run the ACP (Agent Client Protocol) bridge that talks to a Clawdbot Gateway.
This command speaks ACP over stdio for IDEs and forwards prompts to the Gateway
over WebSocket. It keeps ACP sessions mapped to Gateway session keys.
## Usage
```bash
clawdbot acp
# Remote Gateway
clawdbot acp --url wss://gateway-host:18789 --token <token>
# Attach to an existing session key
clawdbot acp --session agent:main:main
# Attach by label (must already exist)
clawdbot acp --session-label "support inbox"
# Reset the session key before the first prompt
clawdbot acp --session agent:main:main --reset-session
```
## How to use this
Use ACP when an IDE (or other client) speaks Agent Client Protocol and you want
it to drive a Clawdbot Gateway session.
1. Ensure the Gateway is running (local or remote).
2. Configure the Gateway target (config or flags).
3. Point your IDE to run `clawdbot acp` over stdio.
Example config (persisted):
```bash
clawdbot config set gateway.remote.url wss://gateway-host:18789
clawdbot config set gateway.remote.token <token>
```
Example direct run (no config write):
```bash
clawdbot acp --url wss://gateway-host:18789 --token <token>
```
## Selecting agents
ACP does not pick agents directly. It routes by the Gateway session key.
Use agent-scoped session keys to target a specific agent:
```bash
clawdbot acp --session agent:main:main
clawdbot acp --session agent:design:main
clawdbot acp --session agent:qa:bug-123
```
Each ACP session maps to a single Gateway session key. One agent can have many
sessions; ACP defaults to an isolated `acp:<uuid>` session unless you override
the key or label.
## Zed editor setup
Add a custom ACP agent in `~/.config/zed/settings.json` (or use Zed’s Settings UI):
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": ["acp"],
"env": {}
}
}
}
```
To target a specific Gateway or agent:
```json
{
"agent_servers": {
"Clawdbot ACP": {
"type": "custom",
"command": "clawdbot",
"args": [
"acp",
"--url", "wss://gateway-host:18789",
"--token", "<token>",
"--session", "agent:design:main"
],
"env": {}
}
}
}
```
In Zed, open the Agent panel and select “Clawdbot ACP” to start a thread.
## Session mapping
By default, ACP sessions get an isolated Gateway session key with an `acp:` prefix.
To reuse a known session, pass a session key or label:
- `--session <key>`: use a specific Gateway session key.
- `--session-label <label>`: resolve an existing session by label.
- `--reset-session`: mint a fresh session id for that key (same key, new transcript).
If your ACP client supports metadata, you can override per session:
```json
{
"_meta": {
"sessionKey": "agent:main:main",
"sessionLabel": "support inbox",
"resetSession": true
}
}
```
Learn more about session keys at [/concepts/session](/concepts/session).
## Options
- `--url <url>`: Gateway WebSocket URL (defaults to gateway.remote.url when configured).
- `--token <token>`: Gateway auth token.
- `--password <password>`: Gateway auth password.
- `--session <key>`: default session key.
- `--session-label <label>`: default session label to resolve.
- `--require-existing`: fail if the session key/label does not exist.
- `--reset-session`: reset the session key before first use.
- `--no-prefix-cwd`: do not prefix prompts with the working directory.
- `--verbose, -v`: verbose logging to stderr.
-16
View File
@@ -11,7 +11,6 @@ Manage agent hooks (event-driven automations for commands like `/new`, `/reset`,
Related:
- Hooks: [Hooks](/hooks)
- Plugin hooks: [Plugins](/plugin#plugin-hooks)
## List All Hooks
@@ -119,9 +118,6 @@ clawdbot hooks enable <name>
Enable a specific hook by adding it to your config (`~/.clawdbot/config.json`).
**Note:** Hooks managed by plugins show `plugin:<id>` in `clawdbot hooks list` and
can’t be enabled/disabled here. Enable/disable the plugin instead.
**Arguments:**
- `<name>`: Hook name (e.g., `session-memory`)
@@ -260,15 +256,3 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
```
**See:** [command-logger documentation](/hooks#command-logger)
### soul-evil
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
**Enable:**
```bash
clawdbot hooks enable soul-evil
```
**See:** [soul-evil documentation](/hooks#soul-evil)
+1 -8
View File
@@ -23,7 +23,6 @@ This page describes the current CLI behavior. If commands change, update this do
- [`message`](/cli/message)
- [`agent`](/cli/agent)
- [`agents`](/cli/agents)
- [`acp`](/cli/acp)
- [`status`](/cli/status)
- [`health`](/cli/health)
- [`sessions`](/cli/sessions)
@@ -126,7 +125,6 @@ clawdbot [--dev] [--profile <name>] <command>
list
add
delete
acp
status
health
sessions
@@ -508,11 +506,6 @@ Options:
- `--force`
- `--json`
### `acp`
Run the ACP bridge that connects IDEs to the Gateway.
See [`acp`](/cli/acp) for full options and examples.
### `status`
Show linked session health and recent recipients.
@@ -529,7 +522,7 @@ Options:
Clawdbot can surface provider usage/quota when OAuth/API creds are available.
Surfaces:
- `/status` (adds a short provider usage line when available)
- `/status` (alias: `/usage`; adds a short usage line when available)
- `clawdbot status --usage` (prints full provider breakdown)
- macOS menu bar (Usage section under Context)
+2 -1
View File
@@ -21,7 +21,7 @@ Context is *not the same thing* as “memory”: memory can be stored on disk an
- `/status` → quick “how full is my window?” view + session settings.
- `/context list` → what’s injected + rough sizes (per file + totals).
- `/context detail` → deeper breakdown: per-file, per-tool schema sizes, per-skill entry sizes, and system prompt size.
- `/usage tokens` → append per-reply usage footer to normal replies.
- `/cost on` → append per-reply usage line to normal replies.
- `/compact` → summarize older history into a compact entry to free window space.
See also: [Slash commands](/tools/slash-commands), [Token use & costs](/token-use), [Compaction](/concepts/compaction).
@@ -149,3 +149,4 @@ Docs: [Session](/concepts/session), [Compaction](/concepts/compaction), [Session
- `System prompt (estimate)` = computed on the fly when no run report exists (or when running via a CLI backend that doesn’t generate the report).
Either way, it reports sizes and top contributors; it does **not** dump the full system prompt or tool schemas.
-38
View File
@@ -172,44 +172,6 @@ When enabled, Clawdbot combines:
If full-text search is unavailable on your platform, Clawdbot falls back to vector-only search.
#### Why hybrid?
Vector search is great at “this means the same thing”:
- “Mac Studio gateway host” vs “the machine running the gateway”
- “debounce file updates” vs “avoid indexing on every write”
But it can be weak at exact, high-signal tokens:
- IDs (`a828e60`, `b3b9895a…`)
- code symbols (`memorySearch.query.hybrid`)
- error strings (“sqlite-vec unavailable”)
BM25 (full-text) is the opposite: strong at exact tokens, weaker at paraphrases.
Hybrid search is the pragmatic middle ground: **use both retrieval signals** so you get
good results for both “natural language” queries and “needle in a haystack” queries.
#### How we merge results (the current design)
Implementation sketch:
1) Retrieve a candidate pool from both sides:
- **Vector**: top `maxResults * candidateMultiplier` by cosine similarity.
- **BM25**: top `maxResults * candidateMultiplier` by FTS5 BM25 rank (lower is better).
2) Convert BM25 rank into a 0..1-ish score:
- `textScore = 1 / (1 + max(0, bm25Rank))`
3) Union candidates by chunk id and compute a weighted score:
- `finalScore = vectorWeight * vectorScore + textWeight * textScore`
Notes:
- `vectorWeight` + `textWeight` is normalized to 1.0 in config resolution, so weights behave as percentages.
- If embeddings are unavailable (or the provider returns a zero-vector), we still run BM25 and return keyword matches.
- If FTS5 can’t be created, we keep vector-only search (no hard failure).
This isn’t “IR-theory perfect”, but it’s simple, fast, and tends to improve recall/precision on real notes.
If we want to get fancier later, common next steps are Reciprocal Rank Fusion (RRF) or score normalization
(min/max or z-score) before mixing.
Config:
```json5
-7
View File
@@ -122,10 +122,3 @@ Each session entry records where it came from (best-effort) in `origin`:
- `from`/`to`: raw routing ids from the inbound envelope
- `accountId`: provider account id (when multi-account)
- `threadId`: thread/topic id when the channel supports it
The origin fields are populated for direct messages, channels, and groups. If a
connector only updates delivery routing (for example, to keep a DM main session
fresh), it should still provide inbound context so the session keeps its
explainer metadata. Extensions can do this by sending `ConversationLabel`,
`GroupSubject`, `GroupChannel`, `GroupSpace`, and `SenderName` in the inbound
context and calling `recordSessionMetaFromInbound` (or passing the same context
to `updateLastRoute`).
-3
View File
@@ -58,9 +58,6 @@ Large files are truncated with a marker. The max per-file size is controlled by
`agents.defaults.bootstrapMaxChars` (default: 20000). Missing files inject a
short missing-file marker.
Internal hooks can intercept this step via `agent:bootstrap` to mutate or replace
the injected bootstrap files (for example swapping `SOUL.md` for an alternate persona).
To inspect how much each injected file contributes (raw vs injected, truncation, plus tool schema overhead), use `/context list` or `/context detail`. See [Context](/concepts/context).
## Time handling
+1 -1
View File
@@ -12,7 +12,7 @@ read_when:
## Where it shows up
- `/status` in chats: emoji‑rich status card with session tokens + estimated cost (API key only). Provider usage shows for the **current model provider** when available.
- `/usage off|tokens|full` in chats: per-response usage footer (OAuth shows tokens only).
- `/cost on|off` in chats: toggles per‑response usage lines (OAuth shows tokens only).
- CLI: `clawdbot status --usage` prints a full per-provider breakdown.
- CLI: `clawdbot channels list` prints the same usage snapshot alongside provider config (use `--no-usage` to skip).
- macOS menu bar: “Usage” section under Context (only if available).
-42
View File
@@ -14,8 +14,6 @@ Hooks are small scripts that run when something happens. There are two kinds:
- **Hooks** (this page): run inside the Gateway when agent events fire, like `/new`, `/reset`, `/stop`, or lifecycle events.
- **Webhooks**: external HTTP webhooks that let other systems trigger work in Clawdbot. See [Webhook Hooks](/automation/webhook) or use `clawdbot webhooks` for Gmail helper commands.
Hooks can also be bundled inside plugins; see [Plugins](/plugin#plugin-hooks).
Common uses:
- Save a memory snapshot when you reset a session
@@ -205,8 +203,6 @@ Each event includes:
sessionFile?: string,
commandSource?: string, // e.g., 'whatsapp', 'telegram'
senderId?: string,
workspaceDir?: string,
bootstrapFiles?: WorkspaceBootstrapFile[],
cfg?: ClawdbotConfig
}
}
@@ -223,10 +219,6 @@ Triggered when agent commands are issued:
- **`command:reset`**: When `/reset` command is issued
- **`command:stop`**: When `/stop` command is issued
### Agent Events
- **`agent:bootstrap`**: Before workspace bootstrap files are injected (hooks may mutate `context.bootstrapFiles`)
### Future Events
Planned event types:
@@ -505,40 +497,6 @@ grep '"action":"new"' ~/.clawdbot/logs/commands.log | jq .
clawdbot hooks enable command-logger
```
### soul-evil
Swaps injected `SOUL.md` content with `SOUL_EVIL.md` during a purge window or by random chance.
**Events**: `agent:bootstrap`
**Output**: No files written; swaps happen in-memory only.
**Enable**:
```bash
clawdbot hooks enable soul-evil
```
**Config**:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"soul-evil": {
"enabled": true,
"file": "SOUL_EVIL.md",
"chance": 0.1,
"purge": { "at": "21:00", "duration": "15m" }
}
}
}
}
}
```
## Best Practices
### Keep Handlers Fast
+17 -24
View File
@@ -58,7 +58,6 @@ register:
- Optional config validation
Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
## Discovery & precedence
@@ -215,27 +214,6 @@ Plugins export either:
- A function: `(api) => { ... }`
- An object: `{ id, name, configSchema, register(api) { ... } }`
## Plugin hooks
Plugins can ship hooks and register them at runtime. This lets a plugin bundle
event-driven automation without a separate hook pack install.
### Example
```
import { registerPluginHooksFromDir } from "clawdbot/plugin-sdk";
export default function register(api) {
registerPluginHooksFromDir(api, "./hooks");
}
```
Notes:
- Hook directories follow the normal hook structure (`HOOK.md` + `handler.ts`).
- Hook eligibility rules still apply (OS/bins/env/config requirements).
- Plugin-managed hooks show up in `clawdbot hooks list` with `plugin:<id>`.
- You cannot enable/disable plugin-managed hooks via `clawdbot hooks`; enable/disable the plugin instead.
## Provider plugins (model auth)
Plugins can register **model provider auth** flows so users can run OAuth or
@@ -401,9 +379,24 @@ export default function (api) {
Load the plugin (extensions dir or `plugins.load.paths`), restart the gateway,
then configure `channels.<id>` in your config.
### Agent tools
### Register a tool
See the dedicated guide: [Plugin agent tools](/plugins/agent-tools).
```ts
import { Type } from "@sinclair/typebox";
export default function (api) {
api.registerTool({
name: "my_tool",
description: "Do a thing",
parameters: Type.Object({
input: Type.String(),
}),
async execute(_id, params) {
return { content: [{ type: "text", text: params.input }] };
},
});
}
```
### Register a gateway RPC method
-94
View File
@@ -1,94 +0,0 @@
---
summary: "Write agent tools in a plugin (schemas, optional tools, allowlists)"
read_when:
- You want to add a new agent tool in a plugin
- You need to make a tool opt-in via allowlists
---
# Plugin agent tools
Clawdbot plugins can register **agent tools** (JSON‑schema functions) that are exposed
to the LLM during agent runs. Tools can be **required** (always available) or
**optional** (opt‑in).
Agent tools are configured under `tools` in the main config, or per‑agent under
`agents.list[].tools`. The allowlist/denylist policy controls which tools the agent
can call.
## Basic tool
```ts
import { Type } from "@sinclair/typebox";
export default function (api) {
api.registerTool({
name: "my_tool",
description: "Do a thing",
parameters: Type.Object({
input: Type.String(),
}),
async execute(_id, params) {
return { content: [{ type: "text", text: params.input }] };
},
});
}
```
## Optional tool (opt‑in)
Optional tools are **never** auto‑enabled. Users must add them to an agent
allowlist.
```ts
export default function (api) {
api.registerTool(
{
name: "workflow_tool",
description: "Run a local workflow",
parameters: {
type: "object",
properties: {
pipeline: { type: "string" },
},
required: ["pipeline"],
},
async execute(_id, params) {
return { content: [{ type: "text", text: params.pipeline }] };
},
},
{ optional: true },
);
}
```
Enable optional tools in `agents.list[].tools.allow` (or global `tools.allow`):
```json5
{
agents: {
list: [
{
id: "main",
tools: {
allow: [
"workflow_tool", // specific tool name
"workflow", // plugin id (enables all tools from that plugin)
"group:plugins" // all plugin tools
]
}
}
]
}
}
```
Other config knobs that affect tool availability:
- `tools.profile` / `agents.list[].tools.profile` (base allowlist)
- `tools.byProvider` / `agents.list[].tools.byProvider` (provider‑specific allow/deny)
- `tools.sandbox.tools.*` (sandbox tool policy when sandboxed)
## Rules + tips
- Tool names must **not** clash with core tool names; conflicting tools are skipped.
- Plugin ids used in allowlists must not clash with core tool names.
- Prefer `optional: true` for tools that trigger side effects or require extra
binaries/credentials.
-251
View File
@@ -1,251 +0,0 @@
---
summary: "Refactor plan: exec host routing, node approvals, and headless runner"
read_when:
- Designing exec host routing or exec approvals
- Implementing node runner + UI IPC
- Adding exec host security modes and slash commands
---
# Exec host refactor plan
## Goals
- Add `exec.host` + `exec.security` to route execution across **sandbox**, **gateway**, and **node**.
- Keep defaults **safe**: no cross-host execution unless explicitly enabled.
- Split execution into a **headless runner service** with optional UI (macOS app) via local IPC.
- Provide **per-agent** policy, allowlist, ask mode, and node binding.
- Support **ask modes** that work *with* or *without* allowlists.
- Cross-platform: Unix socket + token auth (macOS/Linux/Windows parity).
## Non-goals
- No legacy allowlist migration or legacy schema support.
- No PTY/streaming for node exec (aggregated output only).
- No new network layer beyond the existing Bridge + Gateway.
## Decisions (locked)
- **Config keys:** `exec.host` + `exec.security` (per-agent override allowed).
- **Elevation:** keep `/elevated` as an alias for gateway full access.
- **Ask default:** `on-miss`.
- **Approvals store:** `~/.clawdbot/exec-approvals.json` (JSON, no legacy migration).
- **Runner:** headless system service; UI app hosts a Unix socket for approvals.
- **Node identity:** use existing `nodeId`.
- **Socket auth:** Unix socket + token (cross-platform); split later if needed.
## Key concepts
### Host
- `sandbox`: Docker exec (current behavior).
- `gateway`: exec on gateway host.
- `node`: exec on node runner via Bridge (`system.run`).
### Security mode
- `deny`: always block.
- `allowlist`: allow only matches.
- `full`: allow everything (equivalent to elevated).
### Ask mode
- `off`: never ask.
- `on-miss`: ask only when allowlist does not match.
- `always`: ask every time.
Ask is **independent** of allowlist; allowlist can be used with `always` or `on-miss`.
### Policy resolution (per exec)
1) Resolve `exec.host` (tool param → agent override → global default).
2) Resolve `exec.security` and `exec.ask` (same precedence).
3) If host is `sandbox`, proceed with local sandbox exec.
4) If host is `gateway` or `node`, apply security + ask policy on that host.
## Default safety
- Default `exec.host = sandbox`.
- Default `exec.security = deny` for `gateway` and `node`.
- Default `exec.ask = on-miss` (only relevant if security allows).
- If no node binding is set, **agent may target any node**, but only if policy allows it.
## Config surface
### Tool parameters
- `exec.host` (optional): `sandbox | gateway | node`.
- `exec.security` (optional): `deny | allowlist | full`.
- `exec.ask` (optional): `off | on-miss | always`.
- `exec.node` (optional): node id/name to use when `host=node`.
### Config keys (global)
- `tools.exec.host`
- `tools.exec.security`
- `tools.exec.ask`
- `tools.exec.node` (default node binding)
### Config keys (per agent)
- `agents.list[].tools.exec.host`
- `agents.list[].tools.exec.security`
- `agents.list[].tools.exec.ask`
- `agents.list[].tools.exec.node`
### Alias
- `/elevated on` = set `tools.exec.host=gateway`, `tools.exec.security=full` for the agent session.
- `/elevated off` = restore previous exec settings for the agent session.
## Approvals store (JSON)
Path: `~/.clawdbot/exec-approvals.json`
Purpose:
- Local policy + allowlists for the **execution host** (gateway or node runner).
- Ask fallback when no UI is available.
- IPC credentials for UI clients.
Proposed schema (v1):
```json
{
"version": 1,
"socket": {
"path": "~/.clawdbot/exec-approvals.sock",
"token": "base64-opaque-token"
},
"defaults": {
"security": "deny",
"ask": "on-miss",
"askFallback": "deny"
},
"agents": {
"agent-id-1": {
"security": "allowlist",
"ask": "on-miss",
"allowlist": [
{
"pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 0,
"lastUsedCommand": "rg -n TODO",
"lastResolvedPath": "/Users/user/Projects/.../bin/rg"
}
]
}
}
}
```
Notes:
- No legacy allowlist formats.
- `askFallback` applies only when `ask` is required and no UI is reachable.
- File permissions: `0600`.
## Runner service (headless)
### Role
- Enforce `exec.security` + `exec.ask` locally.
- Execute system commands and return output.
- Emit Bridge events for exec lifecycle (optional but recommended).
### Service lifecycle
- Launchd/daemon on macOS; system service on Linux/Windows.
- Approvals JSON is local to the execution host.
- UI hosts a local Unix socket; runners connect on demand.
## UI integration (macOS app)
### IPC
- Unix socket at `~/.clawdbot/exec-approvals.sock`.
- Runner connects and sends an approval request; UI responds with a decision.
- Token stored in `exec-approvals.json`.
### Ask flow
1) Runner receives `system.run` from gateway.
2) If ask required, runner connects to the socket and sends a prompt request.
3) UI shows dialog; returns decision.
4) Runner enforces decision and proceeds.
If UI missing:
- Apply `askFallback` (`deny|allowlist|full`).
## Node identity + binding
- Use existing `nodeId` from Bridge pairing.
- Binding model:
- `tools.exec.node` restricts the agent to a specific node.
- If unset, agent can pick any node (policy still enforces defaults).
- Node selection resolution:
- `nodeId` exact match
- `displayName` (normalized)
- `remoteIp`
- `nodeId` prefix (>= 6 chars)
## Eventing
### Who sees events
- System events are **per session** and shown to the agent on the next prompt.
- Stored in the gateway in-memory queue (`enqueueSystemEvent`).
### Event text
- `Exec started (host=node, node=<id>, id=<runId>)`
- `Exec finished (exit=<code>, tail=<...>)`
- `Exec denied (policy=<...>, reason=<...>)`
### Transport
Option A (recommended):
- Runner sends Bridge `event` frames `exec.started` / `exec.finished`.
- Gateway `handleBridgeEvent` maps these into `enqueueSystemEvent`.
Option B:
- Gateway `exec` tool handles lifecycle directly (synchronous only).
## Exec flows
### Sandbox host
- Existing `exec` behavior (Docker or host when unsandboxed).
- PTY supported in non-sandbox mode only.
### Gateway host
- Gateway process executes on its own machine.
- Enforces local `exec-approvals.json` (security/ask/allowlist).
### Node host
- Gateway calls `node.invoke` with `system.run`.
- Runner enforces local approvals.
- Runner returns aggregated stdout/stderr.
- Optional Bridge events for start/finish/deny.
## Output caps
- Cap combined stdout+stderr at **200k**; keep **tail 20k** for events.
- Truncate with a clear suffix (e.g., `"… (truncated)"`).
## Slash commands
- `/exec host=<sandbox|gateway|node> security=<deny|allowlist|full> ask=<off|on-miss|always> node=<id>`
- Per-agent, per-session overrides; non-persistent unless saved via config.
- `/elevated on|off` remains a shortcut for `host=gateway security=full`.
## Cross-platform story
- The runner service is the portable execution target.
- UI is optional; if missing, `askFallback` applies.
- Windows/Linux support the same approvals JSON + socket protocol.
## Implementation phases
### Phase 1: config + exec routing
- Add config schema for `exec.host`, `exec.security`, `exec.ask`, `exec.node`.
- Update tool plumbing to respect `exec.host`.
- Add `/exec` slash command and keep `/elevated` alias.
### Phase 2: approvals store + gateway enforcement
- Implement `exec-approvals.json` reader/writer.
- Enforce allowlist + ask modes for `gateway` host.
- Add output caps.
### Phase 3: node runner enforcement
- Update node runner to enforce allowlist + ask.
- Add Unix socket prompt bridge to macOS app UI.
- Wire `askFallback`.
### Phase 4: events
- Add node → gateway Bridge events for exec lifecycle.
- Map to `enqueueSystemEvent` for agent prompts.
### Phase 5: UI polish
- Mac app: allowlist editor, per-agent switcher, ask policy UI.
- Node binding controls (optional).
## Testing plan
- Unit tests: allowlist matching (glob + case-insensitive).
- Unit tests: policy resolution precedence (tool param → agent override → global).
- Integration tests: node runner deny/allow/ask flows.
- Bridge event tests: node event → system event routing.
## Open risks
- UI unavailability: ensure `askFallback` is respected.
- Long-running commands: rely on timeout + output caps.
- Multi-node ambiguity: error unless node binding or explicit node param.
## Related docs
- [Exec tool](/tools/exec)
- [Exec approvals](/tools/exec-approvals)
- [Nodes](/nodes)
- [Elevated mode](/tools/elevated)
+2 -2
View File
@@ -42,13 +42,13 @@ Use these in chat:
- `/status` → **emoji‑rich status card** with the session model, context usage,
last response input/output tokens, and **estimated cost** (API key only).
- `/usage off|tokens|full` → appends a **per-response usage footer** to every reply.
- `/cost on|off` → appends a **per-response usage line** to every reply.
- Persists per session (stored as `responseUsage`).
- OAuth auth **hides cost** (tokens only).
Other surfaces:
- **TUI/Web TUI:** `/status` + `/usage` are supported.
- **TUI/Web TUI:** `/status` + `/cost` are supported.
- **CLI:** `clawdbot status --usage` and `clawdbot channels list` show
provider quota windows (not per-response costs).
+15 -5
View File
@@ -6,8 +6,9 @@ read_when:
# Elevated Mode (/elevated directives)
## What it does
- `/elevated on` is a **shortcut** for `exec.host=gateway` + `exec.security=full`.
- Only changes behavior when the agent is **sandboxed** (otherwise exec already runs on the host).
- Elevated mode allows the exec tool to run with elevated privileges when the feature is available and the sender is approved.
- The bash chat command (`!`; `/bash` alias) uses the same `tools.elevated` allowlists because it always runs on the host.
- **Optional for sandboxed agents**: elevated only changes behavior when the agent is running in a sandbox. If the agent already runs unsandboxed, elevated is effectively a no-op.
- Directive forms: `/elevated on`, `/elevated off`, `/elev on`, `/elev off`.
- Only `on|off` are accepted; anything else returns a hint and does not change state.
@@ -16,9 +17,18 @@ read_when:
- **Per-session state**: `/elevated on|off` sets the elevated level for the current session key.
- **Inline directive**: `/elevated on` 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.
- **Unsandboxed agents**: no-op for location; only affects gating, logging, and status.
- **Host execution**: elevated runs `exec` on the host (bypasses sandbox).
- **Unsandboxed agents**: when there is no sandbox to bypass, elevated does not change where `exec` runs.
- **Tool policy still applies**: if `exec` is denied by tool policy, elevated cannot be used.
- **Not skill-scoped**: elevated cannot be limited to a specific skill; it only changes `exec` location.
Note:
- Sandbox on: `/elevated on` runs that `exec` command on the host.
- Sandbox off: `/elevated on` does not change execution (already on host).
## When elevated matters
- Only impacts `exec` when the agent is running sandboxed (it drops the sandbox for that command).
- For unsandboxed agents, elevated does not change execution; it only affects gating, logging, and status.
## Resolution order
1. Inline directive on the message (applies only to that message).
@@ -28,7 +38,7 @@ read_when:
## Setting a session default
- Send a message that is **only** the directive (whitespace allowed), e.g. `/elevated on`.
- Confirmation reply is sent (`Elevated mode enabled.` / `Elevated mode disabled.`).
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error and does not change session state.
- If elevated access is disabled or the sender is not on the approved allowlist, the directive replies with an actionable error (runtime sandboxed/direct + failing config key paths) and does not change session state.
- Send `/elevated` (or `/elevated:`) with no argument to see the current elevated level.
## Availability + allowlists
+61 -88
View File
@@ -1,88 +1,39 @@
---
summary: "Exec approvals, allowlists, and sandbox escape prompts"
summary: "Exec approvals, allowlists, and sandbox escape prompts in the macOS app"
read_when:
- Configuring exec approvals or allowlists
- Implementing exec approval UX in the macOS app
- Reviewing sandbox escape prompts and implications
---
# Exec approvals
# Exec approvals (macOS app)
Exec approvals are the **companion app guardrail** for letting a sandboxed agent run
commands on a real host (`gateway` or `node`). Think of it like a safety interlock:
commands are allowed only when policy + allowlist + (optional) user approval all agree.
Exec approvals are **in addition** to tool policy and elevated gating.
Exec approvals are the **macOS companion app** guardrail for running host
commands from sandboxed agents. Think of it as a per-agent “run this on my Mac”
approval layer: the agent asks, the app decides, and the command runs (or not).
This is **in addition** to tool policy and elevated gating; all of those checks
must pass before a command can run.
If the companion app UI is **not available**, any request that requires a prompt is
resolved by the **ask fallback** (default: deny).
If you are **not** running the macOS companion app, exec approvals are
unavailable and `system.run` requests will be rejected with a message that a
companion app is required.
## Where it applies
## Settings
Exec approvals are enforced locally on the execution host:
- **gateway host** → `clawdbot` process on the gateway machine
- **node host** → node runner (macOS companion app or headless node)
In the macOS app, each agent has an **Exec approvals** setting:
## Settings and storage
- **Deny**: block all host exec requests from the agent.
- **Always ask**: show a confirmation dialog for each host exec request.
- **Always allow**: run host exec requests without prompting.
Approvals live in a local JSON file:
`~/.clawdbot/exec-approvals.json`
Example schema:
```json
{
"version": 1,
"socket": {
"path": "~/.clawdbot/exec-approvals.sock",
"token": "base64url-token"
},
"defaults": {
"security": "deny",
"ask": "on-miss",
"askFallback": "deny",
"autoAllowSkills": false
},
"agents": {
"main": {
"security": "allowlist",
"ask": "on-miss",
"askFallback": "deny",
"autoAllowSkills": true,
"allowlist": [
{
"pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 1737150000000,
"lastUsedCommand": "rg -n TODO",
"lastResolvedPath": "/Users/user/Projects/.../bin/rg"
}
]
}
}
}
```
## Policy knobs
### Security (`exec.security`)
- **deny**: block all host exec requests.
- **allowlist**: allow only allowlisted commands.
- **full**: allow everything (equivalent to elevated).
### Ask (`exec.ask`)
- **off**: never prompt.
- **on-miss**: prompt only when allowlist does not match.
- **always**: prompt on every command.
### Ask fallback (`askFallback`)
If a prompt is required but no UI is reachable, fallback decides:
- **deny**: block.
- **allowlist**: allow only if allowlist matches.
- **full**: allow.
Optional toggles:
- **Auto-allow skill CLIs**: when enabled, CLIs referenced by known skills are
treated as allowlisted (see below).
## Allowlist (per agent)
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
editing in the macOS app. Patterns are **case-insensitive glob matches**.
The allowlist is **per agent**. If multiple agents exist, you can switch which
agent’s allowlist you’re editing. Entries are path-based and support **globs**.
Examples:
- `~/Projects/**/bin/bird`
@@ -90,44 +41,66 @@ Examples:
- `/opt/homebrew/bin/rg`
Each allowlist entry tracks:
- **last used** timestamp
- **last used** (timestamp)
- **last used command**
- **last resolved path**
- **last used path** (resolved absolute path)
- **last seen metadata** (hash/version/mtime when available)
## Auto-allow skill CLIs
## How matching works
When **Auto-allow skill CLIs** is enabled, executables referenced by known skills
are treated as allowlisted (node hosts only). Disable this if you want strict
manual allowlists.
1) Parse the command to determine the executable (first token).
2) Resolve the executable to an absolute path using `PATH`.
3) Match against denylist (if present) → **deny**.
4) Match against allowlist → **allow**.
5) Otherwise follow the Exec approvals policy (deny/ask/allow).
If **auto-allow skill CLIs** is enabled, each installed skill can contribute one
or more allowlist entries. A skill-based allowlist entry only auto-allows when:
- the resolved path matches, and
- the binary hash/version matches the last approved record (if tracked).
If the binary changes (new hash/version), the command falls back to **Ask** so
the user can re-approve.
## Approval flow
When a prompt is required, the companion app displays a confirmation dialog with:
When the policy is **Always ask** (or when a binary has changed), the macOS app
shows a confirmation dialog. The dialog should include:
- command + args
- cwd
- agent id
- resolved executable path
- host + policy metadata
- environment overrides (diff)
- policy + rule that matched (if any)
Actions:
- **Allow once** → run now
- **Always allow** → add to allowlist + run
- **Always allow** → add/update allowlist entry + run
- **Deny** → block
When approved, the command runs **in the background** and the agent receives
system events as it starts and completes.
## System events
Exec lifecycle is surfaced as system messages:
- `exec.started`
- `exec.finished`
- `exec.denied`
The agent receives system messages for observability and recovery:
These are posted to the agent’s session after the node reports the event.
- `exec.started` — command accepted and launched
- `exec.finished` — command completed (exit code + output)
- `exec.denied` — command blocked (policy or denylist)
These are **system messages**; no extra agent tool call is required to resume.
## Implications
- **full** is powerful; prefer allowlists when possible.
- **ask** keeps you in the loop while still allowing fast approvals.
- Per-agent allowlists prevent one agent’s approvals from leaking into others.
- **Always allow** is powerful: the agent can run any host command without a
prompt. Prefer allowlisting trusted CLIs instead.
- **Ask** keeps you in the loop while still allowing fast approvals.
- Per-agent allowlists prevent one agent’s approval set from leaking into others.
## Storage
Allowlists and approval settings are stored **locally in the macOS app** (SQLite
is a good fit). The Markdown docs describe behavior; they are not the storage
mechanism.
Related:
- [Exec tool](/tools/exec)
+4 -19
View File
@@ -14,36 +14,21 @@ Background sessions are scoped per agent; `process` only sees sessions from the
## Parameters
- `command` (required)
- `workdir` (defaults to cwd)
- `env` (key/value overrides)
- `yieldMs` (default 10000): auto-background after delay
- `background` (bool): background immediately
- `timeout` (seconds, default 1800): kill on expiry
- `pty` (bool): run in a pseudo-terminal when available (TTY-only CLIs, coding agents, terminal UIs)
- `host` (`sandbox | gateway | node`): where to execute
- `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
Notes:
- `host` defaults to `sandbox`.
- `elevated` is ignored when sandboxing is off (exec already runs on the host).
- `gateway`/`node` approvals are controlled by `~/.clawdbot/exec-approvals.json`.
- `node` requires a paired node (macOS companion app).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
- `elevated` (bool): run on host if elevated mode is enabled/allowed (only changes behavior when the agent is sandboxed)
- Need a fully interactive session? Use `pty: true` and the `process` tool for stdin/output.
Note: `elevated` is ignored when sandboxing is off (exec already runs on the host).
## Config
- `tools.exec.notifyOnExit` (default: true): when true, backgrounded exec sessions enqueue a system event and request a heartbeat on exit.
- `tools.exec.host` (default: `sandbox`)
- `tools.exec.security` (default: `deny`)
- `tools.exec.ask` (default: `on-miss`)
- `tools.exec.node` (default: unset)
## Exec approvals (macOS app)
Sandboxed agents can require per-request approval before `exec` runs on the gateway or node host.
Sandboxed agents can require per-request approval before `exec` runs on the host.
See [Exec approvals](/tools/exec-approvals) for the policy, allowlist, and UI flow.
## Examples
+2 -6
View File
@@ -169,19 +169,15 @@ Core parameters:
- `background` (immediate background)
- `timeout` (seconds; kills the process if exceeded, default 1800)
- `elevated` (bool; run on host if elevated mode is enabled/allowed; only changes behavior when the agent is sandboxed)
- `host` (`sandbox | gateway | node`)
- `security` (`deny | allowlist | full`)
- `ask` (`off | on-miss | always`)
- `node` (node id/name for `host=node`)
- Need a real TTY? Set `pty: true`.
Notes:
- Returns `status: "running"` with a `sessionId` when backgrounded.
- Use `process` to poll/log/write/kill/clear background sessions.
- If `process` is disallowed, `exec` runs synchronously and ignores `yieldMs`/`background`.
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and is an alias for `host=gateway` + `security=full`.
- `elevated` is gated by `tools.elevated` plus any `agents.list[].tools.elevated` override (both must allow) and runs on the host.
- `elevated` only changes behavior when the agent is sandboxed (otherwise it’s a no-op).
- gateway/node approvals and allowlists: [Exec approvals](/tools/exec-approvals).
- macOS app approvals/allowlists: [Exec approvals](/tools/exec-approvals).
### `process`
Manage background exec sessions.
+8 -8
View File
@@ -17,7 +17,7 @@ There are two related systems:
- In normal chat messages (not directive-only), they are treated as “inline hints” and do **not** persist session settings.
- In directive-only messages (the message contains only directives), they persist to the session and reply with an acknowledgement.
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status`, `/whoami` (`/id`).
There are also a few **inline shortcuts** (allowlisted/authorized senders only): `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
They run immediately, are stripped before the model sees the message, and the remaining text continues through the normal flow.
## Config
@@ -60,11 +60,11 @@ Text + native (when enabled):
- `/commands`
- `/status` (show current status; includes provider usage/quota for the current model provider when available)
- `/context [list|detail|json]` (explain “context”; `detail` shows per-file + per-tool + per-skill + system prompt size)
- `/usage` (alias: `/status`)
- `/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)
- `/config show|get|set|unset` (persist config to disk, owner-only; requires `commands.config: true`)
- `/debug show|set|unset|reset` (runtime overrides, owner-only; requires `commands.debug: true`)
- `/usage off|tokens|full` (per-response usage footer)
- `/cost on|off` (toggle per-response usage line)
- `/stop`
- `/restart`
- `/dock-telegram` (alias: `/dock_telegram`) (switch replies to Telegram)
@@ -89,8 +89,8 @@ Text-only:
Notes:
- Commands accept an optional `:` between the command and args (e.g. `/think: high`, `/send: on`, `/help:`).
- For full provider usage breakdown, use `clawdbot status --usage`.
- `/usage` controls the per-response usage footer. It only shows dollar cost when the model uses an API key (OAuth hides cost).
- `/status` and `/usage` show the same status output; for full provider usage breakdown, use `clawdbot status --usage`.
- `/cost` appends per-response token usage; it only shows dollar cost when the model uses an API key (OAuth hides cost).
- `/restart` is disabled by default; set `commands.restart: true` to enable it.
- `/verbose` is meant for debugging and extra visibility; keep it **off** in normal use.
- `/reasoning` (and `/verbose`) are risky in group settings: they may reveal internal reasoning or tool output you did not intend to expose. Prefer leaving them off, especially in group chats.
@@ -98,15 +98,15 @@ Notes:
- **Group mention gating:** command-only messages from allowlisted senders bypass mention requirements.
- **Inline shortcuts (allowlisted senders only):** certain commands also work when embedded in a normal message and are stripped before the model sees the remaining text.
- Example: `hey /status` triggers a status reply, and the remaining text continues through the normal flow.
- Currently: `/help`, `/commands`, `/status`, `/whoami` (`/id`).
- Currently: `/help`, `/commands`, `/status` (`/usage`), `/whoami` (`/id`).
- Unauthorized command-only messages are silently ignored, and inline `/...` tokens are treated as plain text.
- **Skill commands:** `user-invocable` skills are exposed as slash commands. Names are sanitized to `a-z0-9_` (max 32 chars); collisions get numeric suffixes (e.g. `_2`).
- **Native command arguments:** Discord uses autocomplete for dynamic options (and button menus when you omit required args). Telegram and Slack show a button menu when a command supports choices and you omit the arg.
## Usage surfaces (what shows where)
## Usage vs cost (what shows where)
- **Provider usage/quota** (example: “Claude 80% left”) shows up in `/status` for the current model provider when usage tracking is enabled.
- **Per-response tokens/cost** is controlled by `/usage off|tokens|full` (appended to normal replies).
- **Per-response tokens/cost** is controlled by `/cost on|off` (appended to normal replies).
- `/model status` is about **models/auth/endpoints**, not usage.
## Model selection (`/model`)
-11
View File
@@ -9,17 +9,6 @@ read_when:
Sub-agents are background agent runs spawned from an existing agent run. They run in their own session (`agent:<agentId>:subagent:<uuid>`) and, when finished, **announce** their result back to the requester chat channel.
## Slash command
Use `/subagents` to inspect or control sub-agent runs for the **current session**:
- `/subagents list`
- `/subagents stop <id|#|all>`
- `/subagents log <id|#> [limit] [tools]`
- `/subagents info <id|#>`
- `/subagents send <id|#> <message>`
`/subagents info` shows run metadata (status, timestamps, session id, transcript path, cleanup).
Primary goals:
- Parallelize “research / long task / slow tool” work without blocking the main run.
- Keep sub-agents isolated by default (session separation + optional sandboxing).
+1 -1
View File
@@ -77,7 +77,7 @@ Session controls:
- `/think <off|minimal|low|medium|high>`
- `/verbose <on|full|off>`
- `/reasoning <on|off|stream>`
- `/usage <off|tokens|full>`
- `/cost <on|off>`
- `/elevated <on|off>` (alias: `/elev`)
- `/activation <mention|always>`
- `/deliver <on|off>`
-18
View File
@@ -1,18 +0,0 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { bluebubblesPlugin } from "./src/channel.js";
import { handleBlueBubblesWebhookRequest } from "./src/monitor.js";
import { setBlueBubblesRuntime } from "./src/runtime.js";
const plugin = {
id: "bluebubbles",
name: "BlueBubbles",
description: "BlueBubbles channel plugin (macOS app)",
register(api: ClawdbotPluginApi) {
setBlueBubblesRuntime(api.runtime);
api.registerChannel({ plugin: bluebubblesPlugin });
api.registerHttpHandler(handleBlueBubblesWebhookRequest);
},
};
export default plugin;
-9
View File
@@ -1,9 +0,0 @@
{
"name": "@clawdbot/bluebubbles",
"version": "2026.1.15",
"type": "module",
"description": "Clawdbot BlueBubbles channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
}
}
-79
View File
@@ -1,79 +0,0 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import { normalizeBlueBubblesServerUrl, type BlueBubblesAccountConfig } from "./types.js";
export type ResolvedBlueBubblesAccount = {
accountId: string;
enabled: boolean;
name?: string;
config: BlueBubblesAccountConfig;
configured: boolean;
baseUrl?: string;
};
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = cfg.channels?.bluebubbles?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listBlueBubblesAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultBlueBubblesAccountId(cfg: ClawdbotConfig): string {
const ids = listBlueBubblesAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): BlueBubblesAccountConfig | undefined {
const accounts = cfg.channels?.bluebubbles?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as BlueBubblesAccountConfig | undefined;
}
function mergeBlueBubblesAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): BlueBubblesAccountConfig {
const base = (cfg.channels?.bluebubbles ?? {}) as BlueBubblesAccountConfig & {
accounts?: unknown;
};
const { accounts: _ignored, ...rest } = base;
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...rest, ...account };
}
export function resolveBlueBubblesAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedBlueBubblesAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.channels?.bluebubbles?.enabled;
const merged = mergeBlueBubblesAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const serverUrl = merged.serverUrl?.trim();
const password = merged.password?.trim();
const configured = Boolean(serverUrl && password);
const baseUrl = serverUrl ? normalizeBlueBubblesServerUrl(serverUrl) : undefined;
return {
accountId,
enabled: baseEnabled !== false && accountEnabled,
name: merged.name?.trim() || undefined,
config: merged,
configured,
baseUrl,
};
}
export function listEnabledBlueBubblesAccounts(cfg: ClawdbotConfig): ResolvedBlueBubblesAccount[] {
return listBlueBubblesAccountIds(cfg)
.map((accountId) => resolveBlueBubblesAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
-121
View File
@@ -1,121 +0,0 @@
import {
createActionGate,
jsonResult,
readNumberParam,
readReactionParams,
readStringParam,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelToolSend,
type ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { sendBlueBubblesReaction } from "./reactions.js";
import { resolveChatGuidForTarget } from "./send.js";
import { normalizeBlueBubblesHandle, parseBlueBubblesTarget } from "./targets.js";
import type { BlueBubblesSendTarget } from "./types.js";
const providerId = "bluebubbles";
function mapTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "chat_guid") return { kind: "chat_guid", chatGuid: parsed.chatGuid };
if (parsed.kind === "chat_id") return { kind: "chat_id", chatId: parsed.chatId };
if (parsed.kind === "chat_identifier") {
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
}
return {
kind: "handle",
address: normalizeBlueBubblesHandle(parsed.to),
service: parsed.service,
};
}
export const bluebubblesMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const account = resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig });
if (!account.enabled || !account.configured) return [];
const gate = createActionGate((cfg as ClawdbotConfig).channels?.bluebubbles?.actions);
const actions = new Set<ChannelMessageActionName>();
if (gate("reactions")) actions.add("react");
return Array.from(actions);
},
supportsAction: ({ action }) => action === "react",
extractToolSend: ({ args }): ChannelToolSend | null => {
const action = typeof args.action === "string" ? args.action.trim() : "";
if (action !== "sendMessage") return null;
const to = typeof args.to === "string" ? args.to : undefined;
if (!to) return null;
const accountId = typeof args.accountId === "string" ? args.accountId.trim() : undefined;
return { to, accountId };
},
handleAction: async ({ action, params, cfg, accountId }) => {
if (action !== "react") {
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
}
const { emoji, remove, isEmpty } = readReactionParams(params, {
removeErrorMessage: "Emoji is required to remove a BlueBubbles reaction.",
});
if (isEmpty && !remove) {
throw new Error("Emoji is required to send a BlueBubbles reaction.");
}
const messageId = readStringParam(params, "messageId", { required: true });
const chatGuid = readStringParam(params, "chatGuid");
const chatIdentifier = readStringParam(params, "chatIdentifier");
const chatId = readNumberParam(params, "chatId", { integer: true });
const to = readStringParam(params, "to");
const partIndex = readNumberParam(params, "partIndex", { integer: true });
const account = resolveBlueBubblesAccount({
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
});
const baseUrl = account.config.serverUrl?.trim();
const password = account.config.password?.trim();
let resolvedChatGuid = chatGuid?.trim() || "";
if (!resolvedChatGuid) {
const target =
chatIdentifier?.trim()
? ({ kind: "chat_identifier", chatIdentifier: chatIdentifier.trim() } as BlueBubblesSendTarget)
: typeof chatId === "number"
? ({ kind: "chat_id", chatId } as BlueBubblesSendTarget)
: to
? mapTarget(to)
: null;
if (!target) {
throw new Error("BlueBubbles reaction requires chatGuid, chatIdentifier, chatId, or to.");
}
if (!baseUrl || !password) {
throw new Error("BlueBubbles reaction requires serverUrl and password.");
}
resolvedChatGuid =
(await resolveChatGuidForTarget({
baseUrl,
password,
target,
})) ?? "";
}
if (!resolvedChatGuid) {
throw new Error("BlueBubbles reaction failed: chatGuid not found for target.");
}
await sendBlueBubblesReaction({
chatGuid: resolvedChatGuid,
messageGuid: messageId,
emoji,
remove: remove || undefined,
partIndex: typeof partIndex === "number" ? partIndex : undefined,
opts: {
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
},
});
if (!remove) {
return jsonResult({ ok: true, added: emoji });
}
return jsonResult({ ok: true, removed: true });
},
};
-57
View File
@@ -1,57 +0,0 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { resolveBlueBubblesAccount } from "./accounts.js";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
type BlueBubblesAttachment,
} from "./types.js";
export type BlueBubblesAttachmentOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: ClawdbotConfig;
};
const DEFAULT_ATTACHMENT_MAX_BYTES = 8 * 1024 * 1024;
function resolveAccount(params: BlueBubblesAttachmentOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
if (!password) throw new Error("BlueBubbles password is required");
return { baseUrl, password };
}
export async function downloadBlueBubblesAttachment(
attachment: BlueBubblesAttachment,
opts: BlueBubblesAttachmentOpts & { maxBytes?: number } = {},
): Promise<{ buffer: Uint8Array; contentType?: string }> {
const guid = attachment.guid?.trim();
if (!guid) throw new Error("BlueBubbles attachment guid is required");
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/attachment/${encodeURIComponent(guid)}/download`,
password,
});
const res = await blueBubblesFetchWithTimeout(url, { method: "GET" }, opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(
`BlueBubbles attachment download failed (${res.status}): ${errorText || "unknown"}`,
);
}
const contentType = res.headers.get("content-type") ?? undefined;
const buf = new Uint8Array(await res.arrayBuffer());
const maxBytes = typeof opts.maxBytes === "number" ? opts.maxBytes : DEFAULT_ATTACHMENT_MAX_BYTES;
if (buf.byteLength > maxBytes) {
throw new Error(`BlueBubbles attachment too large (${buf.byteLength} bytes)`);
}
return { buffer: buf, contentType: contentType ?? attachment.mimeType ?? undefined };
}
-284
View File
@@ -1,284 +0,0 @@
import type { ChannelAccountSnapshot, ChannelPlugin, ClawdbotConfig } from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
import {
listBlueBubblesAccountIds,
type ResolvedBlueBubblesAccount,
resolveBlueBubblesAccount,
resolveDefaultBlueBubblesAccountId,
} from "./accounts.js";
import { BlueBubblesConfigSchema } from "./config-schema.js";
import { probeBlueBubbles } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js";
import { normalizeBlueBubblesHandle } from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
const meta = {
id: "bluebubbles",
label: "BlueBubbles",
selectionLabel: "BlueBubbles (macOS app)",
docsPath: "/channels/bluebubbles",
docsLabel: "bluebubbles",
blurb: "iMessage via the BlueBubbles mac app + REST API.",
order: 75,
};
export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
id: "bluebubbles",
meta,
capabilities: {
chatTypes: ["direct", "group"],
media: false,
reactions: true,
},
reload: { configPrefixes: ["channels.bluebubbles"] },
configSchema: buildChannelConfigSchema(BlueBubblesConfigSchema),
config: {
listAccountIds: (cfg) => listBlueBubblesAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) =>
resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultBlueBubblesAccountId(cfg as ClawdbotConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "bluebubbles",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "bluebubbles",
accountId,
clearBaseFields: ["serverUrl", "password", "name", "webhookPath"],
}),
isConfigured: (account) => account.configured,
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveBlueBubblesAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ??
[]).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^bluebubbles:/i, ""))
.map((entry) => normalizeBlueBubblesHandle(entry)),
},
actions: bluebubblesMessageActions,
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as ClawdbotConfig).channels?.bluebubbles?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.bluebubbles.accounts.${resolvedAccountId}.`
: "channels.bluebubbles.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("bluebubbles"),
normalizeEntry: (raw) => normalizeBlueBubblesHandle(raw.replace(/^bluebubbles:/i, "")),
};
},
collectWarnings: ({ account }) => {
const groupPolicy = account.config.groupPolicy ?? "allowlist";
if (groupPolicy !== "open") return [];
return [
`- BlueBubbles groups: groupPolicy="open" allows any member to trigger the bot. Set channels.bluebubbles.groupPolicy="allowlist" + channels.bluebubbles.groupAllowFrom to restrict senders.`,
];
},
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "bluebubbles",
accountId,
name,
}),
validateInput: ({ input }) => {
if (!input.httpUrl && !input.password) {
return "BlueBubbles requires --http-url and --password.";
}
if (!input.httpUrl) return "BlueBubbles requires --http-url.";
if (!input.password) return "BlueBubbles requires --password.";
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
channelKey: "bluebubbles",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "bluebubbles",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
},
},
} as ClawdbotConfig;
}
return {
...next,
channels: {
...next.channels,
bluebubbles: {
...next.channels?.bluebubbles,
enabled: true,
accounts: {
...(next.channels?.bluebubbles?.accounts ?? {}),
[accountId]: {
...(next.channels?.bluebubbles?.accounts?.[accountId] ?? {}),
enabled: true,
...(input.httpUrl ? { serverUrl: input.httpUrl } : {}),
...(input.password ? { password: input.password } : {}),
},
},
},
},
} as ClawdbotConfig;
},
},
pairing: {
idLabel: "bluebubblesSenderId",
normalizeAllowEntry: (entry) => normalizeBlueBubblesHandle(entry.replace(/^bluebubbles:/i, "")),
notifyApproval: async ({ cfg, id }) => {
await sendMessageBlueBubbles(id, PAIRING_APPROVED_MESSAGE, {
cfg: cfg as ClawdbotConfig,
});
},
},
outbound: {
deliveryMode: "direct",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to BlueBubbles requires --to <handle|chat_guid:GUID>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ cfg, to, text, accountId }) => {
const result = await sendMessageBlueBubbles(to, text, {
cfg: cfg as ClawdbotConfig,
accountId: accountId ?? undefined,
});
return { channel: "bluebubbles", ...result };
},
sendMedia: async () => {
throw new Error("BlueBubbles media delivery is not supported yet.");
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: (accounts) =>
accounts.flatMap((account) => {
const lastError = typeof account.lastError === "string" ? account.lastError.trim() : "";
if (!lastError) return [];
return [
{
channel: "bluebubbles",
accountId: account.accountId,
kind: "runtime",
message: `Channel error: ${lastError}`,
},
];
}),
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
baseUrl: snapshot.baseUrl ?? null,
running: snapshot.running ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
probeBlueBubbles({
baseUrl: account.baseUrl,
password: account.config.password ?? null,
timeoutMs,
}),
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: account.configured,
baseUrl: account.baseUrl,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const webhookPath = resolveWebhookPathFromConfig(account.config);
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.baseUrl,
});
ctx.log?.info(`[${account.accountId}] starting provider (webhook=${webhookPath})`);
return monitorBlueBubblesProvider({
account,
config: ctx.cfg as ClawdbotConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
webhookPath,
});
},
},
};
-66
View File
@@ -1,66 +0,0 @@
import { resolveBlueBubblesAccount } from "./accounts.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesChatOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: ClawdbotConfig;
};
function resolveAccount(params: BlueBubblesChatOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
if (!password) throw new Error("BlueBubbles password is required");
return { baseUrl, password };
}
export async function markBlueBubblesChatRead(
chatGuid: string,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) return;
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/read`,
password,
});
const res = await blueBubblesFetchWithTimeout(url, { method: "POST" }, opts.timeoutMs);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles read failed (${res.status}): ${errorText || "unknown"}`);
}
}
export async function sendBlueBubblesTyping(
chatGuid: string,
typing: boolean,
opts: BlueBubblesChatOpts = {},
): Promise<void> {
const trimmed = chatGuid.trim();
if (!trimmed) return;
const { baseUrl, password } = resolveAccount(opts);
const url = buildBlueBubblesApiUrl({
baseUrl,
path: `/api/v1/chat/${encodeURIComponent(trimmed)}/typing`,
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{ method: typing ? "POST" : "DELETE" },
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text().catch(() => "");
throw new Error(`BlueBubbles typing failed (${res.status}): ${errorText || "unknown"}`);
}
}
@@ -1,30 +0,0 @@
import { z } from "zod";
const allowFromEntry = z.union([z.string(), z.number()]);
const bluebubblesActionSchema = z
.object({
reactions: z.boolean().optional(),
})
.optional();
const bluebubblesAccountSchema = z.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
serverUrl: z.string().optional(),
password: z.string().optional(),
webhookPath: z.string().optional(),
dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(),
allowFrom: z.array(allowFromEntry).optional(),
groupAllowFrom: z.array(allowFromEntry).optional(),
groupPolicy: z.enum(["open", "disabled", "allowlist"]).optional(),
historyLimit: z.number().int().min(0).optional(),
dmHistoryLimit: z.number().int().min(0).optional(),
textChunkLimit: z.number().int().positive().optional(),
mediaMaxMb: z.number().int().positive().optional(),
});
export const BlueBubblesConfigSchema = bluebubblesAccountSchema.extend({
accounts: z.object({}).catchall(bluebubblesAccountSchema).optional(),
actions: bluebubblesActionSchema,
});
File diff suppressed because it is too large. Load diff
-36
View File
@@ -1,36 +0,0 @@
import { buildBlueBubblesApiUrl, blueBubblesFetchWithTimeout } from "./types.js";
export type BlueBubblesProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
};
export async function probeBlueBubbles(params: {
baseUrl?: string | null;
password?: string | null;
timeoutMs?: number;
}): Promise<BlueBubblesProbe> {
const baseUrl = params.baseUrl?.trim();
const password = params.password?.trim();
if (!baseUrl) return { ok: false, error: "serverUrl not configured" };
if (!password) return { ok: false, error: "password not configured" };
const url = buildBlueBubblesApiUrl({ baseUrl, path: "/api/v1/ping", password });
try {
const res = await blueBubblesFetchWithTimeout(
url,
{ method: "GET" },
params.timeoutMs,
);
if (!res.ok) {
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
}
return { ok: true, status: res.status };
} catch (err) {
return {
ok: false,
status: null,
error: err instanceof Error ? err.message : String(err),
};
}
}
-114
View File
@@ -1,114 +0,0 @@
import { resolveBlueBubblesAccount } from "./accounts.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { blueBubblesFetchWithTimeout, buildBlueBubblesApiUrl } from "./types.js";
export type BlueBubblesReactionOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: ClawdbotConfig;
};
const REACTION_TYPES = new Set([
"love",
"like",
"dislike",
"laugh",
"emphasize",
"question",
]);
const REACTION_ALIASES = new Map<string, string>([
["heart", "love"],
["thumbs_up", "like"],
["thumbs-down", "dislike"],
["thumbs_down", "dislike"],
["haha", "laugh"],
["lol", "laugh"],
["emphasis", "emphasize"],
["exclaim", "emphasize"],
["question", "question"],
]);
const REACTION_EMOJIS = new Map<string, string>([
["❤️", "love"],
["❤", "love"],
["♥️", "love"],
["😍", "love"],
["👍", "like"],
["👎", "dislike"],
["😂", "laugh"],
["🤣", "laugh"],
["😆", "laugh"],
["‼️", "emphasize"],
["‼", "emphasize"],
["❗", "emphasize"],
["❓", "question"],
["❔", "question"],
]);
function resolveAccount(params: BlueBubblesReactionOpts) {
const account = resolveBlueBubblesAccount({
cfg: params.cfg ?? {},
accountId: params.accountId,
});
const baseUrl = params.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = params.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
if (!password) throw new Error("BlueBubbles password is required");
return { baseUrl, password };
}
function normalizeReactionInput(emoji: string, remove?: boolean): string {
const trimmed = emoji.trim();
if (!trimmed) throw new Error("BlueBubbles reaction requires an emoji or name.");
let raw = trimmed.toLowerCase();
if (raw.startsWith("-")) raw = raw.slice(1);
const aliased = REACTION_ALIASES.get(raw) ?? raw;
const mapped = REACTION_EMOJIS.get(trimmed) ?? REACTION_EMOJIS.get(raw) ?? aliased;
if (!REACTION_TYPES.has(mapped)) {
throw new Error(`Unsupported BlueBubbles reaction: ${trimmed}`);
}
return remove ? `-${mapped}` : mapped;
}
export async function sendBlueBubblesReaction(params: {
chatGuid: string;
messageGuid: string;
emoji: string;
remove?: boolean;
partIndex?: number;
opts?: BlueBubblesReactionOpts;
}): Promise<void> {
const chatGuid = params.chatGuid.trim();
const messageGuid = params.messageGuid.trim();
if (!chatGuid) throw new Error("BlueBubbles reaction requires chatGuid.");
if (!messageGuid) throw new Error("BlueBubbles reaction requires messageGuid.");
const reaction = normalizeReactionInput(params.emoji, params.remove);
const { baseUrl, password } = resolveAccount(params.opts ?? {});
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/message/react",
password,
});
const payload = {
chatGuid,
selectedMessageGuid: messageGuid,
reaction,
partIndex: typeof params.partIndex === "number" ? params.partIndex : 0,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
params.opts?.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles reaction failed (${res.status}): ${errorText || "unknown"}`);
}
}
-14
View File
@@ -1,14 +0,0 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setBlueBubblesRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getBlueBubblesRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("BlueBubbles runtime not initialized");
}
return runtime;
}
-263
View File
@@ -1,263 +0,0 @@
import crypto from "node:crypto";
import { resolveBlueBubblesAccount } from "./accounts.js";
import { parseBlueBubblesTarget, normalizeBlueBubblesHandle } from "./targets.js";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import {
blueBubblesFetchWithTimeout,
buildBlueBubblesApiUrl,
type BlueBubblesSendTarget,
} from "./types.js";
export type BlueBubblesSendOpts = {
serverUrl?: string;
password?: string;
accountId?: string;
timeoutMs?: number;
cfg?: ClawdbotConfig;
};
export type BlueBubblesSendResult = {
messageId: string;
};
function resolveSendTarget(raw: string): BlueBubblesSendTarget {
const parsed = parseBlueBubblesTarget(raw);
if (parsed.kind === "handle") {
return {
kind: "handle",
address: normalizeBlueBubblesHandle(parsed.to),
service: parsed.service,
};
}
if (parsed.kind === "chat_id") {
return { kind: "chat_id", chatId: parsed.chatId };
}
if (parsed.kind === "chat_guid") {
return { kind: "chat_guid", chatGuid: parsed.chatGuid };
}
return { kind: "chat_identifier", chatIdentifier: parsed.chatIdentifier };
}
function extractMessageId(payload: unknown): string {
if (!payload || typeof payload !== "object") return "unknown";
const record = payload as Record<string, unknown>;
const data = record.data && typeof record.data === "object" ? (record.data as Record<string, unknown>) : null;
const candidates = [
record.messageId,
record.guid,
record.id,
data?.messageId,
data?.guid,
data?.id,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
if (typeof candidate === "number" && Number.isFinite(candidate)) return String(candidate);
}
return "unknown";
}
type BlueBubblesChatRecord = Record<string, unknown>;
function extractChatGuid(chat: BlueBubblesChatRecord): string | null {
const candidates = [
chat.chatGuid,
chat.guid,
chat.chat_guid,
chat.identifier,
chat.chatIdentifier,
chat.chat_identifier,
];
for (const candidate of candidates) {
if (typeof candidate === "string" && candidate.trim()) return candidate.trim();
}
return null;
}
function extractChatId(chat: BlueBubblesChatRecord): number | null {
const candidates = [chat.chatId, chat.id, chat.chat_id];
for (const candidate of candidates) {
if (typeof candidate === "number" && Number.isFinite(candidate)) return candidate;
}
return null;
}
function extractParticipantAddresses(chat: BlueBubblesChatRecord): string[] {
const raw =
(Array.isArray(chat.participants) ? chat.participants : null) ??
(Array.isArray(chat.handles) ? chat.handles : null) ??
(Array.isArray(chat.participantHandles) ? chat.participantHandles : null);
if (!raw) return [];
const out: string[] = [];
for (const entry of raw) {
if (typeof entry === "string") {
out.push(entry);
continue;
}
if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>;
const candidate =
(typeof record.address === "string" && record.address) ||
(typeof record.handle === "string" && record.handle) ||
(typeof record.id === "string" && record.id) ||
(typeof record.identifier === "string" && record.identifier);
if (candidate) out.push(candidate);
}
}
return out;
}
async function queryChats(params: {
baseUrl: string;
password: string;
timeoutMs?: number;
offset: number;
limit: number;
}): Promise<BlueBubblesChatRecord[]> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/chat/query",
password: params.password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
limit: params.limit,
offset: params.offset,
with: ["participants"],
}),
},
params.timeoutMs,
);
if (!res.ok) return [];
const payload = (await res.json().catch(() => null)) as Record<string, unknown> | null;
const data = payload && typeof payload.data !== "undefined" ? (payload.data as unknown) : null;
return Array.isArray(data) ? (data as BlueBubblesChatRecord[]) : [];
}
export async function resolveChatGuidForTarget(params: {
baseUrl: string;
password: string;
timeoutMs?: number;
target: BlueBubblesSendTarget;
}): Promise<string | null> {
if (params.target.kind === "chat_guid") return params.target.chatGuid;
const normalizedHandle =
params.target.kind === "handle" ? normalizeBlueBubblesHandle(params.target.address) : "";
const targetChatId = params.target.kind === "chat_id" ? params.target.chatId : null;
const targetChatIdentifier =
params.target.kind === "chat_identifier" ? params.target.chatIdentifier : null;
const limit = 500;
for (let offset = 0; offset < 5000; offset += limit) {
const chats = await queryChats({
baseUrl: params.baseUrl,
password: params.password,
timeoutMs: params.timeoutMs,
offset,
limit,
});
if (chats.length === 0) break;
for (const chat of chats) {
if (targetChatId != null) {
const chatId = extractChatId(chat);
if (chatId != null && chatId === targetChatId) {
return extractChatGuid(chat);
}
}
if (targetChatIdentifier) {
const guid = extractChatGuid(chat);
if (guid && guid === targetChatIdentifier) return guid;
const identifier =
typeof chat.identifier === "string"
? chat.identifier
: typeof chat.chatIdentifier === "string"
? chat.chatIdentifier
: typeof chat.chat_identifier === "string"
? chat.chat_identifier
: "";
if (identifier && identifier === targetChatIdentifier) return extractChatGuid(chat);
}
if (normalizedHandle) {
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
return extractChatGuid(chat);
}
}
}
}
return null;
}
export async function sendMessageBlueBubbles(
to: string,
text: string,
opts: BlueBubblesSendOpts = {},
): Promise<BlueBubblesSendResult> {
const trimmedText = text ?? "";
if (!trimmedText.trim()) {
throw new Error("BlueBubbles send requires text");
}
const account = resolveBlueBubblesAccount({
cfg: opts.cfg ?? {},
accountId: opts.accountId,
});
const baseUrl = opts.serverUrl?.trim() || account.config.serverUrl?.trim();
const password = opts.password?.trim() || account.config.password?.trim();
if (!baseUrl) throw new Error("BlueBubbles serverUrl is required");
if (!password) throw new Error("BlueBubbles password is required");
const target = resolveSendTarget(to);
const chatGuid = await resolveChatGuidForTarget({
baseUrl,
password,
timeoutMs: opts.timeoutMs,
target,
});
if (!chatGuid) {
throw new Error(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
);
}
const payload: Record<string, unknown> = {
chatGuid,
tempGuid: crypto.randomUUID(),
message: trimmedText,
method: "apple-script",
};
const url = buildBlueBubblesApiUrl({
baseUrl,
path: "/api/v1/message/text",
password,
});
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
opts.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) return { messageId: "ok" };
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}
-191
View File
@@ -1,191 +0,0 @@
export type BlueBubblesService = "imessage" | "sms" | "auto";
export type BlueBubblesTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; to: string; service: BlueBubblesService };
export type BlueBubblesAllowTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; handle: string };
const CHAT_ID_PREFIXES = ["chat_id:", "chatid:", "chat:"];
const CHAT_GUID_PREFIXES = ["chat_guid:", "chatguid:", "guid:"];
const CHAT_IDENTIFIER_PREFIXES = ["chat_identifier:", "chatidentifier:", "chatident:"];
const SERVICE_PREFIXES: Array<{ prefix: string; service: BlueBubblesService }> = [
{ prefix: "imessage:", service: "imessage" },
{ prefix: "sms:", service: "sms" },
{ prefix: "auto:", service: "auto" },
];
function stripPrefix(value: string, prefix: string): string {
return value.slice(prefix.length).trim();
}
export function normalizeBlueBubblesHandle(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "";
const lowered = trimmed.toLowerCase();
if (lowered.startsWith("imessage:")) return normalizeBlueBubblesHandle(trimmed.slice(9));
if (lowered.startsWith("sms:")) return normalizeBlueBubblesHandle(trimmed.slice(4));
if (lowered.startsWith("auto:")) return normalizeBlueBubblesHandle(trimmed.slice(5));
if (trimmed.includes("@")) return trimmed.toLowerCase();
return trimmed.replace(/\s+/g, "");
}
export function parseBlueBubblesTarget(raw: string): BlueBubblesTarget {
const trimmed = raw.trim();
if (!trimmed) throw new Error("BlueBubbles target is required");
const lower = trimmed.toLowerCase();
for (const { prefix, service } of SERVICE_PREFIXES) {
if (lower.startsWith(prefix)) {
const remainder = stripPrefix(trimmed, prefix);
if (!remainder) throw new Error(`${prefix} target is required`);
const remainderLower = remainder.toLowerCase();
const isChatTarget =
CHAT_ID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_GUID_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
CHAT_IDENTIFIER_PREFIXES.some((p) => remainderLower.startsWith(p)) ||
remainderLower.startsWith("group:");
if (isChatTarget) {
return parseBlueBubblesTarget(remainder);
}
return { kind: "handle", to: remainder, service };
}
}
for (const prefix of CHAT_ID_PREFIXES) {
if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix);
const chatId = Number.parseInt(value, 10);
if (!Number.isFinite(chatId)) {
throw new Error(`Invalid chat_id: ${value}`);
}
return { kind: "chat_id", chatId };
}
}
for (const prefix of CHAT_GUID_PREFIXES) {
if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix);
if (!value) throw new Error("chat_guid is required");
return { kind: "chat_guid", chatGuid: value };
}
}
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix);
if (!value) throw new Error("chat_identifier is required");
return { kind: "chat_identifier", chatIdentifier: value };
}
}
if (lower.startsWith("group:")) {
const value = stripPrefix(trimmed, "group:");
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) {
return { kind: "chat_id", chatId };
}
if (!value) throw new Error("group target is required");
return { kind: "chat_guid", chatGuid: value };
}
return { kind: "handle", to: trimmed, service: "auto" };
}
export function parseBlueBubblesAllowTarget(raw: string): BlueBubblesAllowTarget {
const trimmed = raw.trim();
if (!trimmed) return { kind: "handle", handle: "" };
const lower = trimmed.toLowerCase();
for (const { prefix } of SERVICE_PREFIXES) {
if (lower.startsWith(prefix)) {
const remainder = stripPrefix(trimmed, prefix);
if (!remainder) return { kind: "handle", handle: "" };
return parseBlueBubblesAllowTarget(remainder);
}
}
for (const prefix of CHAT_ID_PREFIXES) {
if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix);
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
}
}
for (const prefix of CHAT_GUID_PREFIXES) {
if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix);
if (value) return { kind: "chat_guid", chatGuid: value };
}
}
for (const prefix of CHAT_IDENTIFIER_PREFIXES) {
if (lower.startsWith(prefix)) {
const value = stripPrefix(trimmed, prefix);
if (value) return { kind: "chat_identifier", chatIdentifier: value };
}
}
if (lower.startsWith("group:")) {
const value = stripPrefix(trimmed, "group:");
const chatId = Number.parseInt(value, 10);
if (Number.isFinite(chatId)) return { kind: "chat_id", chatId };
if (value) return { kind: "chat_guid", chatGuid: value };
}
return { kind: "handle", handle: normalizeBlueBubblesHandle(trimmed) };
}
export function isAllowedBlueBubblesSender(params: {
allowFrom: Array<string | number>;
sender: string;
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
}): boolean {
const allowFrom = params.allowFrom.map((entry) => String(entry).trim());
if (allowFrom.length === 0) return true;
if (allowFrom.includes("*")) return true;
const senderNormalized = normalizeBlueBubblesHandle(params.sender);
const chatId = params.chatId ?? undefined;
const chatGuid = params.chatGuid?.trim();
const chatIdentifier = params.chatIdentifier?.trim();
for (const entry of allowFrom) {
if (!entry) continue;
const parsed = parseBlueBubblesAllowTarget(entry);
if (parsed.kind === "chat_id" && chatId !== undefined) {
if (parsed.chatId === chatId) return true;
} else if (parsed.kind === "chat_guid" && chatGuid) {
if (parsed.chatGuid === chatGuid) return true;
} else if (parsed.kind === "chat_identifier" && chatIdentifier) {
if (parsed.chatIdentifier === chatIdentifier) return true;
} else if (parsed.kind === "handle" && senderNormalized) {
if (parsed.handle === senderNormalized) return true;
}
}
return false;
}
export function formatBlueBubblesChatTarget(params: {
chatId?: number | null;
chatGuid?: string | null;
chatIdentifier?: string | null;
}): string {
if (params.chatId && Number.isFinite(params.chatId)) {
return `chat_id:${params.chatId}`;
}
const guid = params.chatGuid?.trim();
if (guid) return `chat_guid:${guid}`;
const identifier = params.chatIdentifier?.trim();
if (identifier) return `chat_identifier:${identifier}`;
return "";
}
-105
View File
@@ -1,105 +0,0 @@
export type DmPolicy = "pairing" | "allowlist" | "open" | "disabled";
export type GroupPolicy = "open" | "disabled" | "allowlist";
export type BlueBubblesAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this BlueBubbles account. Default: true. */
enabled?: boolean;
/** Base URL for the BlueBubbles API. */
serverUrl?: string;
/** Password for BlueBubbles API authentication. */
password?: string;
/** Webhook path for the gateway HTTP server. */
webhookPath?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: DmPolicy;
allowFrom?: Array<string | number>;
/** Optional allowlist for group senders. */
groupAllowFrom?: Array<string | number>;
/** Group message handling policy. */
groupPolicy?: GroupPolicy;
/** Max group messages to keep as history context (0 disables). */
historyLimit?: number;
/** Max DM turns to keep as history context. */
dmHistoryLimit?: number;
/** Per-DM config overrides keyed by user ID. */
dms?: Record<string, unknown>;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: Record<string, unknown>;
/** Max outbound media size in MB. */
mediaMaxMb?: number;
};
export type BlueBubblesActionConfig = {
reactions?: boolean;
};
export type BlueBubblesConfig = {
/** Optional per-account BlueBubbles configuration (multi-account). */
accounts?: Record<string, BlueBubblesAccountConfig>;
/** Per-action tool gating (default: true for all). */
actions?: BlueBubblesActionConfig;
} & BlueBubblesAccountConfig;
export type BlueBubblesSendTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string }
| { kind: "handle"; address: string; service?: "imessage" | "sms" | "auto" };
export type BlueBubblesAttachment = {
guid?: string;
uti?: string;
mimeType?: string;
transferName?: string;
totalBytes?: number;
height?: number;
width?: number;
originalROWID?: number;
};
const DEFAULT_TIMEOUT_MS = 10_000;
export function normalizeBlueBubblesServerUrl(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
throw new Error("BlueBubbles serverUrl is required");
}
const withScheme = /^https?:\/\//i.test(trimmed) ? trimmed : `http://${trimmed}`;
return withScheme.replace(/\/+$/, "");
}
export function buildBlueBubblesApiUrl(params: {
baseUrl: string;
path: string;
password?: string;
}): string {
const normalized = normalizeBlueBubblesServerUrl(params.baseUrl);
const url = new URL(params.path, `${normalized}/`);
if (params.password) {
url.searchParams.set("password", params.password);
}
return url.toString();
}
export async function blueBubblesFetchWithTimeout(
url: string,
init: RequestInit,
timeoutMs = DEFAULT_TIMEOUT_MS,
) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
return await fetch(url, { ...init, signal: controller.signal });
} finally {
clearTimeout(timer);
}
}
-1
View File
@@ -9,7 +9,6 @@
]
},
"dependencies": {
"clawdbot": "workspace:*",
"markdown-it": "14.1.0",
"matrix-js-sdk": "40.0.0"
}
@@ -552,7 +552,6 @@ export async function monitorMatrixProvider(opts: MonitorMatrixOpts = {}): Promi
channel: "matrix",
to: `room:${roomId}`,
accountId: route.accountId,
ctx: ctxPayload,
});
}
-1
View File
@@ -9,7 +9,6 @@
]
},
"dependencies": {
"clawdbot": "workspace:*",
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
@@ -2,7 +2,7 @@ import crypto from "node:crypto";
import { describe, expect, it } from "vitest";
import { verifyPlivoWebhook, verifyTwilioWebhook } from "./webhook-security.js";
import { verifyPlivoWebhook } from "./webhook-security.js";
function canonicalizeBase64(input: string): string {
return Buffer.from(input, "base64").toString("base64");
@@ -71,26 +71,6 @@ function plivoV3Signature(params: {
return canonicalizeBase64(digest);
}
function twilioSignature(params: {
authToken: string;
url: string;
postBody: string;
}): string {
let dataToSign = params.url;
const sortedParams = Array.from(
new URLSearchParams(params.postBody).entries(),
).sort((a, b) => a[0].localeCompare(b[0]));
for (const [key, value] of sortedParams) {
dataToSign += key + value;
}
return crypto
.createHmac("sha1", params.authToken)
.update(dataToSign)
.digest("base64");
}
describe("verifyPlivoWebhook", () => {
it("accepts valid V2 signature", () => {
const authToken = "test-auth-token";
@@ -174,35 +154,3 @@ describe("verifyPlivoWebhook", () => {
});
});
describe("verifyTwilioWebhook", () => {
it("uses request query when publicUrl omits it", () => {
const authToken = "test-auth-token";
const publicUrl = "https://example.com/voice/webhook";
const urlWithQuery = `${publicUrl}?callId=abc`;
const postBody = "CallSid=CS123&CallStatus=completed&From=%2B15550000000";
const signature = twilioSignature({
authToken,
url: urlWithQuery,
postBody,
});
const result = verifyTwilioWebhook(
{
headers: {
host: "example.com",
"x-forwarded-proto": "https",
"x-twilio-signature": signature,
},
rawBody: postBody,
url: "http://local/voice/webhook?callId=abc",
method: "POST",
query: { callId: "abc" },
},
authToken,
{ publicUrl },
);
expect(result.ok).toBe(true);
});
});
+1 -20
View File
@@ -98,25 +98,6 @@ export function reconstructWebhookUrl(ctx: WebhookContext): string {
return `${proto}://${host}${path}`;
}
function buildTwilioVerificationUrl(
ctx: WebhookContext,
publicUrl?: string,
): string {
if (!publicUrl) {
return reconstructWebhookUrl(ctx);
}
try {
const base = new URL(publicUrl);
const requestUrl = new URL(ctx.url);
base.pathname = requestUrl.pathname;
base.search = requestUrl.search;
return base.toString();
} catch {
return publicUrl;
}
}
/**
* Get a header value, handling both string and string[] types.
*/
@@ -173,7 +154,7 @@ export function verifyTwilioWebhook(
}
// Reconstruct the URL Twilio used
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl);
const verificationUrl = options?.publicUrl || reconstructWebhookUrl(ctx);
// Parse the body as URL-encoded params
const params = new URLSearchParams(ctx.rawBody);
-2
View File
@@ -2,14 +2,12 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { zaloDock, zaloPlugin } from "./src/channel.js";
import { handleZaloWebhookRequest } from "./src/monitor.js";
import { setZaloRuntime } from "./src/runtime.js";
const plugin = {
id: "zalo",
name: "Zalo",
description: "Zalo channel plugin (Bot API)",
register(api: ClawdbotPluginApi) {
setZaloRuntime(api.runtime);
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
api.registerHttpHandler(handleZaloWebhookRequest);
},
-1
View File
@@ -9,7 +9,6 @@
]
},
"dependencies": {
"clawdbot": "workspace:*",
"undici": "7.18.2"
}
}
+14 -11
View File
@@ -1,22 +1,25 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import type { ResolvedZaloAccount, ZaloAccountConfig, ZaloConfig } from "./types.js";
import type {
CoreConfig,
ResolvedZaloAccount,
ZaloAccountConfig,
ZaloConfig,
} from "./types.js";
import { resolveZaloToken } from "./token.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listZaloAccountIds(cfg: ClawdbotConfig): string[] {
export function listZaloAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultZaloAccountId(cfg: ClawdbotConfig): string {
export function resolveDefaultZaloAccountId(cfg: CoreConfig): string {
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
if (zaloConfig?.defaultAccount?.trim()) return zaloConfig.defaultAccount.trim();
const ids = listZaloAccountIds(cfg);
@@ -25,7 +28,7 @@ export function resolveDefaultZaloAccountId(cfg: ClawdbotConfig): string {
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
cfg: CoreConfig,
accountId: string,
): ZaloAccountConfig | undefined {
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
@@ -33,7 +36,7 @@ function resolveAccountConfig(
return accounts[accountId] as ZaloAccountConfig | undefined;
}
function mergeZaloAccountConfig(cfg: ClawdbotConfig, accountId: string): ZaloAccountConfig {
function mergeZaloAccountConfig(cfg: CoreConfig, accountId: string): ZaloAccountConfig {
const raw = (cfg.channels?.zalo ?? {}) as ZaloConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
@@ -41,7 +44,7 @@ function mergeZaloAccountConfig(cfg: ClawdbotConfig, accountId: string): ZaloAcc
}
export function resolveZaloAccount(params: {
cfg: ClawdbotConfig;
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedZaloAccount {
const accountId = normalizeAccountId(params.accountId);
@@ -64,7 +67,7 @@ export function resolveZaloAccount(params: {
};
}
export function listEnabledZaloAccounts(cfg: ClawdbotConfig): ResolvedZaloAccount[] {
export function listEnabledZaloAccounts(cfg: CoreConfig): ResolvedZaloAccount[] {
return listZaloAccountIds(cfg)
.map((accountId) => resolveZaloAccount({ cfg, accountId }))
.filter((account) => account.enabled);
+5 -5
View File
@@ -1,16 +1,16 @@
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import { jsonResult, readStringParam } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "./types.js";
import { listEnabledZaloAccounts } from "./accounts.js";
import { sendMessageZalo } from "./send.js";
import { jsonResult, readStringParam } from "./tool-helpers.js";
const providerId = "zalo";
function listEnabledAccounts(cfg: ClawdbotConfig) {
function listEnabledAccounts(cfg: CoreConfig) {
return listEnabledZaloAccounts(cfg).filter(
(account) => account.enabled && account.tokenSource !== "none",
);
@@ -18,7 +18,7 @@ function listEnabledAccounts(cfg: ClawdbotConfig) {
export const zaloMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledAccounts(cfg as ClawdbotConfig);
const accounts = listEnabledAccounts(cfg as CoreConfig);
if (accounts.length === 0) return [];
const actions = new Set<ChannelMessageActionName>(["send"]);
return Array.from(actions);
@@ -44,7 +44,7 @@ export const zaloMessageActions: ChannelMessageActionAdapter = {
const result = await sendMessageZalo(to ?? "", content ?? "", {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
});
if (!result.ok) {
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "./types.js";
import { zaloPlugin } from "./channel.js";
@@ -12,7 +12,7 @@ describe("zalo directory", () => {
allowFrom: ["zalo:123", "zl:234", "345"],
},
},
} as unknown as ClawdbotConfig;
} as unknown as CoreConfig;
expect(zaloPlugin.directory).toBeTruthy();
expect(zaloPlugin.directory?.listPeers).toBeTruthy();
+31 -34
View File
@@ -1,29 +1,25 @@
import type {
ChannelAccountSnapshot,
ChannelDock,
ChannelPlugin,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
PAIRING_APPROVED_MESSAGE,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
import type { ChannelAccountSnapshot, ChannelDock, ChannelPlugin } from "clawdbot/plugin-sdk";
import { buildChannelConfigSchema } from "clawdbot/plugin-sdk";
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
import { zaloMessageActions } from "./actions.js";
import { ZaloConfigSchema } from "./config-schema.js";
import {
deleteAccountFromConfigSection,
setAccountEnabledInConfigSection,
} from "./shared/channel-config.js";
import { zaloOnboardingAdapter } from "./onboarding.js";
import { formatPairingApproveHint, PAIRING_APPROVED_MESSAGE } from "./shared/pairing.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import { probeZalo } from "./probe.js";
import { sendMessageZalo } from "./send.js";
import {
applyAccountNameToChannelSection,
migrateBaseNameToDefaultAccount,
} from "./shared/channel-setup.js";
import { collectZaloStatusIssues } from "./status-issues.js";
import type { CoreConfig } from "./types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
const meta = {
id: "zalo",
@@ -37,6 +33,7 @@ const meta = {
quickstartAllowFrom: true,
};
function normalizeZaloMessagingTarget(raw: string): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
@@ -53,7 +50,7 @@ export const zaloDock: ChannelDock = {
outbound: { textChunkLimit: 2000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
@@ -87,12 +84,12 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
reload: { configPrefixes: ["channels.zalo"] },
configSchema: buildChannelConfigSchema(ZaloConfigSchema),
config: {
listAccountIds: (cfg) => listZaloAccountIds(cfg as ClawdbotConfig),
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg as ClawdbotConfig),
listAccountIds: (cfg) => listZaloAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) => resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultZaloAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
sectionKey: "zalo",
accountId,
enabled,
@@ -100,7 +97,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
sectionKey: "zalo",
accountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
@@ -114,7 +111,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
@@ -128,7 +125,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as ClawdbotConfig).channels?.zalo?.accounts?.[resolvedAccountId],
(cfg as CoreConfig).channels?.zalo?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.zalo.accounts.${resolvedAccountId}.`
@@ -164,7 +161,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
directory: {
self: async () => null,
listPeers: async ({ cfg, accountId, query, limit }) => {
const account = resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId });
const account = resolveZaloAccount({ cfg: cfg as CoreConfig, accountId });
const q = query?.trim().toLowerCase() || "";
const peers = Array.from(
new Set(
@@ -185,7 +182,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
channelKey: "zalo",
accountId,
name,
@@ -201,7 +198,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
channelKey: "zalo",
accountId,
name: input.name,
@@ -230,7 +227,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
: {}),
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
return {
...next,
@@ -253,14 +250,14 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
},
},
},
} as ClawdbotConfig;
} as CoreConfig;
},
},
pairing: {
idLabel: "zaloUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveZaloAccount({ cfg: cfg as ClawdbotConfig });
const account = resolveZaloAccount({ cfg: cfg as CoreConfig });
if (!account.token) throw new Error("Zalo token not configured");
await sendMessageZalo(id, PAIRING_APPROVED_MESSAGE, { token: account.token });
},
@@ -292,7 +289,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
});
return {
channel: "zalo",
@@ -305,7 +302,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,
mediaUrl,
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
});
return {
channel: "zalo",
@@ -378,7 +375,7 @@ export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
return monitorZaloProvider({
token,
account,
config: ctx.cfg as ClawdbotConfig,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
useWebhook: Boolean(account.config.webhookUrl),
+176
View File
@@ -0,0 +1,176 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export type CoreChannelDeps = {
chunkMarkdownText: (text: string, limit: number) => string[];
formatAgentEnvelope: (params: {
channel: string;
from: string;
timestamp?: number;
body: string;
}) => string;
dispatchReplyWithBufferedBlockDispatcher: (params: {
ctx: unknown;
cfg: unknown;
dispatcherOptions: {
deliver: (payload: unknown) => Promise<void>;
onError?: (err: unknown, info: { kind: string }) => void;
};
}) => Promise<void>;
resolveAgentRoute: (params: {
cfg: unknown;
channel: string;
accountId: string;
peer: { kind: "dm" | "group" | "channel"; id: string };
}) => { sessionKey: string; accountId: string };
buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string;
readChannelAllowFromStore: (channel: string) => Promise<string[]>;
upsertChannelPairingRequest: (params: {
channel: string;
id: string;
meta?: { name?: string };
pairingAdapter?: {
idLabel: string;
normalizeAllowEntry?: (entry: string) => string;
notifyApproval?: (params: { cfg: unknown; id: string; runtime?: unknown }) => Promise<void>;
};
}) => Promise<{ code: string; created: boolean }>;
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
saveMediaBuffer: (
buffer: Buffer,
contentType: string | undefined,
type: "inbound" | "outbound",
maxBytes: number,
) => Promise<{ path: string; contentType: string }>;
shouldLogVerbose: () => boolean;
};
let coreRootCache: string | null = null;
let coreDepsPromise: Promise<CoreChannelDeps> | null = null;
function findPackageRoot(startDir: string, name: string): string | null {
let dir = startDir;
for (;;) {
const pkgPath = path.join(dir, "package.json");
try {
if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, "utf8");
const pkg = JSON.parse(raw) as { name?: string };
if (pkg.name === name) return dir;
}
} catch {
// ignore parse errors
}
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function resolveClawdbotRoot(): string {
if (coreRootCache) return coreRootCache;
const override = process.env.CLAWDBOT_ROOT?.trim();
if (override) {
coreRootCache = override;
return override;
}
const candidates = new Set<string>();
if (process.argv[1]) {
candidates.add(path.dirname(process.argv[1]));
}
candidates.add(process.cwd());
try {
const urlPath = fileURLToPath(import.meta.url);
candidates.add(path.dirname(urlPath));
} catch {
// ignore
}
for (const start of candidates) {
const found = findPackageRoot(start, "clawdbot");
if (found) {
coreRootCache = found;
return found;
}
}
throw new Error(
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
);
}
async function importCoreModule<T>(relativePath: string): Promise<T> {
const root = resolveClawdbotRoot();
const distPath = path.join(root, "dist", relativePath);
if (!fs.existsSync(distPath)) {
throw new Error(
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
);
}
return (await import(pathToFileURL(distPath).href)) as T;
}
export async function loadCoreChannelDeps(): Promise<CoreChannelDeps> {
if (coreDepsPromise) return coreDepsPromise;
coreDepsPromise = (async () => {
const [
chunk,
envelope,
dispatcher,
routing,
pairingMessages,
pairingStore,
mediaFetch,
mediaStore,
globals,
] = await Promise.all([
importCoreModule<{ chunkMarkdownText: CoreChannelDeps["chunkMarkdownText"] }>(
"auto-reply/chunk.js",
),
importCoreModule<{ formatAgentEnvelope: CoreChannelDeps["formatAgentEnvelope"] }>(
"auto-reply/envelope.js",
),
importCoreModule<{
dispatchReplyWithBufferedBlockDispatcher: CoreChannelDeps["dispatchReplyWithBufferedBlockDispatcher"];
}>("auto-reply/reply/provider-dispatcher.js"),
importCoreModule<{ resolveAgentRoute: CoreChannelDeps["resolveAgentRoute"] }>(
"routing/resolve-route.js",
),
importCoreModule<{ buildPairingReply: CoreChannelDeps["buildPairingReply"] }>(
"pairing/pairing-messages.js",
),
importCoreModule<{
readChannelAllowFromStore: CoreChannelDeps["readChannelAllowFromStore"];
upsertChannelPairingRequest: CoreChannelDeps["upsertChannelPairingRequest"];
}>("pairing/pairing-store.js"),
importCoreModule<{ fetchRemoteMedia: CoreChannelDeps["fetchRemoteMedia"] }>(
"media/fetch.js",
),
importCoreModule<{ saveMediaBuffer: CoreChannelDeps["saveMediaBuffer"] }>(
"media/store.js",
),
importCoreModule<{ shouldLogVerbose: CoreChannelDeps["shouldLogVerbose"] }>(
"globals.js",
),
]);
return {
chunkMarkdownText: chunk.chunkMarkdownText,
formatAgentEnvelope: envelope.formatAgentEnvelope,
dispatchReplyWithBufferedBlockDispatcher:
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
resolveAgentRoute: routing.resolveAgentRoute,
buildPairingReply: pairingMessages.buildPairingReply,
readChannelAllowFromStore: pairingStore.readChannelAllowFromStore,
upsertChannelPairingRequest: pairingStore.upsertChannelPairingRequest,
fetchRemoteMedia: mediaFetch.fetchRemoteMedia,
saveMediaBuffer: mediaStore.saveMediaBuffer,
shouldLogVerbose: globals.shouldLogVerbose,
};
})();
return coreDepsPromise;
}
+54 -62
View File
@@ -1,17 +1,14 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
finalizeInboundContext,
formatAgentEnvelope,
isControlCommandMessage,
recordSessionMetaFromInbound,
resolveCommandAuthorizedFromAuthorizers,
resolveStorePath,
shouldComputeCommandAuthorized,
type ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
ZaloApiError,
deleteWebhook,
@@ -23,8 +20,10 @@ import {
type ZaloMessage,
type ZaloUpdate,
} from "./api.js";
import { zaloPlugin } from "./channel.js";
import { loadCoreChannelDeps } from "./core-bridge.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import { getZaloRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
export type ZaloRuntimeEnv = {
log?: (message: string) => void;
@@ -34,7 +33,7 @@ export type ZaloRuntimeEnv = {
export type ZaloMonitorOptions = {
token: string;
account: ResolvedZaloAccount;
config: ClawdbotConfig;
config: CoreConfig;
runtime: ZaloRuntimeEnv;
abortSignal: AbortSignal;
useWebhook?: boolean;
@@ -52,11 +51,9 @@ export type ZaloMonitorResult = {
const ZALO_TEXT_LIMIT = 2000;
const DEFAULT_MEDIA_MAX_MB = 5;
type ZaloCoreRuntime = ReturnType<typeof getZaloRuntime>;
function logVerbose(core: ZaloCoreRuntime, runtime: ZaloRuntimeEnv, message: string): void {
if (core.logging.shouldLogVerbose()) {
runtime.log?.(`[zalo] ${message}`);
function logVerbose(deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>, message: string): void {
if (deps.shouldLogVerbose()) {
console.log(`[zalo] ${message}`);
}
}
@@ -103,9 +100,9 @@ async function readJsonBody(req: IncomingMessage, maxBytes: number) {
type WebhookTarget = {
token: string;
account: ResolvedZaloAccount;
config: ClawdbotConfig;
config: CoreConfig;
runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
secret: string;
path: string;
mediaMaxMb: number;
@@ -210,7 +207,7 @@ export async function handleZaloWebhookRequest(
target.account,
target.config,
target.runtime,
target.core,
target.deps,
target.mediaMaxMb,
target.statusSink,
target.fetcher,
@@ -226,9 +223,9 @@ export async function handleZaloWebhookRequest(
function startPollingLoop(params: {
token: string;
account: ResolvedZaloAccount;
config: ClawdbotConfig;
config: CoreConfig;
runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
abortSignal: AbortSignal;
isStopped: () => boolean;
mediaMaxMb: number;
@@ -240,7 +237,7 @@ function startPollingLoop(params: {
account,
config,
runtime,
core,
deps,
abortSignal,
isStopped,
mediaMaxMb,
@@ -262,7 +259,7 @@ function startPollingLoop(params: {
account,
config,
runtime,
core,
deps,
mediaMaxMb,
statusSink,
fetcher,
@@ -289,9 +286,9 @@ async function processUpdate(
update: ZaloUpdate,
token: string,
account: ResolvedZaloAccount,
config: ClawdbotConfig,
config: CoreConfig,
runtime: ZaloRuntimeEnv,
core: ZaloCoreRuntime,
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
mediaMaxMb: number,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
@@ -307,7 +304,7 @@ async function processUpdate(
account,
config,
runtime,
core,
deps,
statusSink,
fetcher,
);
@@ -319,7 +316,7 @@ async function processUpdate(
account,
config,
runtime,
core,
deps,
mediaMaxMb,
statusSink,
fetcher,
@@ -340,9 +337,9 @@ async function handleTextMessage(
message: ZaloMessage,
token: string,
account: ResolvedZaloAccount,
config: ClawdbotConfig,
config: CoreConfig,
runtime: ZaloRuntimeEnv,
core: ZaloCoreRuntime,
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
): Promise<void> {
@@ -355,7 +352,7 @@ async function handleTextMessage(
account,
config,
runtime,
core,
deps,
text,
mediaPath: undefined,
mediaType: undefined,
@@ -368,9 +365,9 @@ async function handleImageMessage(
message: ZaloMessage,
token: string,
account: ResolvedZaloAccount,
config: ClawdbotConfig,
config: CoreConfig,
runtime: ZaloRuntimeEnv,
core: ZaloCoreRuntime,
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
mediaMaxMb: number,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
@@ -383,8 +380,8 @@ async function handleImageMessage(
if (photo) {
try {
const maxBytes = mediaMaxMb * 1024 * 1024;
const fetched = await core.channel.media.fetchRemoteMedia({ url: photo });
const saved = await core.channel.media.saveMediaBuffer(
const fetched = await deps.fetchRemoteMedia({ url: photo });
const saved = await deps.saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
@@ -403,7 +400,7 @@ async function handleImageMessage(
account,
config,
runtime,
core,
deps,
text: caption,
mediaPath,
mediaType,
@@ -416,9 +413,9 @@ async function processMessageWithPipeline(params: {
message: ZaloMessage;
token: string;
account: ResolvedZaloAccount;
config: ClawdbotConfig;
config: CoreConfig;
runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
text?: string;
mediaPath?: string;
mediaType?: string;
@@ -431,7 +428,7 @@ async function processMessageWithPipeline(params: {
account,
config,
runtime,
core,
deps,
text,
mediaPath,
mediaType,
@@ -451,7 +448,7 @@ async function processMessageWithPipeline(params: {
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("zalo").catch(() => [])
? await deps.readChannelAllowFromStore("zalo").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const useAccessGroups = config.commands?.useAccessGroups !== false;
@@ -465,7 +462,7 @@ async function processMessageWithPipeline(params: {
if (!isGroup) {
if (dmPolicy === "disabled") {
logVerbose(core, runtime, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
logVerbose(deps, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
return;
}
@@ -474,20 +471,21 @@ async function processMessageWithPipeline(params: {
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
const { code, created } = await deps.upsertChannelPairingRequest({
channel: "zalo",
id: senderId,
meta: { name: senderName ?? undefined },
pairingAdapter: zaloPlugin.pairing,
});
if (created) {
logVerbose(core, runtime, `zalo pairing request sender=${senderId}`);
logVerbose(deps, `zalo pairing request sender=${senderId}`);
try {
await sendMessage(
token,
{
chat_id: chatId,
text: core.channel.pairing.buildPairingReply({
text: deps.buildPairingReply({
channel: "zalo",
idLine: `Your Zalo user id: ${senderId}`,
code,
@@ -497,26 +495,18 @@ async function processMessageWithPipeline(params: {
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(
core,
runtime,
`zalo pairing reply failed for ${senderId}: ${String(err)}`,
);
logVerbose(deps, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
}
}
} else {
logVerbose(
core,
runtime,
`Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`,
);
logVerbose(deps, `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`);
}
return;
}
}
}
const route = core.channel.routing.resolveAgentRoute({
const route = deps.resolveAgentRoute({
cfg: config,
channel: "zalo",
accountId: account.accountId,
@@ -527,14 +517,16 @@ async function processMessageWithPipeline(params: {
});
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
logVerbose(core, runtime, `zalo: drop control command from unauthorized sender ${senderId}`);
logVerbose(deps, `zalo: drop control command from unauthorized sender ${senderId}`);
return;
}
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const body = formatAgentEnvelope({
channel: "Zalo",
from: fromLabel,
const fromLabel = isGroup
? `group:${chatId}`
: senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
channel: "Zalo",
from: fromLabel,
timestamp: date ? date * 1000 : undefined,
body: rawBody,
});
@@ -573,7 +565,7 @@ async function processMessageWithPipeline(params: {
runtime.error?.(`zalo: failed updating session meta: ${String(err)}`);
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
await deps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
@@ -583,7 +575,7 @@ async function processMessageWithPipeline(params: {
token,
chatId,
runtime,
core,
deps,
statusSink,
fetcher,
});
@@ -600,11 +592,11 @@ async function deliverZaloReply(params: {
token: string;
chatId: string;
runtime: ZaloRuntimeEnv;
core: ZaloCoreRuntime;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
}): Promise<void> {
const { payload, token, chatId, runtime, core, statusSink, fetcher } = params;
const { payload, token, chatId, runtime, deps, statusSink, fetcher } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
@@ -628,7 +620,7 @@ async function deliverZaloReply(params: {
}
if (payload.text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
const chunks = deps.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
for (const chunk of chunks) {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
@@ -657,7 +649,7 @@ export async function monitorZaloProvider(
fetcher: fetcherOverride,
} = options;
const core = getZaloRuntime();
const deps = await loadCoreChannelDeps();
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
@@ -694,7 +686,7 @@ export async function monitorZaloProvider(
account,
config,
runtime,
core,
deps,
path,
secret: webhookSecret,
statusSink: (patch) => statusSink?.(patch),
@@ -723,7 +715,7 @@ export async function monitorZaloProvider(
account,
config,
runtime,
core,
deps,
abortSignal,
isStopped: () => stopped,
mediaMaxMb: effectiveMediaMaxMb,
+5 -5
View File
@@ -3,8 +3,8 @@ import type { AddressInfo } from "node:net";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig, PluginRuntime } from "clawdbot/plugin-sdk";
import type { ResolvedZaloAccount } from "./types.js";
import type { CoreConfig, ResolvedZaloAccount } from "./types.js";
import type { loadCoreChannelDeps } from "./core-bridge.js";
import { handleZaloWebhookRequest, registerZaloWebhookTarget } from "./monitor.js";
async function withServer(
@@ -26,7 +26,7 @@ async function withServer(
describe("handleZaloWebhookRequest", () => {
it("returns 400 for non-object payloads", async () => {
const core = {} as PluginRuntime;
const deps = {} as Awaited<ReturnType<typeof loadCoreChannelDeps>>;
const account: ResolvedZaloAccount = {
accountId: "default",
enabled: true,
@@ -37,9 +37,9 @@ describe("handleZaloWebhookRequest", () => {
const unregister = registerZaloWebhookTarget({
token: "tok",
account,
config: {} as ClawdbotConfig,
config: {} as CoreConfig,
runtime: {},
core,
deps,
secret: "secret",
path: "/hook",
mediaMaxMb: 5,
+24 -31
View File
@@ -1,30 +1,23 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
ClawdbotConfig,
WizardPrompter,
} from "clawdbot/plugin-sdk";
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
promptAccountId,
} from "clawdbot/plugin-sdk";
import { addWildcardAllowFrom, promptAccountId } from "./shared/onboarding.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./shared/account-ids.js";
import {
listZaloAccountIds,
resolveDefaultZaloAccountId,
resolveZaloAccount,
} from "./accounts.js";
import type { CoreConfig } from "./types.js";
const channel = "zalo" as const;
type UpdateMode = "polling" | "webhook";
function setZaloDmPolicy(
cfg: ClawdbotConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
) {
function setZaloDmPolicy(cfg: CoreConfig, dmPolicy: "pairing" | "allowlist" | "open" | "disabled") {
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
return {
...cfg,
@@ -36,17 +29,17 @@ function setZaloDmPolicy(
...(allowFrom ? { allowFrom } : {}),
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
function setZaloUpdateMode(
cfg: ClawdbotConfig,
cfg: CoreConfig,
accountId: string,
mode: UpdateMode,
webhookUrl?: string,
webhookSecret?: string,
webhookPath?: string,
): ClawdbotConfig {
): CoreConfig {
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (mode === "polling") {
if (isDefault) {
@@ -62,7 +55,7 @@ function setZaloUpdateMode(
...cfg.channels,
zalo: rest,
},
} as ClawdbotConfig;
} as CoreConfig;
}
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
string,
@@ -85,7 +78,7 @@ function setZaloUpdateMode(
accounts,
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
if (isDefault) {
@@ -100,7 +93,7 @@ function setZaloUpdateMode(
webhookPath,
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
@@ -122,7 +115,7 @@ function setZaloUpdateMode(
accounts,
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
@@ -139,10 +132,10 @@ async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
}
async function promptZaloAllowFrom(params: {
cfg: ClawdbotConfig;
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<ClawdbotConfig> {
}): Promise<CoreConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveZaloAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
@@ -176,7 +169,7 @@ async function promptZaloAllowFrom(params: {
allowFrom: unique,
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
return {
@@ -197,7 +190,7 @@ async function promptZaloAllowFrom(params: {
},
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
const dmPolicy: ChannelOnboardingDmPolicy = {
@@ -206,15 +199,15 @@ const dmPolicy: ChannelOnboardingDmPolicy = {
policyKey: "channels.zalo.dmPolicy",
allowFromKey: "channels.zalo.allowFrom",
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as ClawdbotConfig, policy),
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as CoreConfig, policy),
};
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const configured = listZaloAccountIds(cfg as ClawdbotConfig).some((accountId) =>
Boolean(resolveZaloAccount({ cfg: cfg as ClawdbotConfig, accountId }).token),
const configured = listZaloAccountIds(cfg as CoreConfig).some((accountId) =>
Boolean(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).token),
);
return {
channel,
@@ -226,13 +219,13 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
const zaloOverride = accountOverrides.zalo?.trim();
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg as ClawdbotConfig);
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg as CoreConfig);
let zaloAccountId = zaloOverride
? normalizeAccountId(zaloOverride)
: defaultZaloAccountId;
if (shouldPromptAccountIds && !zaloOverride) {
zaloAccountId = await promptAccountId({
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
prompter,
label: "Zalo",
currentId: zaloAccountId,
@@ -241,7 +234,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
});
}
let next = cfg as ClawdbotConfig;
let next = cfg as CoreConfig;
const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
const accountConfigured = Boolean(resolvedAccount.token);
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
@@ -269,7 +262,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
enabled: true,
},
},
} as ClawdbotConfig;
} as CoreConfig;
} else {
token = String(
await prompter.text({
@@ -312,7 +305,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
botToken: token,
},
},
} as ClawdbotConfig;
} as CoreConfig;
} else {
next = {
...next,
@@ -331,7 +324,7 @@ export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
},
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
}
-14
View File
@@ -1,14 +0,0 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setZaloRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getZaloRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Zalo runtime not initialized");
}
return runtime;
}
+2 -3
View File
@@ -1,5 +1,4 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import type { CoreConfig } from "./types.js";
import type { ZaloFetch } from "./api.js";
import { sendMessage, sendPhoto } from "./api.js";
import { resolveZaloAccount } from "./accounts.js";
@@ -9,7 +8,7 @@ import { resolveZaloToken } from "./token.js";
export type ZaloSendOptions = {
token?: string;
accountId?: string;
cfg?: ClawdbotConfig;
cfg?: CoreConfig;
mediaUrl?: string;
caption?: string;
verbose?: boolean;
+15
View File
@@ -0,0 +1,15 @@
export const DEFAULT_ACCOUNT_ID = "default";
export function normalizeAccountId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID;
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
return (
trimmed
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 64) || DEFAULT_ACCOUNT_ID
);
}
@@ -0,0 +1,112 @@
import { DEFAULT_ACCOUNT_ID } from "./account-ids.js";
type ChannelSection = {
accounts?: Record<string, Record<string, unknown>>;
enabled?: boolean;
};
type ConfigWithChannels = {
channels?: Record<string, unknown>;
};
export function setAccountEnabledInConfigSection<T extends ConfigWithChannels>(params: {
cfg: T;
sectionKey: string;
accountId: string;
enabled: boolean;
allowTopLevel?: boolean;
}): T {
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
const channels = params.cfg.channels;
const base = (channels?.[params.sectionKey] as ChannelSection | undefined) ?? undefined;
const hasAccounts = Boolean(base?.accounts);
if (params.allowTopLevel && accountKey === DEFAULT_ACCOUNT_ID && !hasAccounts) {
return {
...params.cfg,
channels: {
...channels,
[params.sectionKey]: {
...base,
enabled: params.enabled,
},
},
} as T;
}
const baseAccounts = (base?.accounts ?? {}) as Record<string, Record<string, unknown>>;
const existing = baseAccounts[accountKey] ?? {};
return {
...params.cfg,
channels: {
...channels,
[params.sectionKey]: {
...base,
accounts: {
...baseAccounts,
[accountKey]: {
...existing,
enabled: params.enabled,
},
},
},
},
} as T;
}
export function deleteAccountFromConfigSection<T extends ConfigWithChannels>(params: {
cfg: T;
sectionKey: string;
accountId: string;
clearBaseFields?: string[];
}): T {
const accountKey = params.accountId || DEFAULT_ACCOUNT_ID;
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const base = (channels?.[params.sectionKey] as ChannelSection | undefined) ?? undefined;
if (!base) return params.cfg;
const baseAccounts =
base.accounts && typeof base.accounts === "object" ? { ...base.accounts } : undefined;
if (accountKey !== DEFAULT_ACCOUNT_ID) {
const accounts = baseAccounts ? { ...baseAccounts } : {};
delete accounts[accountKey];
return {
...params.cfg,
channels: {
...channels,
[params.sectionKey]: {
...base,
accounts: Object.keys(accounts).length ? accounts : undefined,
},
},
} as T;
}
if (baseAccounts && Object.keys(baseAccounts).length > 0) {
delete baseAccounts[accountKey];
const baseRecord = { ...(base as Record<string, unknown>) };
for (const field of params.clearBaseFields ?? []) {
if (field in baseRecord) baseRecord[field] = undefined;
}
return {
...params.cfg,
channels: {
...channels,
[params.sectionKey]: {
...baseRecord,
accounts: Object.keys(baseAccounts).length ? baseAccounts : undefined,
},
},
} as T;
}
const nextChannels = { ...channels } as Record<string, unknown>;
delete nextChannels[params.sectionKey];
const nextCfg = { ...params.cfg } as T;
if (Object.keys(nextChannels).length > 0) {
nextCfg.channels = nextChannels as T["channels"];
} else {
delete nextCfg.channels;
}
return nextCfg;
}
+114
View File
@@ -0,0 +1,114 @@
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-ids.js";
type ConfigWithChannels = {
channels?: Record<string, unknown>;
};
type ChannelSectionBase = {
name?: string;
accounts?: Record<string, Record<string, unknown>>;
};
function channelHasAccounts(cfg: ConfigWithChannels, channelKey: string): boolean {
const channels = cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[channelKey] as ChannelSectionBase | undefined;
return Boolean(base?.accounts && Object.keys(base.accounts).length > 0);
}
function shouldStoreNameInAccounts(params: {
cfg: ConfigWithChannels;
channelKey: string;
accountId: string;
alwaysUseAccounts?: boolean;
}): boolean {
if (params.alwaysUseAccounts) return true;
if (params.accountId !== DEFAULT_ACCOUNT_ID) return true;
return channelHasAccounts(params.cfg, params.channelKey);
}
export function applyAccountNameToChannelSection<T extends ConfigWithChannels>(params: {
cfg: T;
channelKey: string;
accountId: string;
name?: string;
alwaysUseAccounts?: boolean;
}): T {
const trimmed = params.name?.trim();
if (!trimmed) return params.cfg;
const accountId = normalizeAccountId(params.accountId);
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const baseConfig = channels?.[params.channelKey];
const base =
typeof baseConfig === "object" && baseConfig ? (baseConfig as ChannelSectionBase) : undefined;
const useAccounts = shouldStoreNameInAccounts({
cfg: params.cfg,
channelKey: params.channelKey,
accountId,
alwaysUseAccounts: params.alwaysUseAccounts,
});
if (!useAccounts && accountId === DEFAULT_ACCOUNT_ID) {
const safeBase = base ?? {};
return {
...params.cfg,
channels: {
...channels,
[params.channelKey]: {
...safeBase,
name: trimmed,
},
},
} as T;
}
const baseAccounts: Record<string, Record<string, unknown>> = base?.accounts ?? {};
const existingAccount = baseAccounts[accountId] ?? {};
const baseWithoutName =
accountId === DEFAULT_ACCOUNT_ID
? (({ name: _ignored, ...rest }) => rest)(base ?? {})
: (base ?? {});
return {
...params.cfg,
channels: {
...channels,
[params.channelKey]: {
...baseWithoutName,
accounts: {
...baseAccounts,
[accountId]: {
...existingAccount,
name: trimmed,
},
},
},
},
} as T;
}
export function migrateBaseNameToDefaultAccount<T extends ConfigWithChannels>(params: {
cfg: T;
channelKey: string;
alwaysUseAccounts?: boolean;
}): T {
if (params.alwaysUseAccounts) return params.cfg;
const channels = params.cfg.channels as Record<string, unknown> | undefined;
const base = channels?.[params.channelKey] as ChannelSectionBase | undefined;
const baseName = base?.name?.trim();
if (!baseName) return params.cfg;
const accounts: Record<string, Record<string, unknown>> = {
...base?.accounts,
};
const defaultAccount = accounts[DEFAULT_ACCOUNT_ID] ?? {};
if (!defaultAccount.name) {
accounts[DEFAULT_ACCOUNT_ID] = { ...defaultAccount, name: baseName };
}
const { name: _ignored, ...rest } = base ?? {};
return {
...params.cfg,
channels: {
...channels,
[params.channelKey]: {
...rest,
accounts,
},
},
} as T;
}
+53
View File
@@ -0,0 +1,53 @@
import type { WizardPrompter } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "./account-ids.js";
export type PromptAccountIdParams<TConfig> = {
cfg: TConfig;
prompter: WizardPrompter;
label: string;
currentId?: string;
listAccountIds: (cfg: TConfig) => string[];
defaultAccountId: string;
};
export async function promptAccountId<TConfig>(
params: PromptAccountIdParams<TConfig>,
): Promise<string> {
const existingIds = params.listAccountIds(params.cfg);
const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
const choice = (await params.prompter.select({
message: `${params.label} account`,
options: [
...existingIds.map((id) => ({
value: id,
label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
})),
{ value: "__new__", label: "Add a new account" },
],
initialValue: initial,
})) as string;
if (choice !== "__new__") return normalizeAccountId(choice);
const entered = await params.prompter.text({
message: `New ${params.label} account id`,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
const normalized = normalizeAccountId(String(entered));
if (String(entered).trim() !== normalized) {
await params.prompter.note(
`Normalized account id to "${normalized}".`,
`${params.label} account`,
);
}
return normalized;
}
export function addWildcardAllowFrom(
allowFrom?: Array<string | number> | null,
): Array<string | number> {
const next = (allowFrom ?? []).map((v) => String(v).trim()).filter(Boolean);
if (!next.includes("*")) next.push("*");
return next;
}
+6
View File
@@ -0,0 +1,6 @@
export const PAIRING_APPROVED_MESSAGE =
"\u2705 Clawdbot access approved. Send a message to start chatting.";
export function formatPairingApproveHint(channelId: string): string {
return `Approve via: clawdbot pairing list ${channelId} / clawdbot pairing approve ${channelId} <code>`;
}
+1 -2
View File
@@ -1,8 +1,7 @@
import { readFileSync } from "node:fs";
import { DEFAULT_ACCOUNT_ID } from "clawdbot/plugin-sdk";
import type { ZaloConfig } from "./types.js";
import { DEFAULT_ACCOUNT_ID } from "./shared/account-ids.js";
export type ZaloTokenResolution = {
token: string;
+30
View File
@@ -0,0 +1,30 @@
export function readStringParam(
params: Record<string, unknown>,
key: string,
opts?: { required?: boolean; allowEmpty?: boolean; trim?: boolean },
): string | undefined {
const raw = params[key];
if (raw === undefined || raw === null) {
if (opts?.required) throw new Error(`${key} is required`);
return undefined;
}
const value = String(raw);
const trimmed = opts?.trim === false ? value : value.trim();
if (!opts?.allowEmpty && !trimmed) {
if (opts?.required) throw new Error(`${key} is required`);
return undefined;
}
return trimmed;
}
export function jsonResult(payload: unknown) {
return {
content: [
{
type: "text",
text: JSON.stringify(payload, null, 2),
},
],
details: payload,
};
}
+7
View File
@@ -40,3 +40,10 @@ export type ResolvedZaloAccount = {
tokenSource: ZaloTokenSource;
config: ZaloAccountConfig;
};
export type CoreConfig = {
channels?: {
zalo?: ZaloConfig;
};
[key: string]: unknown;
};
-2
View File
@@ -2,14 +2,12 @@ import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { zalouserPlugin } from "./src/channel.js";
import { ZalouserToolSchema, executeZalouserTool } from "./src/tool.js";
import { setZalouserRuntime } from "./src/runtime.js";
const plugin = {
id: "zalouser",
name: "Zalo Personal",
description: "Zalo personal account messaging via zca-cli",
register(api: ClawdbotPluginApi) {
setZalouserRuntime(api.runtime);
// Register channel plugin (for onboarding & gateway)
api.registerChannel(zalouserPlugin);
-1
View File
@@ -4,7 +4,6 @@
"type": "module",
"description": "Clawdbot Zalo Personal Account plugin via zca-cli",
"dependencies": {
"clawdbot": "workspace:*",
"@sinclair/typebox": "0.34.47"
},
"clawdbot": {
+21 -17
View File
@@ -1,22 +1,25 @@
import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
import { runZca, parseJsonOutput } from "./zca.js";
import type { ResolvedZalouserAccount, ZalouserAccountConfig, ZalouserConfig } from "./types.js";
import {
DEFAULT_ACCOUNT_ID,
type CoreConfig,
type ResolvedZalouserAccount,
type ZalouserAccountConfig,
type ZalouserConfig,
} from "./types.js";
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listZalouserAccountIds(cfg: ClawdbotConfig): string[] {
export function listZalouserAccountIds(cfg: CoreConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultZalouserAccountId(cfg: ClawdbotConfig): string {
export function resolveDefaultZalouserAccountId(cfg: CoreConfig): string {
const zalouserConfig = cfg.channels?.zalouser as ZalouserConfig | undefined;
if (zalouserConfig?.defaultAccount?.trim()) return zalouserConfig.defaultAccount.trim();
const ids = listZalouserAccountIds(cfg);
@@ -24,8 +27,14 @@ export function resolveDefaultZalouserAccountId(cfg: ClawdbotConfig): string {
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
export function normalizeAccountId(accountId?: string | null): string {
const trimmed = accountId?.trim();
if (!trimmed) return DEFAULT_ACCOUNT_ID;
return trimmed.toLowerCase();
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
cfg: CoreConfig,
accountId: string,
): ZalouserAccountConfig | undefined {
const accounts = (cfg.channels?.zalouser as ZalouserConfig | undefined)?.accounts;
@@ -33,10 +42,7 @@ function resolveAccountConfig(
return accounts[accountId] as ZalouserAccountConfig | undefined;
}
function mergeZalouserAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): ZalouserAccountConfig {
function mergeZalouserAccountConfig(cfg: CoreConfig, accountId: string): ZalouserAccountConfig {
const raw = (cfg.channels?.zalouser ?? {}) as ZalouserConfig;
const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw;
const account = resolveAccountConfig(cfg, accountId) ?? {};
@@ -56,7 +62,7 @@ export async function checkZcaAuthenticated(profile: string): Promise<boolean> {
}
export async function resolveZalouserAccount(params: {
cfg: ClawdbotConfig;
cfg: CoreConfig;
accountId?: string | null;
}): Promise<ResolvedZalouserAccount> {
const accountId = normalizeAccountId(params.accountId);
@@ -78,7 +84,7 @@ export async function resolveZalouserAccount(params: {
}
export function resolveZalouserAccountSync(params: {
cfg: ClawdbotConfig;
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedZalouserAccount {
const accountId = normalizeAccountId(params.accountId);
@@ -98,9 +104,7 @@ export function resolveZalouserAccountSync(params: {
};
}
export async function listEnabledZalouserAccounts(
cfg: ClawdbotConfig,
): Promise<ResolvedZalouserAccount[]> {
export async function listEnabledZalouserAccounts(cfg: CoreConfig): Promise<ResolvedZalouserAccount[]> {
const ids = listZalouserAccountIds(cfg);
const accounts = await Promise.all(
ids.map((accountId) => resolveZalouserAccount({ cfg, accountId }))
+88 -36
View File
@@ -1,16 +1,6 @@
import type {
ChannelAccountSnapshot,
ChannelDirectoryEntry,
ChannelPlugin,
ClawdbotConfig,
} from "clawdbot/plugin-sdk";
import {
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
normalizeAccountId,
setAccountEnabledInConfigSection,
} from "clawdbot/plugin-sdk";
import type { ChannelAccountSnapshot, ChannelDirectoryEntry, ChannelPlugin } from "clawdbot/plugin-sdk";
import { formatPairingApproveHint } from "clawdbot/plugin-sdk";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
@@ -22,7 +12,14 @@ import {
import { zalouserOnboardingAdapter } from "./onboarding.js";
import { sendMessageZalouser } from "./send.js";
import { checkZcaInstalled, parseJsonOutput, runZca, runZcaInteractive } from "./zca.js";
import type { ZcaFriend, ZcaGroup, ZcaUserInfo } from "./types.js";
import {
DEFAULT_ACCOUNT_ID,
type CoreConfig,
type ZalouserConfig,
type ZcaFriend,
type ZcaGroup,
type ZcaUserInfo,
} from "./types.js";
const meta = {
id: "zalouser",
@@ -37,7 +34,7 @@ const meta = {
};
function resolveZalouserQrProfile(accountId?: string | null): string {
const normalized = normalizeAccountId(accountId);
const normalized = String(accountId ?? "").trim();
if (!normalized || normalized === DEFAULT_ACCOUNT_ID) {
return process.env.ZCA_PROFILE?.trim() || "default";
}
@@ -72,6 +69,65 @@ function mapGroup(params: {
};
}
function deleteAccountFromConfigSection(params: {
cfg: CoreConfig;
accountId: string;
}): CoreConfig {
const { cfg, accountId } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
const { zalouser: _removed, ...restChannels } = cfg.channels ?? {};
return { ...cfg, channels: restChannels };
}
const accounts = { ...(cfg.channels?.zalouser?.accounts ?? {}) };
delete accounts[accountId];
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
accounts,
},
},
};
}
function setAccountEnabledInConfigSection(params: {
cfg: CoreConfig;
accountId: string;
enabled: boolean;
}): CoreConfig {
const { cfg, accountId, enabled } = params;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
enabled,
},
},
};
}
return {
...cfg,
channels: {
...cfg.channels,
zalouser: {
...cfg.channels?.zalouser,
accounts: {
...(cfg.channels?.zalouser?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalouser?.accounts?.[accountId] ?? {}),
enabled,
},
},
},
},
};
}
export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
id: "zalouser",
meta,
@@ -87,24 +143,20 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
reload: { configPrefixes: ["channels.zalouser"] },
config: {
listAccountIds: (cfg) => listZalouserAccountIds(cfg as ClawdbotConfig),
listAccountIds: (cfg) => listZalouserAccountIds(cfg as CoreConfig),
resolveAccount: (cfg, accountId) =>
resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as ClawdbotConfig),
resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }),
defaultAccountId: (cfg) => resolveDefaultZalouserAccountId(cfg as CoreConfig),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "zalouser",
cfg: cfg as CoreConfig,
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as ClawdbotConfig,
sectionKey: "zalouser",
cfg: cfg as CoreConfig,
accountId,
clearBaseFields: ["profile", "name", "dmPolicy", "allowFrom", "groupPolicy", "groups", "messagePrefix"],
}),
isConfigured: async (account) => {
// Check if zca auth status is OK for this profile
@@ -121,7 +173,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
configured: undefined,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId }).config.allowFrom ?? []).map(
(resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
@@ -135,7 +187,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as ClawdbotConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
(cfg as CoreConfig).channels?.zalouser?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.zalouser.accounts.${resolvedAccountId}.`
@@ -175,7 +227,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
self: async ({ cfg, accountId, runtime }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await runZca(["me", "info", "-j"], { profile: account.profile, timeout: 10000 });
if (!result.ok) {
runtime.error(result.stderr || "Failed to fetch profile");
@@ -193,7 +245,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
listPeers: async ({ cfg, accountId, query, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const args = query?.trim()
? ["friend", "find", query.trim()]
: ["friend", "list", "-j"];
@@ -217,7 +269,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
listGroups: async ({ cfg, accountId, query, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await runZca(["group", "list", "-j"], { profile: account.profile, timeout: 15000 });
if (!result.ok) {
throw new Error(result.stderr || "Failed to list groups");
@@ -241,7 +293,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
listGroupMembers: async ({ cfg, accountId, groupId, limit }) => {
const ok = await checkZcaInstalled();
if (!ok) throw new Error("Missing dependency: `zca` not found in PATH");
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await runZca(["group", "members", groupId, "-j"], {
profile: account.profile,
timeout: 20000,
@@ -283,7 +335,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
}
try {
const account = resolveZalouserAccountSync({
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
});
const args =
@@ -339,7 +391,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
idLabel: "zalouserUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalouser|zlu):/i, ""),
notifyApproval: async ({ cfg, id }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig });
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig });
const authenticated = await checkZcaAuthenticated(account.profile);
if (!authenticated) throw new Error("Zalouser not authenticated");
await sendMessageZalouser(id, "Your pairing request has been approved.", {
@@ -350,7 +402,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
auth: {
login: async ({ cfg, accountId, runtime }) => {
const account = resolveZalouserAccountSync({
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
accountId: accountId ?? DEFAULT_ACCOUNT_ID,
});
const ok = await checkZcaInstalled();
@@ -393,7 +445,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
},
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await sendMessageZalouser(to, text, { profile: account.profile });
return {
channel: "zalouser",
@@ -403,7 +455,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const result = await sendMessageZalouser(to, text, {
profile: account.profile,
mediaUrl,
@@ -482,7 +534,7 @@ export const zalouserPlugin: ChannelPlugin<ResolvedZalouserAccount> = {
const { monitorZalouserProvider } = await import("./monitor.js");
return monitorZalouserProvider({
account,
config: ctx.cfg as ClawdbotConfig,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
+171
View File
@@ -0,0 +1,171 @@
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
export type CoreChannelDeps = {
chunkMarkdownText: (text: string, limit: number) => string[];
formatAgentEnvelope: (params: {
channel: string;
from: string;
timestamp?: number;
body: string;
}) => string;
dispatchReplyWithBufferedBlockDispatcher: (params: {
ctx: unknown;
cfg: unknown;
dispatcherOptions: {
deliver: (payload: unknown) => Promise<void>;
onError?: (err: unknown, info: { kind: string }) => void;
};
}) => Promise<void>;
resolveAgentRoute: (params: {
cfg: unknown;
channel: string;
accountId: string;
peer: { kind: "dm" | "group" | "channel"; id: string };
}) => { sessionKey: string; accountId: string };
buildPairingReply: (params: { channel: string; idLine: string; code: string }) => string;
readChannelAllowFromStore: (channel: string) => Promise<string[]>;
upsertChannelPairingRequest: (params: {
channel: string;
id: string;
meta?: { name?: string };
}) => Promise<{ code: string; created: boolean }>;
fetchRemoteMedia: (params: { url: string }) => Promise<{ buffer: Buffer; contentType?: string }>;
saveMediaBuffer: (
buffer: Buffer,
contentType: string | undefined,
type: "inbound" | "outbound",
maxBytes: number,
) => Promise<{ path: string; contentType: string }>;
shouldLogVerbose: () => boolean;
};
let coreRootCache: string | null = null;
let coreDepsPromise: Promise<CoreChannelDeps> | null = null;
function findPackageRoot(startDir: string, name: string): string | null {
let dir = startDir;
for (;;) {
const pkgPath = path.join(dir, "package.json");
try {
if (fs.existsSync(pkgPath)) {
const raw = fs.readFileSync(pkgPath, "utf8");
const pkg = JSON.parse(raw) as { name?: string };
if (pkg.name === name) return dir;
}
} catch {
// ignore parse errors
}
const parent = path.dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
function resolveClawdbotRoot(): string {
if (coreRootCache) return coreRootCache;
const override = process.env.CLAWDBOT_ROOT?.trim();
if (override) {
coreRootCache = override;
return override;
}
const candidates = new Set<string>();
if (process.argv[1]) {
candidates.add(path.dirname(process.argv[1]));
}
candidates.add(process.cwd());
try {
const urlPath = fileURLToPath(import.meta.url);
candidates.add(path.dirname(urlPath));
} catch {
// ignore
}
for (const start of candidates) {
const found = findPackageRoot(start, "clawdbot");
if (found) {
coreRootCache = found;
return found;
}
}
throw new Error(
"Unable to resolve Clawdbot root. Set CLAWDBOT_ROOT to the package root.",
);
}
async function importCoreModule<T>(relativePath: string): Promise<T> {
const root = resolveClawdbotRoot();
const distPath = path.join(root, "dist", relativePath);
if (!fs.existsSync(distPath)) {
throw new Error(
`Missing core module at ${distPath}. Run \`pnpm build\` or install the official package.`,
);
}
return (await import(pathToFileURL(distPath).href)) as T;
}
export async function loadCoreChannelDeps(): Promise<CoreChannelDeps> {
if (coreDepsPromise) return coreDepsPromise;
coreDepsPromise = (async () => {
const [
chunk,
envelope,
dispatcher,
routing,
pairingMessages,
pairingStore,
mediaFetch,
mediaStore,
globals,
] = await Promise.all([
importCoreModule<{ chunkMarkdownText: CoreChannelDeps["chunkMarkdownText"] }>(
"auto-reply/chunk.js",
),
importCoreModule<{ formatAgentEnvelope: CoreChannelDeps["formatAgentEnvelope"] }>(
"auto-reply/envelope.js",
),
importCoreModule<{
dispatchReplyWithBufferedBlockDispatcher: CoreChannelDeps["dispatchReplyWithBufferedBlockDispatcher"];
}>("auto-reply/reply/provider-dispatcher.js"),
importCoreModule<{ resolveAgentRoute: CoreChannelDeps["resolveAgentRoute"] }>(
"routing/resolve-route.js",
),
importCoreModule<{ buildPairingReply: CoreChannelDeps["buildPairingReply"] }>(
"pairing/pairing-messages.js",
),
importCoreModule<{
readChannelAllowFromStore: CoreChannelDeps["readChannelAllowFromStore"];
upsertChannelPairingRequest: CoreChannelDeps["upsertChannelPairingRequest"];
}>("pairing/pairing-store.js"),
importCoreModule<{ fetchRemoteMedia: CoreChannelDeps["fetchRemoteMedia"] }>(
"media/fetch.js",
),
importCoreModule<{ saveMediaBuffer: CoreChannelDeps["saveMediaBuffer"] }>(
"media/store.js",
),
importCoreModule<{ shouldLogVerbose: CoreChannelDeps["shouldLogVerbose"] }>(
"globals.js",
),
]);
return {
chunkMarkdownText: chunk.chunkMarkdownText,
formatAgentEnvelope: envelope.formatAgentEnvelope,
dispatchReplyWithBufferedBlockDispatcher:
dispatcher.dispatchReplyWithBufferedBlockDispatcher,
resolveAgentRoute: routing.resolveAgentRoute,
buildPairingReply: pairingMessages.buildPairingReply,
readChannelAllowFromStore: pairingStore.readChannelAllowFromStore,
upsertChannelPairingRequest: pairingStore.upsertChannelPairingRequest,
fetchRemoteMedia: mediaFetch.fetchRemoteMedia,
saveMediaBuffer: mediaStore.saveMediaBuffer,
shouldLogVerbose: globals.shouldLogVerbose,
};
})();
return coreDepsPromise;
}
+32 -34
View File
@@ -1,9 +1,8 @@
import type { ChildProcess } from "node:child_process";
import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk";
import type { RuntimeEnv } from "clawdbot/plugin-sdk";
import {
finalizeInboundContext,
formatAgentEnvelope,
isControlCommandMessage,
mergeAllowlist,
recordSessionMetaFromInbound,
@@ -12,19 +11,20 @@ import {
shouldComputeCommandAuthorized,
summarizeMapping,
} from "clawdbot/plugin-sdk";
import { loadCoreChannelDeps, type CoreChannelDeps } from "./core-bridge.js";
import { sendMessageZalouser } from "./send.js";
import type {
CoreConfig,
ResolvedZalouserAccount,
ZcaFriend,
ZcaGroup,
ZcaMessage,
} from "./types.js";
import { getZalouserRuntime } from "./runtime.js";
import { parseJsonOutput, runZca, runZcaStreaming } from "./zca.js";
export type ZalouserMonitorOptions = {
account: ResolvedZalouserAccount;
config: ClawdbotConfig;
config: CoreConfig;
runtime: RuntimeEnv;
abortSignal: AbortSignal;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
@@ -55,10 +55,8 @@ function buildNameIndex<T>(
return index;
}
type ZalouserCoreRuntime = ReturnType<typeof getZalouserRuntime>;
function logVerbose(core: ZalouserCoreRuntime, runtime: RuntimeEnv, message: string): void {
if (core.logging.shouldLogVerbose()) {
function logVerbose(deps: CoreChannelDeps, runtime: RuntimeEnv, message: string): void {
if (deps.shouldLogVerbose()) {
runtime.log(`[zalouser] ${message}`);
}
}
@@ -159,8 +157,8 @@ function startZcaListener(
async function processMessage(
message: ZcaMessage,
account: ResolvedZalouserAccount,
config: ClawdbotConfig,
core: ZalouserCoreRuntime,
config: CoreConfig,
deps: CoreChannelDeps,
runtime: RuntimeEnv,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
): Promise<void> {
@@ -178,13 +176,13 @@ async function processMessage(
const groups = account.config.groups ?? {};
if (isGroup) {
if (groupPolicy === "disabled") {
logVerbose(core, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
logVerbose(deps, runtime, `zalouser: drop group ${chatId} (groupPolicy=disabled)`);
return;
}
if (groupPolicy === "allowlist") {
const allowed = isGroupAllowed({ groupId: chatId, groupName, groups });
if (!allowed) {
logVerbose(core, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
logVerbose(deps, runtime, `zalouser: drop group ${chatId} (not allowlisted)`);
return;
}
}
@@ -196,7 +194,7 @@ async function processMessage(
const shouldComputeAuth = shouldComputeCommandAuthorized(rawBody, config);
const storeAllowFrom =
!isGroup && (dmPolicy !== "open" || shouldComputeAuth)
? await core.channel.pairing.readAllowFromStore("zalouser").catch(() => [])
? await deps.readChannelAllowFromStore("zalouser").catch(() => [])
: [];
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const useAccessGroups = config.commands?.useAccessGroups !== false;
@@ -210,7 +208,7 @@ async function processMessage(
if (!isGroup) {
if (dmPolicy === "disabled") {
logVerbose(core, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
logVerbose(deps, runtime, `Blocked zalouser DM from ${senderId} (dmPolicy=disabled)`);
return;
}
@@ -219,18 +217,18 @@ async function processMessage(
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await core.channel.pairing.upsertPairingRequest({
const { code, created } = await deps.upsertChannelPairingRequest({
channel: "zalouser",
id: senderId,
meta: { name: senderName || undefined },
});
if (created) {
logVerbose(core, runtime, `zalouser pairing request sender=${senderId}`);
logVerbose(deps, runtime, `zalouser pairing request sender=${senderId}`);
try {
await sendMessageZalouser(
chatId,
core.channel.pairing.buildPairingReply({
deps.buildPairingReply({
channel: "zalouser",
idLine: `Your Zalo user id: ${senderId}`,
code,
@@ -240,7 +238,7 @@ async function processMessage(
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(
core,
deps,
runtime,
`zalouser pairing reply failed for ${senderId}: ${String(err)}`,
);
@@ -248,7 +246,7 @@ async function processMessage(
}
} else {
logVerbose(
core,
deps,
runtime,
`Blocked unauthorized zalouser sender ${senderId} (dmPolicy=${dmPolicy})`,
);
@@ -259,13 +257,13 @@ async function processMessage(
}
if (isGroup && isControlCommandMessage(rawBody, config) && commandAuthorized !== true) {
logVerbose(core, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
logVerbose(deps, runtime, `zalouser: drop control command from unauthorized sender ${senderId}`);
return;
}
const peer = isGroup ? { kind: "group" as const, id: chatId } : { kind: "group" as const, id: senderId };
const route = core.channel.routing.resolveAgentRoute({
const route = deps.resolveAgentRoute({
cfg: config,
channel: "zalouser",
accountId: account.accountId,
@@ -277,7 +275,7 @@ async function processMessage(
});
const fromLabel = isGroup ? `group:${chatId}` : senderName || `user:${senderId}`;
const body = formatAgentEnvelope({
const body = deps.formatAgentEnvelope({
channel: "Zalo Personal",
from: fromLabel,
timestamp: timestamp ? timestamp * 1000 : undefined,
@@ -315,7 +313,7 @@ async function processMessage(
runtime.error?.(`zalouser: failed updating session meta: ${String(err)}`);
});
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
await deps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
@@ -326,7 +324,7 @@ async function processMessage(
chatId,
isGroup,
runtime,
core,
deps,
statusSink,
});
},
@@ -345,10 +343,10 @@ async function deliverZalouserReply(params: {
chatId: string;
isGroup: boolean;
runtime: RuntimeEnv;
core: ZalouserCoreRuntime;
deps: CoreChannelDeps;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
}): Promise<void> {
const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params;
const { payload, profile, chatId, isGroup, runtime, deps, statusSink } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
@@ -362,7 +360,7 @@ async function deliverZalouserReply(params: {
const caption = first ? payload.text : undefined;
first = false;
try {
logVerbose(core, runtime, `Sending media to ${chatId}`);
logVerbose(deps, runtime, `Sending media to ${chatId}`);
await sendMessageZalouser(chatId, caption ?? "", {
profile,
mediaUrl,
@@ -377,8 +375,8 @@ async function deliverZalouserReply(params: {
}
if (payload.text) {
const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
const chunks = deps.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT);
logVerbose(deps, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`);
for (const chunk of chunks) {
try {
await sendMessageZalouser(chatId, chunk, { profile, isGroup });
@@ -396,7 +394,7 @@ export async function monitorZalouserProvider(
let { account, config } = options;
const { abortSignal, statusSink, runtime } = options;
const core = getZalouserRuntime();
const deps = await loadCoreChannelDeps();
let stopped = false;
let proc: ChildProcess | null = null;
let restartTimer: ReturnType<typeof setTimeout> | null = null;
@@ -508,7 +506,7 @@ export async function monitorZalouserProvider(
}
logVerbose(
core,
deps,
runtime,
`[${account.accountId}] starting zca listener (profile=${account.profile})`,
);
@@ -517,16 +515,16 @@ export async function monitorZalouserProvider(
runtime,
account.profile,
(msg) => {
logVerbose(core, runtime, `[${account.accountId}] inbound message`);
logVerbose(deps, runtime, `[${account.accountId}] inbound message`);
statusSink?.({ lastInboundAt: Date.now() });
processMessage(msg, account, config, core, runtime, statusSink).catch((err) => {
processMessage(msg, account, config, deps, runtime, statusSink).catch((err) => {
runtime.error(`[${account.accountId}] Failed to process message: ${String(err)}`);
});
},
(err) => {
runtime.error(`[${account.accountId}] zca listener error: ${String(err)}`);
if (!stopped && !abortSignal.aborted) {
logVerbose(core, runtime, `[${account.accountId}] restarting listener in 5s...`);
logVerbose(deps, runtime, `[${account.accountId}] restarting listener in 5s...`);
restartTimer = setTimeout(startListener, 5000);
} else {
resolveRunning?.();
+73 -35
View File
@@ -1,35 +1,31 @@
import type {
ChannelOnboardingAdapter,
ChannelOnboardingDmPolicy,
ClawdbotConfig,
WizardPrompter,
} from "clawdbot/plugin-sdk";
import {
addWildcardAllowFrom,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
promptAccountId,
promptChannelAccessConfig,
} from "clawdbot/plugin-sdk";
import { promptChannelAccessConfig } from "clawdbot/plugin-sdk";
import {
listZalouserAccountIds,
resolveDefaultZalouserAccountId,
resolveZalouserAccountSync,
normalizeAccountId,
checkZcaAuthenticated,
} from "./accounts.js";
import { runZca, runZcaInteractive, checkZcaInstalled, parseJsonOutput } from "./zca.js";
import type { ZcaGroup } from "./types.js";
import { DEFAULT_ACCOUNT_ID, type CoreConfig, type ZcaGroup } from "./types.js";
const channel = "zalouser" as const;
function setZalouserDmPolicy(
cfg: ClawdbotConfig,
cfg: CoreConfig,
dmPolicy: "pairing" | "allowlist" | "open" | "disabled",
): ClawdbotConfig {
): CoreConfig {
const allowFrom =
dmPolicy === "open"
? addWildcardAllowFrom(cfg.channels?.zalouser?.allowFrom)
? [...(cfg.channels?.zalouser?.allowFrom ?? []), "*"].filter(
(v, i, a) => a.indexOf(v) === i,
)
: undefined;
return {
...cfg,
@@ -41,7 +37,7 @@ function setZalouserDmPolicy(
...(allowFrom ? { allowFrom } : {}),
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
@@ -60,10 +56,10 @@ async function noteZalouserHelp(prompter: WizardPrompter): Promise<void> {
}
async function promptZalouserAllowFrom(params: {
cfg: ClawdbotConfig;
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<ClawdbotConfig> {
}): Promise<CoreConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveZalouserAccountSync({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
@@ -97,7 +93,7 @@ async function promptZalouserAllowFrom(params: {
allowFrom: unique,
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
return {
@@ -118,14 +114,14 @@ async function promptZalouserAllowFrom(params: {
},
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
function setZalouserGroupPolicy(
cfg: ClawdbotConfig,
cfg: CoreConfig,
accountId: string,
groupPolicy: "open" | "allowlist" | "disabled",
): ClawdbotConfig {
): CoreConfig {
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
@@ -137,7 +133,7 @@ function setZalouserGroupPolicy(
groupPolicy,
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
return {
...cfg,
@@ -156,14 +152,14 @@ function setZalouserGroupPolicy(
},
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
function setZalouserGroupAllowlist(
cfg: ClawdbotConfig,
cfg: CoreConfig,
accountId: string,
groupKeys: string[],
): ClawdbotConfig {
): CoreConfig {
const groups = Object.fromEntries(groupKeys.map((key) => [key, { allow: true }]));
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
@@ -176,7 +172,7 @@ function setZalouserGroupAllowlist(
groups,
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
return {
...cfg,
@@ -195,11 +191,11 @@ function setZalouserGroupAllowlist(
},
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
async function resolveZalouserGroups(params: {
cfg: ClawdbotConfig;
cfg: CoreConfig;
accountId: string;
entries: string[];
}): Promise<Array<{ input: string; resolved: boolean; id?: string }>> {
@@ -230,23 +226,65 @@ async function resolveZalouserGroups(params: {
});
}
async function promptAccountId(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
label: string;
currentId: string;
listAccountIds: (cfg: CoreConfig) => string[];
defaultAccountId: string;
}): Promise<string> {
const { cfg, prompter, label, currentId, listAccountIds, defaultAccountId } = params;
const existingIds = listAccountIds(cfg);
const options = [
...existingIds.map((id) => ({
value: id,
label: id === defaultAccountId ? `${id} (default)` : id,
})),
{ value: "__new__", label: "Create new account" },
];
const selected = await prompter.select({
message: `${label} account`,
options,
initialValue: currentId,
});
if (selected === "__new__") {
const newId = await prompter.text({
message: "New account ID",
placeholder: "work",
validate: (value) => {
const raw = String(value ?? "").trim().toLowerCase();
if (!raw) return "Required";
if (!/^[a-z0-9_-]+$/.test(raw)) return "Use lowercase alphanumeric, dash, or underscore";
if (existingIds.includes(raw)) return "Account already exists";
return undefined;
},
});
return String(newId).trim().toLowerCase();
}
return selected as string;
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Zalo Personal",
channel,
policyKey: "channels.zalouser.dmPolicy",
allowFromKey: "channels.zalouser.allowFrom",
getCurrent: (cfg) => ((cfg as ClawdbotConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as ClawdbotConfig, policy),
getCurrent: (cfg) => ((cfg as CoreConfig).channels?.zalouser?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZalouserDmPolicy(cfg as CoreConfig, policy),
};
export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const ids = listZalouserAccountIds(cfg as ClawdbotConfig);
const ids = listZalouserAccountIds(cfg as CoreConfig);
let configured = false;
for (const accountId of ids) {
const account = resolveZalouserAccountSync({ cfg: cfg as ClawdbotConfig, accountId });
const account = resolveZalouserAccountSync({ cfg: cfg as CoreConfig, accountId });
const isAuth = await checkZcaAuthenticated(account.profile);
if (isAuth) {
configured = true;
@@ -278,14 +316,14 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
}
const zalouserOverride = accountOverrides.zalouser?.trim();
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as ClawdbotConfig);
const defaultAccountId = resolveDefaultZalouserAccountId(cfg as CoreConfig);
let accountId = zalouserOverride
? normalizeAccountId(zalouserOverride)
: defaultAccountId;
if (shouldPromptAccountIds && !zalouserOverride) {
accountId = await promptAccountId({
cfg: cfg as ClawdbotConfig,
cfg: cfg as CoreConfig,
prompter,
label: "Zalo Personal",
currentId: accountId,
@@ -294,7 +332,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
});
}
let next = cfg as ClawdbotConfig;
let next = cfg as CoreConfig;
const account = resolveZalouserAccountSync({ cfg: next, accountId });
const alreadyAuthenticated = await checkZcaAuthenticated(account.profile);
@@ -352,7 +390,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
profile: account.profile !== "default" ? account.profile : undefined,
},
},
} as ClawdbotConfig;
} as CoreConfig;
} else {
next = {
...next,
@@ -371,7 +409,7 @@ export const zalouserOnboardingAdapter: ChannelOnboardingAdapter = {
},
},
},
} as ClawdbotConfig;
} as CoreConfig;
}
if (forceAllowFrom) {
-14
View File
@@ -1,14 +0,0 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setZalouserRuntime(next: PluginRuntime): void {
runtime = next;
}
export function getZalouserRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Zalouser runtime not initialized");
}
return runtime;
}
+11
View File
@@ -68,6 +68,9 @@ export type ListenOptions = CommonOptions & {
prefix?: string;
};
// Channel plugin config types
export const DEFAULT_ACCOUNT_ID = "default";
export type ZalouserAccountConfig = {
enabled?: boolean;
name?: string;
@@ -92,6 +95,14 @@ export type ZalouserConfig = {
accounts?: Record<string, ZalouserAccountConfig>;
};
export type CoreConfig = {
channels?: {
zalouser?: ZalouserConfig;
[key: string]: unknown;
};
[key: string]: unknown;
};
export type ResolvedZalouserAccount = {
accountId: string;
name?: string;
-6
View File
@@ -4,11 +4,6 @@
"description": "WhatsApp gateway CLI (Baileys web) with Pi RPC agent",
"type": "module",
"main": "dist/index.js",
"exports": {
".": "./dist/index.js",
"./plugin-sdk": "./dist/plugin-sdk/index.js",
"./plugin-sdk/*": "./dist/plugin-sdk/*"
},
"bin": {
"clawdbot": "dist/entry.js"
},
@@ -140,7 +135,6 @@
},
"packageManager": "pnpm@10.23.0",
"dependencies": {
"@agentclientprotocol/sdk": "0.13.0",
"@buape/carbon": "0.0.0-beta-20260110172854",
"@clack/prompts": "^0.11.0",
"@grammyjs/runner": "^2.0.3",
-26
View File
@@ -13,9 +13,6 @@ importers:
.:
dependencies:
'@agentclientprotocol/sdk':
specifier: 0.13.0
version: 0.13.0(zod@4.3.5)
'@buape/carbon':
specifier: 0.0.0-beta-20260110172854
version: 0.0.0-beta-20260110172854(hono@4.11.4)
@@ -241,8 +238,6 @@ importers:
specifier: 3.14.5
version: 3.14.5(typescript@5.9.3)
extensions/bluebubbles: {}
extensions/copilot-proxy: {}
extensions/google-antigravity-auth: {}
@@ -251,9 +246,6 @@ importers:
extensions/matrix:
dependencies:
clawdbot:
specifier: workspace:*
version: link:../..
markdown-it:
specifier: 14.1.0
version: 14.1.0
@@ -272,9 +264,6 @@ importers:
'@microsoft/agents-hosting-extensions-teams':
specifier: ^1.1.1
version: 1.1.1
clawdbot:
specifier: workspace:*
version: link:../..
express:
specifier: ^5.2.1
version: 5.2.1
@@ -296,9 +285,6 @@ importers:
extensions/zalo:
dependencies:
clawdbot:
specifier: workspace:*
version: link:../..
undici:
specifier: 7.18.2
version: 7.18.2
@@ -308,9 +294,6 @@ importers:
'@sinclair/typebox':
specifier: 0.34.47
version: 0.34.47
clawdbot:
specifier: workspace:*
version: link:../..
ui:
dependencies:
@@ -342,11 +325,6 @@ importers:
packages:
'@agentclientprotocol/sdk@0.13.0':
resolution: {integrity: sha512-Z6/Fp4cXLbYdMXr5AK752JM5qG2VKb6ShM0Ql6FimBSckMmLyK54OA20UhPYoH4C37FSFwUTARuwQOwQUToYrw==}
peerDependencies:
zod: ^3.25.0 || ^4.0.0
'@anthropic-ai/sdk@0.71.2':
resolution: {integrity: sha512-TGNDEUuEstk/DKu0/TflXAEt+p+p/WhTlFzEnoosvbaDU2LTjm42igSdlL0VijrKpWejtOKxX0b8A7uc+XiSAQ==}
hasBin: true
@@ -4404,10 +4382,6 @@ packages:
snapshots:
'@agentclientprotocol/sdk@0.13.0(zod@4.3.5)':
dependencies:
zod: 4.3.5
'@anthropic-ai/sdk@0.71.2(zod@4.3.5)':
dependencies:
json-schema-to-ts: 3.1.1
-39
View File
@@ -1,39 +0,0 @@
---
name: bluebubbles
description: Build or update the BlueBubbles external channel plugin for Clawdbot (extension package, REST send/probe, webhook inbound).
---
# BlueBubbles plugin
Use this skill when working on the BlueBubbles channel plugin.
## Layout
- Extension package: `extensions/bluebubbles/` (entry: `index.ts`).
- Channel implementation: `extensions/bluebubbles/src/channel.ts`.
- Webhook handling: `extensions/bluebubbles/src/monitor.ts` (register via `api.registerHttpHandler`).
- REST helpers: `extensions/bluebubbles/src/send.ts` + `extensions/bluebubbles/src/probe.ts`.
- Runtime bridge: `extensions/bluebubbles/src/runtime.ts` (set via `api.runtime`).
- Catalog entry for onboarding: `src/channels/plugins/catalog.ts`.
## Internal helpers (use these, not raw API calls)
- `probeBlueBubbles` in `extensions/bluebubbles/src/probe.ts` for health checks.
- `sendMessageBlueBubbles` in `extensions/bluebubbles/src/send.ts` for text delivery.
- `resolveChatGuidForTarget` in `extensions/bluebubbles/src/send.ts` for chat lookup.
- `sendBlueBubblesReaction` in `extensions/bluebubbles/src/reactions.ts` for tapbacks.
- `sendBlueBubblesTyping` + `markBlueBubblesChatRead` in `extensions/bluebubbles/src/chat.ts`.
- `downloadBlueBubblesAttachment` in `extensions/bluebubbles/src/attachments.ts` for inbound media.
- `buildBlueBubblesApiUrl` + `blueBubblesFetchWithTimeout` in `extensions/bluebubbles/src/types.ts` for shared REST plumbing.
## Webhooks
- BlueBubbles posts JSON to the gateway HTTP server.
- Normalize sender/chat IDs defensively (payloads vary by version).
- Skip messages marked as from self.
- Route into core reply pipeline via the plugin runtime (`api.runtime`) and `clawdbot/plugin-sdk` helpers.
- For attachments/stickers, use `<media:...>` placeholders when text is empty and attach media paths via `MediaUrl(s)` in the inbound context.
## Config (core)
- `channels.bluebubbles.serverUrl` (base URL), `channels.bluebubbles.password`, `channels.bluebubbles.webhookPath`.
- Action gating: `channels.bluebubbles.actions.reactions` (default true).
## Message tool notes
- **Reactions:** The `react` action requires a `target` (phone number or chat identifier) in addition to `messageId`. Example: `action=react target=+15551234567 messageId=ABC123 emoji=❤️`
-189
View File
@@ -1,189 +0,0 @@
# Canvas Skill
Display HTML content on connected Clawdbot nodes (Mac app, iOS, Android).
## Overview
The canvas tool lets you present web content on any connected node's canvas view. Great for:
- Displaying games, visualizations, dashboards
- Showing generated HTML content
- Interactive demos
## How It Works
### Architecture
```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Canvas Host │────▶│ Node Bridge │────▶│ Node App │
│ (HTTP Server) │ │ (TCP Server) │ │ (Mac/iOS/ │
│ Port 18793 │ │ Port 18790 │ │ Android) │
└─────────────────┘ └──────────────────┘ └─────────────┘
```
1. **Canvas Host Server**: Serves static HTML/CSS/JS files from `canvasHost.root` directory
2. **Node Bridge**: Communicates canvas URLs to connected nodes
3. **Node Apps**: Render the content in a WebView
### Tailscale Integration
The canvas host server binds based on `gateway.bind` setting:
| Bind Mode | Server Binds To | Canvas URL Uses |
|-----------|-----------------|-----------------|
| `loopback` | 127.0.0.1 | localhost (local only) |
| `lan` | LAN interface | LAN IP address |
| `tailnet` | Tailscale interface | Tailscale hostname |
| `auto` | Best available | Tailscale > LAN > loopback |
**Key insight:** The `canvasHostHostForBridge` is derived from `bridgeHost`. When bound to Tailscale, nodes receive URLs like:
```
http://<tailscale-hostname>:18793/__clawdbot__/canvas/<file>.html
```
This is why localhost URLs don't work - the node receives the Tailscale hostname from the bridge!
## Actions
| Action | Description |
|--------|-------------|
| `present` | Show canvas with optional target URL |
| `hide` | Hide the canvas |
| `navigate` | Navigate to a new URL |
| `eval` | Execute JavaScript in the canvas |
| `snapshot` | Capture screenshot of canvas |
## Configuration
In `~/.clawdbot/clawdbot.json`:
```json
{
"canvasHost": {
"enabled": true,
"port": 18793,
"root": "/Users/you/clawd/canvas",
"liveReload": true
},
"gateway": {
"bind": "auto"
}
}
```
### Live Reload
When `liveReload: true` (default), the canvas host:
- Watches the root directory for changes (via chokidar)
- Injects a WebSocket client into HTML files
- Automatically reloads connected canvases when files change
Great for development!
## Workflow
### 1. Create HTML content
Place files in the canvas root directory (default `~/clawd/canvas/`):
```bash
cat > ~/clawd/canvas/my-game.html << 'HTML'
<!DOCTYPE html>
<html>
<head><title>My Game</title></head>
<body>
<h1>Hello Canvas!</h1>
</body>
</html>
HTML
```
### 2. Find your canvas host URL
Check how your gateway is bound:
```bash
cat ~/.clawdbot/clawdbot.json | jq '.gateway.bind'
```
Then construct the URL:
- **loopback**: `http://127.0.0.1:18793/__clawdbot__/canvas/<file>.html`
- **lan/tailnet/auto**: `http://<hostname>:18793/__clawdbot__/canvas/<file>.html`
Find your Tailscale hostname:
```bash
tailscale status --json | jq -r '.Self.DNSName' | sed 's/\.$//'
```
### 3. Find connected nodes
```bash
clawdbot nodes list
```
Look for Mac/iOS/Android nodes with canvas capability.
### 4. Present content
```
canvas action:present node:<node-id> target:<full-url>
```
**Example:**
```
canvas action:present node:mac-63599bc4-b54d-4392-9048-b97abd58343a target:http://peters-mac-studio-1.sheep-coho.ts.net:18793/__clawdbot__/canvas/snake.html
```
### 5. Navigate, snapshot, or hide
```
canvas action:navigate node:<node-id> url:<new-url>
canvas action:snapshot node:<node-id>
canvas action:hide node:<node-id>
```
## Debugging
### White screen / content not loading
**Cause:** URL mismatch between server bind and node expectation.
**Debug steps:**
1. Check server bind: `cat ~/.clawdbot/clawdbot.json | jq '.gateway.bind'`
2. Check what port canvas is on: `lsof -i :18793`
3. Test URL directly: `curl http://<hostname>:18793/__clawdbot__/canvas/<file>.html`
**Solution:** Use the full hostname matching your bind mode, not localhost.
### "node required" error
Always specify `node:<node-id>` parameter.
### "node not connected" error
Node is offline. Use `clawdbot nodes list` to find online nodes.
### Content not updating
If live reload isn't working:
1. Check `liveReload: true` in config
2. Ensure file is in the canvas root directory
3. Check for watcher errors in logs
## URL Path Structure
The canvas host serves from `/__clawdbot__/canvas/` prefix:
```
http://<host>:18793/__clawdbot__/canvas/index.html → ~/clawd/canvas/index.html
http://<host>:18793/__clawdbot__/canvas/games/snake.html → ~/clawd/canvas/games/snake.html
```
The `/__clawdbot__/canvas/` prefix is defined by `CANVAS_HOST_PATH` constant.
## Tips
- Keep HTML self-contained (inline CSS/JS) for best results
- Use the default index.html as a test page (has bridge diagnostics)
- The canvas persists until you `hide` it or navigate away
- Live reload makes development fast - just save and it updates!
- A2UI JSON push is WIP - use HTML files for now
-34
View File
@@ -1,34 +0,0 @@
import { describe, expect, it } from "vitest";
import { extractAttachmentsFromPrompt, extractTextFromPrompt } from "./event-mapper.js";
describe("acp event mapper", () => {
it("extracts text and resource blocks into prompt text", () => {
const text = extractTextFromPrompt([
{ type: "text", text: "Hello" },
{ type: "resource", resource: { text: "File contents" } },
{ type: "resource_link", uri: "https://example.com", title: "Spec" },
{ type: "image", data: "abc", mimeType: "image/png" },
]);
expect(text).toBe(
"Hello\nFile contents\n[Resource link (Spec)] https://example.com",
);
});
it("extracts image blocks into gateway attachments", () => {
const attachments = extractAttachmentsFromPrompt([
{ type: "image", data: "abc", mimeType: "image/png" },
{ type: "image", data: "", mimeType: "image/png" },
{ type: "text", text: "ignored" },
]);
expect(attachments).toEqual([
{
type: "image",
mimeType: "image/png",
content: "abc",
},
]);
});
});
-73
View File
@@ -1,73 +0,0 @@
import type { ContentBlock, ImageContent, ToolKind } from "@agentclientprotocol/sdk";
export type GatewayAttachment = {
type: string;
mimeType: string;
content: string;
};
export function extractTextFromPrompt(prompt: ContentBlock[]): string {
const parts: string[] = [];
for (const block of prompt) {
if (block.type === "text") {
parts.push(block.text);
continue;
}
if (block.type === "resource") {
const resource = block.resource as { text?: string } | undefined;
if (resource?.text) parts.push(resource.text);
continue;
}
if (block.type === "resource_link") {
const title = block.title ? ` (${block.title})` : "";
const uri = block.uri ?? "";
const line = uri ? `[Resource link${title}] ${uri}` : `[Resource link${title}]`;
parts.push(line);
}
}
return parts.join("\n");
}
export function extractAttachmentsFromPrompt(prompt: ContentBlock[]): GatewayAttachment[] {
const attachments: GatewayAttachment[] = [];
for (const block of prompt) {
if (block.type !== "image") continue;
const image = block as ImageContent;
if (!image.data || !image.mimeType) continue;
attachments.push({
type: "image",
mimeType: image.mimeType,
content: image.data,
});
}
return attachments;
}
export function formatToolTitle(
name: string | undefined,
args: Record<string, unknown> | undefined,
): string {
const base = name ?? "tool";
if (!args || Object.keys(args).length === 0) return base;
const parts = Object.entries(args).map(([key, value]) => {
const raw = typeof value === "string" ? value : JSON.stringify(value);
const safe = raw.length > 100 ? `${raw.slice(0, 100)}...` : raw;
return `${key}: ${safe}`;
});
return `${base}: ${parts.join(", ")}`;
}
export function inferToolKind(name?: string): ToolKind | undefined {
if (!name) return "other";
const normalized = name.toLowerCase();
if (normalized.includes("read")) return "read";
if (normalized.includes("write") || normalized.includes("edit")) return "edit";
if (normalized.includes("delete") || normalized.includes("remove")) return "delete";
if (normalized.includes("move") || normalized.includes("rename")) return "move";
if (normalized.includes("search") || normalized.includes("find")) return "search";
if (normalized.includes("exec") || normalized.includes("run") || normalized.includes("bash")) {
return "execute";
}
if (normalized.includes("fetch") || normalized.includes("http")) return "fetch";
return "other";
}
-4
View File
@@ -1,4 +0,0 @@
export { serveAcpGateway } from "./server.js";
export { createInMemorySessionStore } from "./session.js";
export type { AcpSessionStore } from "./session.js";
export type { AcpServerOptions } from "./types.js";
-35
View File
@@ -1,35 +0,0 @@
export function readString(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): string | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "string" && value.trim()) return value.trim();
}
return undefined;
}
export function readBool(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): boolean | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "boolean") return value;
}
return undefined;
}
export function readNumber(
meta: Record<string, unknown> | null | undefined,
keys: string[],
): number | undefined {
if (!meta) return undefined;
for (const key of keys) {
const value = meta[key];
if (typeof value === "number" && Number.isFinite(value)) return value;
}
return undefined;
}
-149
View File
@@ -1,149 +0,0 @@
#!/usr/bin/env node
import { Readable, Writable } from "node:stream";
import { fileURLToPath } from "node:url";
import { AgentSideConnection, ndJsonStream } from "@agentclientprotocol/sdk";
import { loadConfig } from "../config/config.js";
import { resolveGatewayAuth } from "../gateway/auth.js";
import { buildGatewayConnectionDetails } from "../gateway/call.js";
import { GatewayClient } from "../gateway/client.js";
import { isMainModule } from "../infra/is-main.js";
import {
GATEWAY_CLIENT_MODES,
GATEWAY_CLIENT_NAMES,
} from "../utils/message-channel.js";
import { AcpGatewayAgent } from "./translator.js";
import type { AcpServerOptions } from "./types.js";
export function serveAcpGateway(opts: AcpServerOptions = {}): void {
const cfg = loadConfig();
const connection = buildGatewayConnectionDetails({
config: cfg,
url: opts.gatewayUrl,
});
const isRemoteMode = cfg.gateway?.mode === "remote";
const remote = isRemoteMode ? cfg.gateway?.remote : undefined;
const auth = resolveGatewayAuth({ authConfig: cfg.gateway?.auth, env: process.env });
const token =
opts.gatewayToken ??
(isRemoteMode ? remote?.token?.trim() : undefined) ??
process.env.CLAWDBOT_GATEWAY_TOKEN ??
auth.token;
const password =
opts.gatewayPassword ??
(isRemoteMode ? remote?.password?.trim() : undefined) ??
process.env.CLAWDBOT_GATEWAY_PASSWORD ??
auth.password;
let agent: AcpGatewayAgent | null = null;
const gateway = new GatewayClient({
url: connection.url,
token: token || undefined,
password: password || undefined,
clientName: GATEWAY_CLIENT_NAMES.CLI,
clientDisplayName: "ACP",
clientVersion: "acp",
mode: GATEWAY_CLIENT_MODES.CLI,
onEvent: (evt) => {
void agent?.handleGatewayEvent(evt);
},
onHelloOk: () => {
agent?.handleGatewayReconnect();
},
onClose: (code, reason) => {
agent?.handleGatewayDisconnect(`${code}: ${reason}`);
},
});
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as ReadableStream<Uint8Array>;
const stream = ndJsonStream(input, output);
new AgentSideConnection((conn) => {
agent = new AcpGatewayAgent(conn, gateway, opts);
agent.start();
return agent;
}, stream);
gateway.start();
}
function parseArgs(args: string[]): AcpServerOptions {
const opts: AcpServerOptions = {};
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--url" || arg === "--gateway-url") {
opts.gatewayUrl = args[i + 1];
i += 1;
continue;
}
if (arg === "--token" || arg === "--gateway-token") {
opts.gatewayToken = args[i + 1];
i += 1;
continue;
}
if (arg === "--password" || arg === "--gateway-password") {
opts.gatewayPassword = args[i + 1];
i += 1;
continue;
}
if (arg === "--session") {
opts.defaultSessionKey = args[i + 1];
i += 1;
continue;
}
if (arg === "--session-label") {
opts.defaultSessionLabel = args[i + 1];
i += 1;
continue;
}
if (arg === "--require-existing") {
opts.requireExistingSession = true;
continue;
}
if (arg === "--reset-session") {
opts.resetSession = true;
continue;
}
if (arg === "--no-prefix-cwd") {
opts.prefixCwd = false;
continue;
}
if (arg === "--verbose" || arg === "-v") {
opts.verbose = true;
continue;
}
if (arg === "--help" || arg === "-h") {
printHelp();
process.exit(0);
}
}
return opts;
}
function printHelp(): void {
console.log(`Usage: clawdbot acp [options]
Gateway-backed ACP server for IDE integration.
Options:
--url <url> Gateway WebSocket URL
--token <token> Gateway auth token
--password <password> Gateway auth password
--session <key> Default session key (e.g. "agent:main:main")
--session-label <label> Default session label to resolve
--require-existing Fail if the session key/label does not exist
--reset-session Reset the session key before first use
--no-prefix-cwd Do not prefix prompts with the working directory
--verbose, -v Verbose logging to stderr
--help, -h Show this help message
`);
}
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
const opts = parseArgs(process.argv.slice(2));
serveAcpGateway(opts);
}
-57
View File
@@ -1,57 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import { parseSessionMeta, resolveSessionKey } from "./session-mapper.js";
function createGateway(resolveLabelKey = "agent:main:label"): {
gateway: GatewayClient;
request: ReturnType<typeof vi.fn>;
} {
const request = vi.fn(async (method: string, params: Record<string, unknown>) => {
if (method === "sessions.resolve" && "label" in params) {
return { ok: true, key: resolveLabelKey };
}
if (method === "sessions.resolve" && "key" in params) {
return { ok: true, key: params.key as string };
}
return { ok: true };
});
return {
gateway: { request } as unknown as GatewayClient,
request,
};
}
describe("acp session mapper", () => {
it("prefers explicit sessionLabel over sessionKey", async () => {
const { gateway, request } = createGateway();
const meta = parseSessionMeta({ sessionLabel: "support", sessionKey: "agent:main:main" });
const key = await resolveSessionKey({
meta,
fallbackKey: "acp:fallback",
gateway,
opts: {},
});
expect(key).toBe("agent:main:label");
expect(request).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith("sessions.resolve", { label: "support" });
});
it("lets meta sessionKey override default label", async () => {
const { gateway, request } = createGateway();
const meta = parseSessionMeta({ sessionKey: "agent:main:override" });
const key = await resolveSessionKey({
meta,
fallbackKey: "acp:fallback",
gateway,
opts: { defaultSessionLabel: "default-label" },
});
expect(key).toBe("agent:main:override");
expect(request).not.toHaveBeenCalled();
});
});
Loaded 100 of 241 files, more files were not shown because too many files have changed in this diff. Show more