Compare commits

..
Author SHA1 Message Date
Peter Steinberger 738b3592cd fix: remove conflict marker in google helper (#875) 2026-01-15 05:25:45 +00:00
Peter Steinberger cd2af64860 fix: cap tool call IDs for OpenAI/OpenRouter (#875) (thanks @j1philli) 2026-01-15 05:25:45 +00:00
Josh Phillips 04f1e767b2 Fix OpenAI tool_call id length for OpenRouter 2026-01-15 05:25:45 +00:00
Peter Steinberger 1c7ac2a6ab test: fix windows-only expectations 2026-01-15 05:25:08 +00:00
Peter SteinbergerandBenjamin Jesuiter 77cf40da87 feat: profile-aware gateway service names (#671)
Thanks @bjesuiter.

Co-authored-by: Benjamin Jesuiter <bjesuiter@gmail.com>
2026-01-15 05:23:41 +00:00
Peter Steinberger 1fe8df85cb docs: clarify extension tab attachment 2026-01-15 05:20:56 +00:00
Peter Steinberger 139f80a291 chore: format sources and update protocol outputs 2026-01-15 05:17:19 +00:00
Peter Steinberger 2d066b8715 docs: explain sandboxed browser control 2026-01-15 05:17:12 +00:00
Peter Steinberger 757243993c fix: stabilize gateway config tests + tool schema 2026-01-15 05:16:28 +00:00
Peter Steinberger 4fb114dcb3 feat: improve extension options diagnostics 2026-01-15 05:15:33 +00:00
Peter Steinberger 612cdac4c3 docs: clarify when browser serve is needed 2026-01-15 05:15:33 +00:00
Peter Steinberger 57c66fe813 fix: clean up onboarding + channel selection types 2026-01-15 05:12:33 +00:00
Peter Steinberger 9c02ea9098 feat: polish chrome extension UX 2026-01-15 05:11:03 +00:00
Peter Steinberger 042b65dfcc feat: add web tools config to configure 2026-01-15 05:08:56 +00:00
Peter Steinberger f6a72ef3c2 feat: improve browser extension install output 2026-01-15 05:05:27 +00:00
Peter Steinberger 568cc368ae feat: add onboarding plugin install flow 2026-01-15 05:04:09 +00:00
Peter Steinberger 5abe3c2145 feat: add plugin HTTP hooks + Zalo plugin 2026-01-15 05:04:09 +00:00
Peter Steinberger 0e76d21f11 docs(security): mention audit --fix 2026-01-15 05:03:13 +00:00
Peter Steinberger 5e8693bc42 chore: bump pi packages to 0.45.7 2026-01-15 04:55:40 +00:00
Peter Steinberger ef78b198cb feat: add Chrome extension browser relay 2026-01-15 04:52:28 +00:00
Peter Steinberger 5fdaef3646 fix: downgrade unsigned gemini thinking 2026-01-15 04:51:21 +00:00
Peter Steinberger fa4670c5fe feat: improve agent auth guidance 2026-01-15 04:51:21 +00:00
Peter Steinberger c4402a1ce5 docs: clarify agent auth + sandboxed skills 2026-01-15 04:51:03 +00:00
Peter Steinberger edd8c613d6 feat(security): add audit --fix 2026-01-15 04:50:06 +00:00
Peter Steinberger 0a7f5bf6a5 test(gateway): fix config.patch baseHash fixtures 2026-01-15 04:50:06 +00:00
Peter Steinberger eb3e865f15 fix(build): restore tool policy typing 2026-01-15 04:50:06 +00:00
Roshan SinghandPeter Steinberger 1baa55c145 Structured subagent announce output + include run outcome (#835)
* docs: clarify subagent announce status

* Make subagent announce structured and include run outcome

* fix: stabilize sub-agent announce status (#835) (thanks @roshanasingh4)

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-01-15 04:48:07 +00:00
Peter Steinberger 2e0325e3bf feat: add web search hint to onboarding 2026-01-15 04:25:19 +00:00
Peter Steinberger dcadaad228 docs: add web search note to quick start 2026-01-15 04:16:09 +00:00
Peter Steinberger 1533868680 docs: add web search FAQ 2026-01-15 04:15:24 +00:00
Peter Steinberger f275cc180b feat: add web tools 2026-01-15 04:07:40 +00:00
Peter Steinberger 31d3aef8d6 fix: prevent config clobbering 2026-01-15 04:06:11 +00:00
Peter Steinberger bd467ff765 chore: mark 2026.1.14 as unreleased 2026-01-15 04:05:18 +00:00
Peter Steinberger 429f973280 test: cover browser snapshot labels and efficient mode 2026-01-15 04:04:30 +00:00
Peter Steinberger 6320f739d4 refactor: centralize whatsapp auth detection 2026-01-15 04:01:06 +00:00
Peter Steinberger 1732932c57 fix: unblock launchctl stub on windows 2026-01-15 03:58:32 +00:00
Peter Steinberger 574b6ab5b1 docs: document provider tool policies 2026-01-15 03:55:20 +00:00
Peter Steinberger 1c737f88fe test: cover provider tool policies 2026-01-15 03:55:20 +00:00
Peter Steinberger fa8d9b9189 feat: add provider-specific tool policies 2026-01-15 03:55:20 +00:00
Peter Steinberger 512dbedee3 chore: bump Peekaboo submodule 2026-01-15 03:54:06 +00:00
Peter Steinberger db21c2d397 docs: clarify group sandbox folder allowlist 2026-01-15 03:52:57 +00:00
Peter SteinbergerandGeorge Pickett 1078d178d7 fix: doctor ack reaction migration (#927)
Thanks @grp06.

Co-authored-by: George Pickett <gpickett00@gmail.com>
2026-01-15 03:51:55 +00:00
Peter Steinberger a6e780b2f6 feat: add browser snapshot modes 2026-01-15 03:50:57 +00:00
Peter Steinberger 4e48d0a431 docs: document personal DMs vs public groups 2026-01-15 03:48:14 +00:00
Peter Steinberger c289a88f50 fix(security): force hono 4.11.4 2026-01-15 03:40:02 +00:00
Peter Steinberger 4ce495cb2c Merge pull request #849 from ndraiman/fix/cli-launchd-enable-before-bootstrap
fix(daemon): enable launchd service before bootstrap
2026-01-15 03:36:14 +00:00
Peter Steinberger 6f5fc2276a chore(packaging): align workspace patch version 2026-01-15 03:33:00 +00:00
212 changed files with 10552 additions and 923 deletions

No files matched your search

+20 -2
View File
@@ -1,16 +1,30 @@
# Changelog
## 2026.1.15
## 2026.1.14 (unreleased)
### Changes
- Security: add `clawdbot security audit` (`--deep`) and surface it in `status --all` and `doctor`.
- Docs: clarify per-agent auth stores, sandboxed skill binaries, and elevated semantics.
- Daemon: support profile-aware service names for multi-gateway setups. (#671) — thanks @bjesuiter.
- Docs: add FAQ entries for missing provider auth after adding agents and Gemini thinking signature errors.
- Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging.
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`.
- Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks).
- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba.
- Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`.
- Docs: expand gateway security hardening guidance and incident response checklist.
- Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf.
- Security: add detect-secrets CI scan and baseline guidance. (#227) — thanks @Hyaxia.
- Tools: add `web_search`/`web_fetch` (Brave API), auto-enable `web_fetch` for sandboxed sessions, and remove the `brave-search` skill.
- CLI/Docs: add a web tools configure section for storing Brave API keys and update onboarding tips.
- Browser: add Chrome extension relay takeover mode (toolbar button), plus `clawdbot browser extension install/path` and remote browser control via `clawdbot browser serve` + `browser.controlToken`.
### Fixes
- Browser: add tests for snapshot labels/efficient query params and labeled image responses.
- Google: downgrade unsigned thinking blocks before send to avoid missing signature errors.
- Agents: cap tool call IDs for OpenAI/OpenRouter to avoid request rejections. (#875) — thanks @j1philli.
- Doctor: avoid re-adding WhatsApp config when only legacy ack reactions are set. (#927, fixes #900) — thanks @grp06.
- Agents: scrub tuple `items` schemas for Gemini tool calls. (#926, fixes #746) — thanks @grp06.
- Agents: stabilize sub-agent announce status from runtime outcomes and normalize Result/Notes. (#835) — thanks @roshanasingh4.
- Embedded runner: suppress raw API error payloads from replies. (#924) — thanks @grp06.
- Auth: normalize Claude Code CLI profile mode to oauth and auto-migrate config. (#855) — thanks @sebslight.
- Daemon: clear persisted launchd disabled state before bootstrap (fixes `daemon install` after uninstall). (#849) — thanks @ndraiman.
@@ -19,6 +33,8 @@
- Sandbox: preserve configured PATH for `docker exec` so custom tools remain available. (#873) — thanks @akonyer.
- Slack: respect `channels.slack.requireMention` default when resolving channel mention gating. (#850) — thanks @evalexpr.
- Telegram: aggregate split inbound messages into one prompt (reduces “one reply per fragment”).
- Auto-reply: treat trailing `NO_REPLY` tokens as silent replies.
- Config: prevent partial config writes from clobbering unrelated settings (base hash guard + merge patch for connection saves).
## 2026.1.14
@@ -77,6 +93,7 @@
- Memory: new `clawdbot memory` CLI plus `memory_search`/`memory_get` tools with snippets + line ranges; index stored under `~/.clawdbot/memory/{agentId}.sqlite` with watch-on-by-default.
- Agents: strengthen memory recall guidance; make workspace bootstrap truncation configurable (default 20k) with warnings; add default sub-agent model config.
- Tools/Sandbox: add tool profiles + group shorthands; support tool-policy groups in `tools.sandbox.tools`; drop legacy `memory` shorthand; allow Docker bind mounts via `docker.binds`. (#790) — thanks @akonyer.
- Tools: add provider/model-specific tool policy overrides (`tools.byProvider`) to trim tool exposure per provider.
- Tools: add browser `scrollintoview` action; allow Claude/Gemini tool param aliases; allow thinking `xhigh` for GPT-5.2/Codex with safe downgrades. (#793) — thanks @hsrvc; (#444) — thanks @grp06.
- Gateway/CLI: add Tailscale binary discovery, custom bind mode, and probe auth retry; add `clawdbot dashboard` auto-open flow; default native slash commands to `"auto"` with per-provider overrides. (#740) — thanks @jeffersonwarrior.
- Auth/Onboarding: add Chutes OAuth (PKCE + refresh + onboarding choice); normalize API key inputs; default TUI onboarding to `deliver: false`. (#726) — thanks @FrieSei; (#791) — thanks @roshanasingh4.
@@ -89,6 +106,7 @@
### Fixes
- Doctor: warn on pnpm workspace mismatches, missing Control UI assets, and missing tsx binaries; offer UI rebuilds.
- Tools: apply global tool allow/deny even when agent-specific tool policy is set.
- Models/Providers: treat credential validation failures as auth errors to trigger fallback; normalize `${ENV_VAR}` apiKey values and auto-fill missing provider keys; preserve explicit GitHub Copilot provider config + agent-dir auth profiles. (#822) — thanks @sebslight; (#705) — thanks @TAGOOZ.
- Auth: drop invalid auth profiles from ordering so environment keys can still be used for providers like MiniMax.
- Gemini: normalize Gemini 3 ids to preview variants; strip Gemini CLI tool call/response ids; downgrade missing `thought_signature`; strip Claude `msg_*` thought_signature fields to avoid base64 decode errors. (#795) — thanks @thewilloftheshadow; (#783) — thanks @ananth-vardhan-cn; (#793) — thanks @hsrvc; (#805) — thanks @marcmarg.
Submodule Peekaboo updated: c1243a7978...95ad7532c1.
@@ -34,8 +34,7 @@ extension AttributedString {
var ranges: [Range<AttributedString.Index>] = []
for wordRange in wordRanges {
if let lastRange = ranges.last,
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength
{
self[lastRange].characters.count + self[wordRange].characters.count <= maxLength {
ranges[ranges.count - 1] = lastRange.lowerBound..<wordRange.upperBound
} else {
ranges.append(wordRange)
@@ -13,8 +13,7 @@ public actor TranscriptsStore {
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
fileURL = dir.appendingPathComponent("transcripts.log")
if let data = try? Data(contentsOf: fileURL),
let text = String(data: data, encoding: .utf8)
{
let text = String(data: data, encoding: .utf8) {
entries = text.split(separator: "\n").map(String.init).suffix(limit)
}
}
@@ -24,8 +24,7 @@ public struct WakeWordGateConfig: Sendable, Equatable {
public init(
triggers: [String],
minPostTriggerGap: TimeInterval = 0.45,
minCommandLength: Int = 1)
{
minCommandLength: Int = 1) {
self.triggers = triggers
self.minPostTriggerGap = minPostTriggerGap
self.minCommandLength = minCommandLength
@@ -57,6 +56,12 @@ public enum WakeWordGate {
let tokens: [String]
}
private struct MatchCandidate {
let index: Int
let triggerEnd: TimeInterval
let gap: TimeInterval
}
public static func match(
transcript: String,
segments: [WakeWordSegment],
@@ -68,7 +73,7 @@ public enum WakeWordGate {
let tokens = normalizeSegments(segments)
guard !tokens.isEmpty else { return nil }
var best: (index: Int, triggerEnd: TimeInterval, gap: TimeInterval)?
var best: MatchCandidate?
for trigger in triggerTokens {
let count = trigger.tokens.count
@@ -84,7 +89,7 @@ public enum WakeWordGate {
if let best, i <= best.index { continue }
best = (i, triggerEnd, gap)
best = MatchCandidate(index: i, triggerEnd: triggerEnd, gap: gap)
}
}
@@ -24,7 +24,7 @@ enum CLIRegistry {
subcommands: [
descriptor(for: ServiceInstall.self),
descriptor(for: ServiceUninstall.self),
descriptor(for: ServiceStatus.self),
descriptor(for: ServiceStatus.self)
])
let doctorDesc = descriptor(for: DoctorCommand.self)
let setupDesc = descriptor(for: SetupCommand.self)
@@ -54,7 +54,7 @@ enum CLIRegistry {
startDesc,
stopDesc,
restartDesc,
statusDesc,
statusDesc
])
return [root]
}
@@ -25,7 +25,7 @@ private enum LaunchdHelper {
"Label": label,
"ProgramArguments": [executable, "serve"],
"RunAtLoad": true,
"KeepAlive": true,
"KeepAlive": true
]
let data = try PropertyListSerialization.data(fromPropertyList: plist, format: .xml, options: 0)
try data.write(to: plistURL)
+112 -67
View File
@@ -25,78 +25,123 @@ private func dispatch(invocation: CommandInvocation) async throws {
switch first {
case "swabble":
guard path.count >= 2 else { throw CommanderProgramError.missingSubcommand(command: "swabble") }
let sub = path[1]
switch sub {
case "serve":
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
case "transcribe":
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
case "test-hook":
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
case "mic":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "mic") }
let micSub = path[2]
if micSub == "list" {
var cmd = MicList(parsed: parsed)
try await cmd.run()
} else if micSub == "set" {
var cmd = MicSet(parsed: parsed)
try await cmd.run()
} else {
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
case "service":
guard path.count >= 3 else { throw CommanderProgramError.missingSubcommand(command: "service") }
let svcSub = path[2]
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
case "doctor":
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
case "setup":
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
case "health":
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
case "tail-log":
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
case "start":
var cmd = StartCommand()
try await cmd.run()
case "stop":
var cmd = StopCommand()
try await cmd.run()
case "restart":
var cmd = RestartCommand()
try await cmd.run()
case "status":
var cmd = StatusCommand()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
try await dispatchSwabble(parsed: parsed, path: path)
default:
throw CommanderProgramError.unknownCommand(first)
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatchSwabble(parsed: ParsedValues, path: [String]) async throws {
let sub = try subcommand(path, index: 1, command: "swabble")
switch sub {
case "mic":
try await dispatchMic(parsed: parsed, path: path)
case "service":
try await dispatchService(path: path)
default:
let handlers = swabbleHandlers(parsed: parsed)
guard let handler = handlers[sub] else {
throw CommanderProgramError.unknownSubcommand(command: "swabble", name: sub)
}
try await handler()
}
}
@available(macOS 26.0, *)
@MainActor
private func swabbleHandlers(parsed: ParsedValues) -> [String: () async throws -> Void] {
[
"serve": {
var cmd = ServeCommand(parsed: parsed)
try await cmd.run()
},
"transcribe": {
var cmd = TranscribeCommand(parsed: parsed)
try await cmd.run()
},
"test-hook": {
var cmd = TestHookCommand(parsed: parsed)
try await cmd.run()
},
"doctor": {
var cmd = DoctorCommand(parsed: parsed)
try await cmd.run()
},
"setup": {
var cmd = SetupCommand(parsed: parsed)
try await cmd.run()
},
"health": {
var cmd = HealthCommand(parsed: parsed)
try await cmd.run()
},
"tail-log": {
var cmd = TailLogCommand(parsed: parsed)
try await cmd.run()
},
"start": {
var cmd = StartCommand()
try await cmd.run()
},
"stop": {
var cmd = StopCommand()
try await cmd.run()
},
"restart": {
var cmd = RestartCommand()
try await cmd.run()
},
"status": {
var cmd = StatusCommand()
try await cmd.run()
}
]
}
@available(macOS 26.0, *)
@MainActor
private func dispatchMic(parsed: ParsedValues, path: [String]) async throws {
let micSub = try subcommand(path, index: 2, command: "mic")
switch micSub {
case "list":
var cmd = MicList(parsed: parsed)
try await cmd.run()
case "set":
var cmd = MicSet(parsed: parsed)
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "mic", name: micSub)
}
}
@available(macOS 26.0, *)
@MainActor
private func dispatchService(path: [String]) async throws {
let svcSub = try subcommand(path, index: 2, command: "service")
switch svcSub {
case "install":
var cmd = ServiceInstall()
try await cmd.run()
case "uninstall":
var cmd = ServiceUninstall()
try await cmd.run()
case "status":
var cmd = ServiceStatus()
try await cmd.run()
default:
throw CommanderProgramError.unknownSubcommand(command: "service", name: svcSub)
}
}
private func subcommand(_ path: [String], index: Int, command: String) throws -> String {
guard path.count > index else {
throw CommanderProgramError.missingSubcommand(command: command)
}
return path[index]
}
if #available(macOS 26.0, *) {
let exitCode = await runCLI()
exit(exitCode)
+10 -1
View File
@@ -19,6 +19,7 @@ enum ConfigStore {
}
private static let overrideStore = OverrideStore()
@MainActor private static var lastHash: String?
private static func isRemoteMode() async -> Bool {
let overrides = await self.overrideStore.overrides
@@ -75,6 +76,7 @@ enum ConfigStore {
method: .configGet,
params: nil,
timeoutMs: 8000)
self.lastHash = snap.hash
return snap.config?.mapValues { $0.foundationValue } ?? [:]
} catch {
return nil
@@ -83,17 +85,24 @@ enum ConfigStore {
@MainActor
private static func saveToGateway(_ root: [String: Any]) async throws {
if self.lastHash == nil {
_ = await self.loadFromGateway()
}
let data = try JSONSerialization.data(withJSONObject: root, options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
throw NSError(domain: "ConfigStore", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Failed to encode config.",
])
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
var params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
if let baseHash = self.lastHash {
params["baseHash"] = AnyCodable(baseHash)
}
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
params: params,
timeoutMs: 10000)
_ = await self.loadFromGateway()
}
#if DEBUG
@@ -12,6 +12,7 @@ extension ConnectionsStore {
? "Config invalid; fix it in ~/.clawdbot/clawdbot.json."
: nil
self.configRoot = snap.config?.mapValues { $0.foundationValue } ?? [:]
self.configHash = snap.hash
self.configLoaded = true
self.applyUIConfig(snap)
@@ -34,10 +35,22 @@ extension ConnectionsStore {
AppStateStore.shared.seamColorHex = rawSeam.isEmpty ? nil : rawSeam
}
private func resolveChannelConfig(_ snap: ConfigSnapshot, key: String) -> [String: AnyCodable]? {
if let channels = snap.config?["channels"]?.dictionaryValue,
let entry = channels[key]?.dictionaryValue {
return entry
}
return snap.config?[key]?.dictionaryValue
}
private func applyTelegramConfig(_ snap: ConfigSnapshot) {
let telegram = snap.config?["telegram"]?.dictionaryValue
let telegram = self.resolveChannelConfig(snap, key: "telegram")
self.telegramToken = telegram?["botToken"]?.stringValue ?? ""
self.telegramRequireMention = telegram?["requireMention"]?.boolValue ?? true
let groups = telegram?["groups"]?.dictionaryValue
let defaultGroup = groups?["*"]?.dictionaryValue
self.telegramRequireMention = defaultGroup?["requireMention"]?.boolValue
?? telegram?["requireMention"]?.boolValue
?? true
self.telegramAllowFrom = self.stringList(from: telegram?["allowFrom"]?.arrayValue)
self.telegramProxy = telegram?["proxy"]?.stringValue ?? ""
self.telegramWebhookUrl = telegram?["webhookUrl"]?.stringValue ?? ""
@@ -46,7 +59,7 @@ extension ConnectionsStore {
}
private func applyDiscordConfig(_ snap: ConfigSnapshot) {
let discord = snap.config?["discord"]?.dictionaryValue
let discord = self.resolveChannelConfig(snap, key: "discord")
self.discordEnabled = discord?["enabled"]?.boolValue ?? true
self.discordToken = discord?["token"]?.stringValue ?? ""
@@ -122,7 +135,7 @@ extension ConnectionsStore {
}
private func applySignalConfig(_ snap: ConfigSnapshot) {
let signal = snap.config?["signal"]?.dictionaryValue
let signal = self.resolveChannelConfig(snap, key: "signal")
self.signalEnabled = signal?["enabled"]?.boolValue ?? true
self.signalAccount = signal?["account"]?.stringValue ?? ""
self.signalHttpUrl = signal?["httpUrl"]?.stringValue ?? ""
@@ -139,7 +152,7 @@ extension ConnectionsStore {
}
private func applyIMessageConfig(_ snap: ConfigSnapshot) {
let imessage = snap.config?["imessage"]?.dictionaryValue
let imessage = self.resolveChannelConfig(snap, key: "imessage")
self.imessageEnabled = imessage?["enabled"]?.boolValue ?? true
self.imessageCliPath = imessage?["cliPath"]?.stringValue ?? ""
self.imessageDbPath = imessage?["dbPath"]?.stringValue ?? ""
@@ -150,6 +163,15 @@ extension ConnectionsStore {
self.imessageMediaMaxMb = self.numberString(from: imessage?["mediaMaxMb"])
}
private func channelConfigRoot(for key: String) -> [String: Any] {
if let channels = self.configRoot["channels"] as? [String: Any],
let entry = channels[key] as? [String: Any]
{
return entry
}
return self.configRoot[key] as? [String: Any] ?? [:]
}
func saveTelegramConfig() async {
guard !self.isSavingConfig else { return }
self.isSavingConfig = true
@@ -158,30 +180,24 @@ extension ConnectionsStore {
await self.loadConfig()
}
var telegram: [String: Any] = (self.configRoot["telegram"] as? [String: Any]) ?? [:]
let token = self.trimmed(self.telegramToken)
if token.isEmpty {
telegram.removeValue(forKey: "botToken")
} else {
telegram["botToken"] = token
var telegram: [String: Any] = [:]
if !self.isTelegramTokenLocked {
self.setPatchString(&telegram, key: "botToken", value: self.telegramToken)
}
telegram["requireMention"] = self.telegramRequireMention
telegram["requireMention"] = NSNull()
telegram["groups"] = [
"*": [
"requireMention": self.telegramRequireMention,
],
]
let allow = self.splitCsv(self.telegramAllowFrom)
if allow.isEmpty {
telegram.removeValue(forKey: "allowFrom")
} else {
telegram["allowFrom"] = allow
}
self.setPatchList(&telegram, key: "allowFrom", values: allow)
self.setPatchString(&telegram, key: "proxy", value: self.telegramProxy)
self.setPatchString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setPatchString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setPatchString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
self.setOptionalString(&telegram, key: "proxy", value: self.telegramProxy)
self.setOptionalString(&telegram, key: "webhookUrl", value: self.telegramWebhookUrl)
self.setOptionalString(&telegram, key: "webhookSecret", value: self.telegramWebhookSecret)
self.setOptionalString(&telegram, key: "webhookPath", value: self.telegramWebhookPath)
self.setSection("telegram", payload: telegram)
await self.persistConfig()
await self.persistChannelPatch("telegram", payload: telegram)
}
func saveDiscordConfig() async {
@@ -192,9 +208,9 @@ extension ConnectionsStore {
await self.loadConfig()
}
let discord = self.buildDiscordConfig()
self.setSection("discord", payload: discord)
await self.persistConfig()
let base = self.channelConfigRoot(for: "discord")
let discord = self.buildDiscordPatch(base: base)
await self.persistChannelPatch("discord", payload: discord)
}
func saveSignalConfig() async {
@@ -205,42 +221,23 @@ extension ConnectionsStore {
await self.loadConfig()
}
var signal: [String: Any] = (self.configRoot["signal"] as? [String: Any]) ?? [:]
if self.signalEnabled {
signal.removeValue(forKey: "enabled")
} else {
signal["enabled"] = false
}
self.setOptionalString(&signal, key: "account", value: self.signalAccount)
self.setOptionalString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setOptionalString(&signal, key: "httpHost", value: self.signalHttpHost)
self.setOptionalNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setOptionalString(&signal, key: "cliPath", value: self.signalCliPath)
if self.signalAutoStart {
signal.removeValue(forKey: "autoStart")
} else {
signal["autoStart"] = false
}
self.setOptionalString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setOptionalBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments)
self.setOptionalBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories)
self.setOptionalBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts)
var signal: [String: Any] = [:]
self.setPatchBool(&signal, key: "enabled", value: self.signalEnabled, defaultValue: true)
self.setPatchString(&signal, key: "account", value: self.signalAccount)
self.setPatchString(&signal, key: "httpUrl", value: self.signalHttpUrl)
self.setPatchString(&signal, key: "httpHost", value: self.signalHttpHost)
self.setPatchNumber(&signal, key: "httpPort", value: self.signalHttpPort)
self.setPatchString(&signal, key: "cliPath", value: self.signalCliPath)
self.setPatchBool(&signal, key: "autoStart", value: self.signalAutoStart, defaultValue: true)
self.setPatchString(&signal, key: "receiveMode", value: self.signalReceiveMode)
self.setPatchBool(&signal, key: "ignoreAttachments", value: self.signalIgnoreAttachments, defaultValue: false)
self.setPatchBool(&signal, key: "ignoreStories", value: self.signalIgnoreStories, defaultValue: false)
self.setPatchBool(&signal, key: "sendReadReceipts", value: self.signalSendReadReceipts, defaultValue: false)
let allow = self.splitCsv(self.signalAllowFrom)
if allow.isEmpty {
signal.removeValue(forKey: "allowFrom")
} else {
signal["allowFrom"] = allow
}
self.setPatchList(&signal, key: "allowFrom", values: allow)
self.setPatchNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
self.setOptionalNumber(&signal, key: "mediaMaxMb", value: self.signalMediaMaxMb)
self.setSection("signal", payload: signal)
await self.persistConfig()
await self.persistChannelPatch("signal", payload: signal)
}
func saveIMessageConfig() async {
@@ -251,147 +248,168 @@ extension ConnectionsStore {
await self.loadConfig()
}
var imessage: [String: Any] = (self.configRoot["imessage"] as? [String: Any]) ?? [:]
if self.imessageEnabled {
imessage.removeValue(forKey: "enabled")
} else {
imessage["enabled"] = false
}
self.setOptionalString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setOptionalString(&imessage, key: "dbPath", value: self.imessageDbPath)
var imessage: [String: Any] = [:]
self.setPatchBool(&imessage, key: "enabled", value: self.imessageEnabled, defaultValue: true)
self.setPatchString(&imessage, key: "cliPath", value: self.imessageCliPath)
self.setPatchString(&imessage, key: "dbPath", value: self.imessageDbPath)
let service = self.trimmed(self.imessageService)
if service.isEmpty || service == "auto" {
imessage.removeValue(forKey: "service")
imessage["service"] = NSNull()
} else {
imessage["service"] = service
}
self.setOptionalString(&imessage, key: "region", value: self.imessageRegion)
self.setPatchString(&imessage, key: "region", value: self.imessageRegion)
let allow = self.splitCsv(self.imessageAllowFrom)
if allow.isEmpty {
imessage.removeValue(forKey: "allowFrom")
} else {
imessage["allowFrom"] = allow
}
self.setPatchList(&imessage, key: "allowFrom", values: allow)
self.setOptionalBool(&imessage, key: "includeAttachments", value: self.imessageIncludeAttachments)
self.setOptionalNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
self.setPatchBool(
&imessage,
key: "includeAttachments",
value: self.imessageIncludeAttachments,
defaultValue: false)
self.setPatchNumber(&imessage, key: "mediaMaxMb", value: self.imessageMediaMaxMb)
self.setSection("imessage", payload: imessage)
await self.persistConfig()
await self.persistChannelPatch("imessage", payload: imessage)
}
private func buildDiscordConfig() -> [String: Any] {
var discord: [String: Any] = (self.configRoot["discord"] as? [String: Any]) ?? [:]
if self.discordEnabled {
discord.removeValue(forKey: "enabled")
} else {
discord["enabled"] = false
private func buildDiscordPatch(base: [String: Any]) -> [String: Any] {
var discord: [String: Any] = [:]
self.setPatchBool(&discord, key: "enabled", value: self.discordEnabled, defaultValue: true)
if !self.isDiscordTokenLocked {
self.setPatchString(&discord, key: "token", value: self.discordToken)
}
self.setOptionalString(&discord, key: "token", value: self.discordToken)
if let dm = self.buildDiscordDmConfig(base: discord["dm"] as? [String: Any] ?? [:]) {
if let dm = self.buildDiscordDmPatch() {
discord["dm"] = dm
} else {
discord.removeValue(forKey: "dm")
discord["dm"] = NSNull()
}
self.setOptionalNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setOptionalInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setOptionalInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
self.setPatchNumber(&discord, key: "mediaMaxMb", value: self.discordMediaMaxMb)
self.setPatchInt(&discord, key: "historyLimit", value: self.discordHistoryLimit, allowZero: true)
self.setPatchInt(&discord, key: "textChunkLimit", value: self.discordTextChunkLimit, allowZero: false)
let replyToMode = self.trimmed(self.discordReplyToMode)
if replyToMode.isEmpty || replyToMode == "off" {
discord.removeValue(forKey: "replyToMode")
} else if ["first", "all"].contains(replyToMode) {
discord["replyToMode"] = replyToMode
if replyToMode.isEmpty || replyToMode == "off" || !["first", "all"].contains(replyToMode) {
discord["replyToMode"] = NSNull()
} else {
discord.removeValue(forKey: "replyToMode")
discord["replyToMode"] = replyToMode
}
if let guilds = self.buildDiscordGuildsConfig() {
let baseGuilds = base["guilds"] as? [String: Any] ?? [:]
if let guilds = self.buildDiscordGuildsPatch(base: baseGuilds) {
discord["guilds"] = guilds
} else {
discord.removeValue(forKey: "guilds")
discord["guilds"] = NSNull()
}
if let actions = self.buildDiscordActionsConfig(base: discord["actions"] as? [String: Any] ?? [:]) {
if let actions = self.buildDiscordActionsPatch() {
discord["actions"] = actions
} else {
discord.removeValue(forKey: "actions")
discord["actions"] = NSNull()
}
if let slash = self.buildDiscordSlashConfig(base: discord["slashCommand"] as? [String: Any] ?? [:]) {
if let slash = self.buildDiscordSlashPatch() {
discord["slashCommand"] = slash
} else {
discord.removeValue(forKey: "slashCommand")
discord["slashCommand"] = NSNull()
}
return discord
}
private func buildDiscordDmConfig(base: [String: Any]) -> [String: Any]? {
var dm = base
if self.discordDmEnabled {
dm.removeValue(forKey: "enabled")
} else {
dm["enabled"] = false
}
private func buildDiscordDmPatch() -> [String: Any]? {
var dm: [String: Any] = [:]
self.setPatchBool(&dm, key: "enabled", value: self.discordDmEnabled, defaultValue: true)
let allow = self.splitCsv(self.discordAllowFrom)
if allow.isEmpty {
dm.removeValue(forKey: "allowFrom")
} else {
dm["allowFrom"] = allow
}
if self.discordGroupEnabled {
dm["groupEnabled"] = true
} else {
dm.removeValue(forKey: "groupEnabled")
}
self.setPatchList(&dm, key: "allowFrom", values: allow)
self.setPatchBool(&dm, key: "groupEnabled", value: self.discordGroupEnabled, defaultValue: false)
let groupChannels = self.splitCsv(self.discordGroupChannels)
if groupChannels.isEmpty {
dm.removeValue(forKey: "groupChannels")
} else {
dm["groupChannels"] = groupChannels
}
self.setPatchList(&dm, key: "groupChannels", values: groupChannels)
return dm.isEmpty ? nil : dm
}
private func buildDiscordGuildsConfig() -> [String: Any]? {
let guilds: [String: Any] = self.discordGuilds.reduce(into: [:]) { result, entry in
let key = self.trimmed(entry.key)
guard !key.isEmpty else { return }
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if !slug.isEmpty { payload["slug"] = slug }
if entry.requireMention { payload["requireMention"] = true }
if ["off", "own", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
}
let users = self.splitCsv(entry.users)
if !users.isEmpty { payload["users"] = users }
let channels: [String: Any] = entry.channels.reduce(into: [:]) { channelsResult, channel in
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { return }
var channelPayload: [String: Any] = [:]
if !channel.allow { channelPayload["allow"] = false }
if channel.requireMention { channelPayload["requireMention"] = true }
channelsResult[channelKey] = channelPayload
}
if !channels.isEmpty { payload["channels"] = channels }
result[key] = payload
private func buildDiscordGuildsPatch(base: [String: Any]) -> Any? {
if self.discordGuilds.isEmpty {
return NSNull()
}
return guilds.isEmpty ? nil : guilds
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for entry in self.discordGuilds {
let key = self.trimmed(entry.key)
guard !key.isEmpty else { continue }
formKeys.insert(key)
let baseGuild = base[key] as? [String: Any] ?? [:]
patch[key] = self.buildDiscordGuildPatch(entry, base: baseGuild)
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsConfig(base: [String: Any]) -> [String: Any]? {
var actions = base
private func buildDiscordGuildPatch(_ entry: DiscordGuildForm, base: [String: Any]) -> [String: Any] {
var payload: [String: Any] = [:]
let slug = self.trimmed(entry.slug)
if slug.isEmpty {
payload["slug"] = NSNull()
} else {
payload["slug"] = slug
}
if entry.requireMention {
payload["requireMention"] = true
} else {
payload["requireMention"] = NSNull()
}
if ["off", "all", "allowlist"].contains(entry.reactionNotifications) {
payload["reactionNotifications"] = entry.reactionNotifications
} else {
payload["reactionNotifications"] = NSNull()
}
let users = self.splitCsv(entry.users)
self.setPatchList(&payload, key: "users", values: users)
let baseChannels = base["channels"] as? [String: Any] ?? [:]
if let channels = self.buildDiscordChannelsPatch(base: baseChannels, forms: entry.channels) {
payload["channels"] = channels
} else {
payload["channels"] = NSNull()
}
return payload
}
private func buildDiscordChannelsPatch(base: [String: Any], forms: [DiscordGuildChannelForm]) -> Any? {
if forms.isEmpty {
return NSNull()
}
var patch: [String: Any] = [:]
let baseKeys = Set(base.keys)
var formKeys = Set<String>()
for channel in forms {
let channelKey = self.trimmed(channel.key)
guard !channelKey.isEmpty else { continue }
formKeys.insert(channelKey)
var channelPayload: [String: Any] = [:]
self.setPatchBool(&channelPayload, key: "allow", value: channel.allow, defaultValue: true)
self.setPatchBool(
&channelPayload,
key: "requireMention",
value: channel.requireMention,
defaultValue: false)
patch[channelKey] = channelPayload
}
for key in baseKeys.subtracting(formKeys) {
patch[key] = NSNull()
}
return patch.isEmpty ? NSNull() : patch
}
private func buildDiscordActionsPatch() -> [String: Any]? {
var actions: [String: Any] = [:]
self.setAction(&actions, key: "reactions", value: self.discordActionReactions, defaultValue: true)
self.setAction(&actions, key: "stickers", value: self.discordActionStickers, defaultValue: true)
self.setAction(&actions, key: "polls", value: self.discordActionPolls, defaultValue: true)
@@ -410,52 +428,44 @@ extension ConnectionsStore {
return actions.isEmpty ? nil : actions
}
private func buildDiscordSlashConfig(base: [String: Any]) -> [String: Any]? {
var slash = base
if self.discordSlashEnabled {
slash["enabled"] = true
} else {
slash.removeValue(forKey: "enabled")
}
self.setOptionalString(&slash, key: "name", value: self.discordSlashName)
self.setOptionalString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
if self.discordSlashEphemeral {
slash.removeValue(forKey: "ephemeral")
} else {
slash["ephemeral"] = false
}
private func buildDiscordSlashPatch() -> [String: Any]? {
var slash: [String: Any] = [:]
self.setPatchBool(&slash, key: "enabled", value: self.discordSlashEnabled, defaultValue: false)
self.setPatchString(&slash, key: "name", value: self.discordSlashName)
self.setPatchString(&slash, key: "sessionPrefix", value: self.discordSlashSessionPrefix)
self.setPatchBool(&slash, key: "ephemeral", value: self.discordSlashEphemeral, defaultValue: true)
return slash.isEmpty ? nil : slash
}
private func persistConfig() async {
private func persistChannelPatch(_ channelId: String, payload: [String: Any]) async {
do {
guard let baseHash = self.configHash else {
self.configStatus = "Config hash missing; reload and retry."
return
}
let data = try JSONSerialization.data(
withJSONObject: self.configRoot,
withJSONObject: ["channels": [channelId: payload]],
options: [.prettyPrinted, .sortedKeys])
guard let raw = String(data: data, encoding: .utf8) else {
self.configStatus = "Failed to encode config."
return
}
let params: [String: AnyCodable] = ["raw": AnyCodable(raw)]
let params: [String: AnyCodable] = [
"raw": AnyCodable(raw),
"baseHash": AnyCodable(baseHash),
]
_ = try await GatewayConnection.shared.requestRaw(
method: .configSet,
method: .configPatch,
params: params,
timeoutMs: 10000)
self.configStatus = "Saved to ~/.clawdbot/clawdbot.json."
await self.loadConfig()
await self.refresh(probe: true)
} catch {
self.configStatus = error.localizedDescription
}
}
private func setSection(_ key: String, payload: [String: Any]) {
if payload.isEmpty {
self.configRoot.removeValue(forKey: key)
} else {
self.configRoot[key] = payload
}
}
private func stringList(from values: [AnyCodable]?) -> String {
guard let values else { return "" }
let strings = values.compactMap { entry -> String? in
@@ -492,25 +502,29 @@ extension ConnectionsStore {
value.trimmingCharacters(in: .whitespacesAndNewlines)
}
private func setOptionalString(_ target: inout [String: Any], key: String, value: String) {
private func setPatchString(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target.removeValue(forKey: key)
target[key] = NSNull()
} else {
target[key] = trimmed
}
}
private func setOptionalNumber(_ target: inout [String: Any], key: String, value: String) {
private func setPatchNumber(_ target: inout [String: Any], key: String, value: String) {
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target.removeValue(forKey: key)
} else if let number = Double(trimmed) {
target[key] = NSNull()
return
}
if let number = Double(trimmed) {
target[key] = number
} else {
target[key] = NSNull()
}
}
private func setOptionalInt(
private func setPatchInt(
_ target: inout [String: Any],
key: String,
value: String,
@@ -518,26 +532,39 @@ extension ConnectionsStore {
{
let trimmed = self.trimmed(value)
if trimmed.isEmpty {
target.removeValue(forKey: key)
target[key] = NSNull()
return
}
guard let number = Int(trimmed) else {
target.removeValue(forKey: key)
target[key] = NSNull()
return
}
let isValid = allowZero ? number >= 0 : number > 0
guard isValid else {
target.removeValue(forKey: key)
target[key] = NSNull()
return
}
target[key] = number
}
private func setOptionalBool(_ target: inout [String: Any], key: String, value: Bool) {
if value {
target[key] = true
private func setPatchBool(
_ target: inout [String: Any],
key: String,
value: Bool,
defaultValue: Bool)
{
if value == defaultValue {
target[key] = NSNull()
} else {
target.removeValue(forKey: key)
target[key] = value
}
}
private func setPatchList(_ target: inout [String: Any], key: String, values: [String]) {
if values.isEmpty {
target[key] = NSNull()
} else {
target[key] = values
}
}
@@ -548,7 +575,7 @@ extension ConnectionsStore {
defaultValue: Bool)
{
if value == defaultValue {
actions.removeValue(forKey: key)
actions[key] = NSNull()
} else {
actions[key] = value
}
@@ -180,6 +180,7 @@ struct ConfigSnapshot: Codable {
let path: String?
let exists: Bool?
let raw: String?
let hash: String?
let parsed: AnyCodable?
let valid: Bool?
let config: [String: AnyCodable]?
@@ -307,6 +308,7 @@ final class ConnectionsStore {
var pollTask: Task<Void, Never>?
var configRoot: [String: Any] = [:]
var configLoaded = false
var configHash: String?
init(isPreview: Bool = ProcessInfo.processInfo.isPreview) {
self.isPreview = isPreview
@@ -55,6 +55,7 @@ actor GatewayConnection {
case channelsStatus = "channels.status"
case configGet = "config.get"
case configSet = "config.set"
case configPatch = "config.patch"
case wizardStart = "wizard.start"
case wizardNext = "wizard.next"
case wizardCancel = "wizard.cancel"
@@ -845,42 +845,67 @@ public struct ConfigGetParams: Codable, Sendable {
public struct ConfigSetParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public init(
raw: String
raw: String,
basehash: String?
) {
self.raw = raw
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
}
}
public struct ConfigApplyParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public let sessionkey: String?
public let note: String?
public let restartdelayms: Int?
public init(
raw: String,
basehash: String?,
sessionkey: String?,
note: String?,
restartdelayms: Int?
) {
self.raw = raw
self.basehash = basehash
self.sessionkey = sessionkey
self.note = note
self.restartdelayms = restartdelayms
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
case sessionkey = "sessionKey"
case note
case restartdelayms = "restartDelayMs"
}
}
public struct ConfigPatchParams: Codable, Sendable {
public let raw: String
public let basehash: String?
public init(
raw: String,
basehash: String?
) {
self.raw = raw
self.basehash = basehash
}
private enum CodingKeys: String, CodingKey {
case raw
case basehash = "baseHash"
}
}
public struct ConfigSchemaParams: Codable, Sendable {
}
@@ -4,22 +4,22 @@ import Testing
@Suite(.serialized)
@MainActor
struct ConnectionsSettingsSmokeTests {
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channels: [
"whatsapp": AnyCodable([
"configured": true,
"linked": true,
struct ConnectionsSettingsSmokeTests {
@Test func connectionsSettingsBuildsBodyWithSnapshot() {
let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channels: [
"whatsapp": AnyCodable([
"configured": true,
"linked": true,
"authAgeMs": 86_400_000,
"self": ["e164": "+15551234567"],
"running": true,
@@ -70,13 +70,13 @@ import Testing
"lastError": "not configured",
"probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_050_000,
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
"imessage": "default",
])
@@ -93,23 +93,23 @@ import Testing
let view = ConnectionsSettings(store: store)
_ = view.body
}
}
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channels: [
"whatsapp": AnyCodable([
"configured": false,
"linked": false,
@Test func connectionsSettingsBuildsBodyWithoutSnapshot() {
let store = ConnectionsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"signal": "Signal",
"imessage": "iMessage",
],
channels: [
"whatsapp": AnyCodable([
"configured": false,
"linked": false,
"running": false,
"connected": false,
"reconnectAttempts": 0,
@@ -146,13 +146,13 @@ import Testing
"cliPath": "imsg",
"probe": ["ok": false, "error": "imsg not found (imsg)"],
"lastProbeAt": 1_700_000_200_000,
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
]),
],
channelAccounts: [:],
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"signal": "default",
"imessage": "default",
])
@@ -2,9 +2,9 @@ import Foundation
import Testing
@testable import Clawdbot
@Suite struct HealthDecodeTests {
private let sampleJSON: String = // minimal but complete payload
"""
@Suite struct HealthDecodeTests {
private let sampleJSON: String = // minimal but complete payload
"""
{"ts":1733622000,"durationMs":420,"channels":{"whatsapp":{"linked":true,"authAgeMs":120000},"telegram":{"configured":true,"probe":{"ok":true,"elapsedMs":800}}},"channelOrder":["whatsapp","telegram"],"heartbeatSeconds":60,"sessions":{"path":"/tmp/sessions.json","count":1,"recent":[{"key":"abc","updatedAt":1733621900,"age":120000}]}}
"""
+22
View File
@@ -0,0 +1,22 @@
# Clawdbot Chrome Extension (Browser Relay)
Purpose: attach Clawdbot to an existing Chrome tab so the Gateway can automate it (via the local CDP relay server).
## Dev / load unpacked
1. Build/run Clawdbot Gateway with browser control enabled.
2. Ensure the relay server is reachable at `http://127.0.0.1:18792/` (default).
3. Install the extension to a stable path:
```bash
clawdbot browser extension install
clawdbot browser extension path
```
4. Chrome → `chrome://extensions` → enable “Developer mode”.
5. “Load unpacked” → select the path printed above.
6. Pin the extension. Click the icon on a tab to attach/detach.
## Options
- `Relay port`: defaults to `18792`.
+438
View File
@@ -0,0 +1,438 @@
const DEFAULT_PORT = 18792
const BADGE = {
on: { text: 'ON', color: '#FF5A36' },
off: { text: '', color: '#000000' },
connecting: { text: '…', color: '#F59E0B' },
error: { text: '!', color: '#B91C1C' },
}
/** @type {WebSocket|null} */
let relayWs = null
/** @type {Promise<void>|null} */
let relayConnectPromise = null
let debuggerListenersInstalled = false
let nextSession = 1
/** @type {Map<number, {state:'connecting'|'connected', sessionId?:string, targetId?:string, attachOrder?:number}>} */
const tabs = new Map()
/** @type {Map<string, number>} */
const tabBySession = new Map()
/** @type {Map<string, number>} */
const childSessionToTab = new Map()
/** @type {Map<number, {resolve:(v:any)=>void, reject:(e:Error)=>void}>} */
const pending = new Map()
function nowStack() {
try {
return new Error().stack || ''
} catch {
return ''
}
}
async function getRelayPort() {
const stored = await chrome.storage.local.get(['relayPort'])
const raw = stored.relayPort
const n = Number.parseInt(String(raw || ''), 10)
if (!Number.isFinite(n) || n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function setBadge(tabId, kind) {
const cfg = BADGE[kind]
void chrome.action.setBadgeText({ tabId, text: cfg.text })
void chrome.action.setBadgeBackgroundColor({ tabId, color: cfg.color })
void chrome.action.setBadgeTextColor({ tabId, color: '#FFFFFF' }).catch(() => {})
}
async function ensureRelayConnection() {
if (relayWs && relayWs.readyState === WebSocket.OPEN) return
if (relayConnectPromise) return await relayConnectPromise
relayConnectPromise = (async () => {
const port = await getRelayPort()
const httpBase = `http://127.0.0.1:${port}`
const wsUrl = `ws://127.0.0.1:${port}/extension`
// Fast preflight: is the relay server up?
try {
await fetch(`${httpBase}/`, { method: 'HEAD', signal: AbortSignal.timeout(2000) })
} catch (err) {
throw new Error(`Relay server not reachable at ${httpBase} (${String(err)})`)
}
const ws = new WebSocket(wsUrl)
relayWs = ws
await new Promise((resolve, reject) => {
const t = setTimeout(() => reject(new Error('WebSocket connect timeout')), 5000)
ws.onopen = () => {
clearTimeout(t)
resolve()
}
ws.onerror = () => {
clearTimeout(t)
reject(new Error('WebSocket connect failed'))
}
ws.onclose = (ev) => {
clearTimeout(t)
reject(new Error(`WebSocket closed (${ev.code} ${ev.reason || 'no reason'})`))
}
})
ws.onmessage = (event) => void onRelayMessage(String(event.data || ''))
ws.onclose = () => onRelayClosed('closed')
ws.onerror = () => onRelayClosed('error')
if (!debuggerListenersInstalled) {
debuggerListenersInstalled = true
chrome.debugger.onEvent.addListener(onDebuggerEvent)
chrome.debugger.onDetach.addListener(onDebuggerDetach)
}
})()
try {
await relayConnectPromise
} finally {
relayConnectPromise = null
}
}
function onRelayClosed(reason) {
relayWs = null
for (const [id, p] of pending.entries()) {
pending.delete(id)
p.reject(new Error(`Relay disconnected (${reason})`))
}
for (const tabId of tabs.keys()) {
void chrome.debugger.detach({ tabId }).catch(() => {})
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: disconnected (click to re-attach)',
})
}
tabs.clear()
tabBySession.clear()
childSessionToTab.clear()
}
function sendToRelay(payload) {
const ws = relayWs
if (!ws || ws.readyState !== WebSocket.OPEN) {
throw new Error('Relay not connected')
}
ws.send(JSON.stringify(payload))
}
async function maybeOpenHelpOnce() {
try {
const stored = await chrome.storage.local.get(['helpOnErrorShown'])
if (stored.helpOnErrorShown === true) return
await chrome.storage.local.set({ helpOnErrorShown: true })
await chrome.runtime.openOptionsPage()
} catch {
// ignore
}
}
function requestFromRelay(command) {
const id = command.id
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject })
try {
sendToRelay(command)
} catch (err) {
pending.delete(id)
reject(err instanceof Error ? err : new Error(String(err)))
}
})
}
async function onRelayMessage(text) {
/** @type {any} */
let msg
try {
msg = JSON.parse(text)
} catch {
return
}
if (msg && msg.method === 'ping') {
try {
sendToRelay({ method: 'pong' })
} catch {
// ignore
}
return
}
if (msg && typeof msg.id === 'number' && (msg.result !== undefined || msg.error !== undefined)) {
const p = pending.get(msg.id)
if (!p) return
pending.delete(msg.id)
if (msg.error) p.reject(new Error(String(msg.error)))
else p.resolve(msg.result)
return
}
if (msg && typeof msg.id === 'number' && msg.method === 'forwardCDPCommand') {
try {
const result = await handleForwardCdpCommand(msg)
sendToRelay({ id: msg.id, result })
} catch (err) {
sendToRelay({ id: msg.id, error: err instanceof Error ? err.message : String(err) })
}
}
}
function getTabBySessionId(sessionId) {
const direct = tabBySession.get(sessionId)
if (direct) return { tabId: direct, kind: 'main' }
const child = childSessionToTab.get(sessionId)
if (child) return { tabId: child, kind: 'child' }
return null
}
function getTabByTargetId(targetId) {
for (const [tabId, tab] of tabs.entries()) {
if (tab.targetId === targetId) return tabId
}
return null
}
async function attachTab(tabId, opts = {}) {
const debuggee = { tabId }
await chrome.debugger.attach(debuggee, '1.3')
await chrome.debugger.sendCommand(debuggee, 'Page.enable').catch(() => {})
const info = /** @type {any} */ (await chrome.debugger.sendCommand(debuggee, 'Target.getTargetInfo'))
const targetInfo = info?.targetInfo
const targetId = String(targetInfo?.targetId || '').trim()
if (!targetId) {
throw new Error('Target.getTargetInfo returned no targetId')
}
const sessionId = `cb-tab-${nextSession++}`
const attachOrder = nextSession
tabs.set(tabId, { state: 'connected', sessionId, targetId, attachOrder })
tabBySession.set(sessionId, tabId)
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: attached (click to detach)',
})
if (!opts.skipAttachedEvent) {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.attachedToTarget',
params: {
sessionId,
targetInfo: { ...targetInfo, attached: true },
waitingForDebugger: false,
},
},
})
}
setBadge(tabId, 'on')
return { sessionId, targetId }
}
async function detachTab(tabId, reason) {
const tab = tabs.get(tabId)
if (tab?.sessionId && tab?.targetId) {
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
method: 'Target.detachedFromTarget',
params: { sessionId: tab.sessionId, targetId: tab.targetId, reason },
},
})
} catch {
// ignore
}
}
if (tab?.sessionId) tabBySession.delete(tab.sessionId)
tabs.delete(tabId)
for (const [childSessionId, parentTabId] of childSessionToTab.entries()) {
if (parentTabId === tabId) childSessionToTab.delete(childSessionId)
}
try {
await chrome.debugger.detach({ tabId })
} catch {
// ignore
}
setBadge(tabId, 'off')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay (click to attach/detach)',
})
}
async function connectOrToggleForActiveTab() {
const [active] = await chrome.tabs.query({ active: true, currentWindow: true })
const tabId = active?.id
if (!tabId) return
const existing = tabs.get(tabId)
if (existing?.state === 'connected') {
await detachTab(tabId, 'toggle')
return
}
tabs.set(tabId, { state: 'connecting' })
setBadge(tabId, 'connecting')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: connecting to local relay…',
})
try {
await ensureRelayConnection()
await attachTab(tabId)
} catch (err) {
tabs.delete(tabId)
setBadge(tabId, 'error')
void chrome.action.setTitle({
tabId,
title: 'Clawdbot Browser Relay: relay not running (open options for setup)',
})
void maybeOpenHelpOnce()
// Extra breadcrumbs in chrome://extensions service worker logs.
const message = err instanceof Error ? err.message : String(err)
console.warn('attach failed', message, nowStack())
}
}
async function handleForwardCdpCommand(msg) {
const method = String(msg?.params?.method || '').trim()
const params = msg?.params?.params || undefined
const sessionId = typeof msg?.params?.sessionId === 'string' ? msg.params.sessionId : undefined
// Map command to tab
const bySession = sessionId ? getTabBySessionId(sessionId) : null
const targetId = typeof params?.targetId === 'string' ? params.targetId : undefined
const tabId =
bySession?.tabId ||
(targetId ? getTabByTargetId(targetId) : null) ||
(() => {
// No sessionId: pick the first connected tab (stable-ish).
for (const [id, tab] of tabs.entries()) {
if (tab.state === 'connected') return id
}
return null
})()
if (!tabId) throw new Error(`No attached tab for method ${method}`)
/** @type {chrome.debugger.DebuggerSession} */
const debuggee = { tabId }
if (method === 'Runtime.enable') {
try {
await chrome.debugger.sendCommand(debuggee, 'Runtime.disable')
await new Promise((r) => setTimeout(r, 50))
} catch {
// ignore
}
return await chrome.debugger.sendCommand(debuggee, 'Runtime.enable', params)
}
if (method === 'Target.createTarget') {
const url = typeof params?.url === 'string' ? params.url : 'about:blank'
const tab = await chrome.tabs.create({ url, active: false })
if (!tab.id) throw new Error('Failed to create tab')
await new Promise((r) => setTimeout(r, 100))
const attached = await attachTab(tab.id)
return { targetId: attached.targetId }
}
if (method === 'Target.closeTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toClose = target ? getTabByTargetId(target) : tabId
if (!toClose) return { success: false }
try {
await chrome.tabs.remove(toClose)
} catch {
return { success: false }
}
return { success: true }
}
if (method === 'Target.activateTarget') {
const target = typeof params?.targetId === 'string' ? params.targetId : ''
const toActivate = target ? getTabByTargetId(target) : tabId
if (!toActivate) return {}
const tab = await chrome.tabs.get(toActivate).catch(() => null)
if (!tab) return {}
if (tab.windowId) {
await chrome.windows.update(tab.windowId, { focused: true }).catch(() => {})
}
await chrome.tabs.update(toActivate, { active: true }).catch(() => {})
return {}
}
const tabState = tabs.get(tabId)
const mainSessionId = tabState?.sessionId
const debuggerSession =
sessionId && mainSessionId && sessionId !== mainSessionId
? { ...debuggee, sessionId }
: debuggee
return await chrome.debugger.sendCommand(debuggerSession, method, params)
}
function onDebuggerEvent(source, method, params) {
const tabId = source.tabId
if (!tabId) return
const tab = tabs.get(tabId)
if (!tab?.sessionId) return
if (method === 'Target.attachedToTarget' && params?.sessionId) {
childSessionToTab.set(String(params.sessionId), tabId)
}
if (method === 'Target.detachedFromTarget' && params?.sessionId) {
childSessionToTab.delete(String(params.sessionId))
}
try {
sendToRelay({
method: 'forwardCDPEvent',
params: {
sessionId: source.sessionId || tab.sessionId,
method,
params,
},
})
} catch {
// ignore
}
}
function onDebuggerDetach(source, reason) {
const tabId = source.tabId
if (!tabId) return
if (!tabs.has(tabId)) return
void detachTab(tabId, reason)
}
chrome.action.onClicked.addListener(() => void connectOrToggleForActiveTab())
chrome.runtime.onInstalled.addListener(() => {
// Useful: first-time instructions.
void chrome.runtime.openOptionsPage()
})
Binary file not shown.

After

Width:  |  Height:  |  Size: 614 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

+25
View File
@@ -0,0 +1,25 @@
{
"manifest_version": 3,
"name": "Clawdbot Browser Relay",
"version": "0.1.0",
"description": "Attach Clawdbot to your existing Chrome tab via a local CDP relay server.",
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
},
"permissions": ["debugger", "tabs", "activeTab", "storage"],
"host_permissions": ["http://127.0.0.1/*", "http://localhost/*"],
"background": { "service_worker": "background.js", "type": "module" },
"action": {
"default_title": "Clawdbot Browser Relay (click to attach/detach)",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"options_ui": { "page": "options.html", "open_in_tab": true }
}
+197
View File
@@ -0,0 +1,197 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Clawdbot Browser Relay</title>
<style>
:root {
color-scheme: light dark;
--accent: #ff5a36;
--panel: color-mix(in oklab, canvas 92%, canvasText 8%);
--border: color-mix(in oklab, canvasText 18%, transparent);
--muted: color-mix(in oklab, canvasText 70%, transparent);
--shadow: 0 10px 30px color-mix(in oklab, canvasText 18%, transparent);
font-family: ui-rounded, system-ui, -apple-system, BlinkMacSystemFont, "SF Pro Rounded",
"SF Pro Display", "Segoe UI", sans-serif;
line-height: 1.4;
}
body {
margin: 0;
min-height: 100vh;
background:
radial-gradient(1000px 500px at 10% 0%, color-mix(in oklab, var(--accent) 30%, transparent), transparent 70%),
radial-gradient(900px 450px at 90% 0%, color-mix(in oklab, var(--accent) 18%, transparent), transparent 75%),
canvas;
color: canvasText;
}
.wrap {
max-width: 820px;
margin: 36px auto;
padding: 0 24px 48px 24px;
}
header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
}
.logo {
width: 44px;
height: 44px;
border-radius: 14px;
background: color-mix(in oklab, var(--accent) 18%, transparent);
border: 1px solid color-mix(in oklab, var(--accent) 35%, transparent);
box-shadow: var(--shadow);
display: grid;
place-items: center;
}
.logo img {
width: 28px;
height: 28px;
image-rendering: pixelated;
}
h1 {
font-size: 20px;
margin: 0;
letter-spacing: -0.01em;
}
.subtitle {
margin: 2px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.grid {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
}
.card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 16px;
padding: 16px;
box-shadow: var(--shadow);
}
.card h2 {
margin: 0 0 10px 0;
font-size: 14px;
letter-spacing: 0.01em;
}
.card p {
margin: 8px 0 0 0;
color: var(--muted);
font-size: 13px;
}
.row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
label {
display: block;
font-size: 12px;
color: var(--muted);
margin-bottom: 6px;
}
input {
width: 160px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(--border);
background: color-mix(in oklab, canvas 92%, canvasText 8%);
color: canvasText;
outline: none;
}
input:focus {
border-color: color-mix(in oklab, var(--accent) 70%, transparent);
box-shadow: 0 0 0 4px color-mix(in oklab, var(--accent) 20%, transparent);
}
button {
padding: 10px 14px;
border-radius: 12px;
border: 1px solid color-mix(in oklab, var(--accent) 55%, transparent);
background: linear-gradient(
180deg,
color-mix(in oklab, var(--accent) 80%, white 20%),
var(--accent)
);
color: white;
font-weight: 650;
letter-spacing: 0.01em;
cursor: pointer;
}
button:active {
transform: translateY(1px);
}
.hint {
margin-top: 10px;
font-size: 12px;
color: var(--muted);
}
code {
font-family: ui-monospace, Menlo, Monaco, Consolas, "SF Mono", monospace;
font-size: 12px;
}
a {
color: color-mix(in oklab, var(--accent) 85%, canvasText 15%);
}
.status {
margin-top: 10px;
font-size: 12px;
color: color-mix(in oklab, var(--accent) 70%, canvasText 30%);
min-height: 16px;
}
.status[data-kind='ok'] {
color: color-mix(in oklab, #16a34a 75%, canvasText 25%);
}
.status[data-kind='error'] {
color: color-mix(in oklab, #ef4444 75%, canvasText 25%);
}
</style>
</head>
<body>
<div class="wrap">
<header>
<div class="logo" aria-hidden="true">
<img src="icons/icon128.png" alt="" />
</div>
<div>
<h1>Clawdbot Browser Relay</h1>
<p class="subtitle">Click the toolbar button on a tab to attach / detach.</p>
</div>
</header>
<div class="grid">
<div class="card">
<h2>Getting started</h2>
<p>
If you see a red <code>!</code> badge on the extension icon, the relay server is not reachable.
Start Clawdbot’s browser relay on this machine (Gateway or <code>clawdbot browser serve</code>),
then click the toolbar button again.
</p>
<p>
Full guide (install, remote Gateway, security): <a href="https://docs.clawd.bot/tools/chrome-extension" target="_blank" rel="noreferrer">docs.clawd.bot/tools/chrome-extension</a>
</p>
</div>
<div class="card">
<h2>Relay port</h2>
<label for="port">Port</label>
<div class="row">
<input id="port" inputmode="numeric" pattern="[0-9]*" />
<button id="save" type="button">Save</button>
</div>
<div class="hint">
Default: <code>18792</code>. Extension connects to: <code id="relay-url">http://127.0.0.1:&lt;port&gt;/</code>.
Only change this if your Clawdbot profile uses a different <code>cdpUrl</code> port.
</div>
<div class="status" id="status"></div>
</div>
</div>
<script type="module" src="options.js"></script>
</div>
</body>
</html>
+59
View File
@@ -0,0 +1,59 @@
const DEFAULT_PORT = 18792
function clampPort(value) {
const n = Number.parseInt(String(value || ''), 10)
if (!Number.isFinite(n)) return DEFAULT_PORT
if (n <= 0 || n > 65535) return DEFAULT_PORT
return n
}
function updateRelayUrl(port) {
const el = document.getElementById('relay-url')
if (!el) return
el.textContent = `http://127.0.0.1:${port}/`
}
function setStatus(kind, message) {
const status = document.getElementById('status')
if (!status) return
status.dataset.kind = kind || ''
status.textContent = message || ''
}
async function checkRelayReachable(port) {
const url = `http://127.0.0.1:${port}/`
const ctrl = new AbortController()
const t = setTimeout(() => ctrl.abort(), 900)
try {
const res = await fetch(url, { method: 'HEAD', signal: ctrl.signal })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
setStatus('ok', `Relay reachable at ${url}`)
} catch {
setStatus(
'error',
`Relay not reachable at ${url}. Start Clawdbot’s browser relay on this machine, then click the toolbar button again.`,
)
} finally {
clearTimeout(t)
}
}
async function load() {
const stored = await chrome.storage.local.get(['relayPort'])
const port = clampPort(stored.relayPort)
document.getElementById('port').value = String(port)
updateRelayUrl(port)
await checkRelayReachable(port)
}
async function save() {
const input = document.getElementById('port')
const port = clampPort(input.value)
await chrome.storage.local.set({ relayPort: port })
input.value = String(port)
updateRelayUrl(port)
await checkRelayReachable(port)
}
document.getElementById('save').addEventListener('click', () => void save())
void load()
+1
View File
@@ -18,6 +18,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [iMessage](/channels/imessage) — macOS only; native integration.
- [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support.
- [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin).
- [WebChat](/web/webchat) — Gateway WebChat UI over WebSocket.
## Notes
+159
View File
@@ -0,0 +1,159 @@
---
summary: "Zalo bot support status, capabilities, and configuration"
read_when:
- Working on Zalo features or webhooks
---
# Zalo (Bot API)
Status: experimental. Direct messages only; groups coming soon per Zalo docs.
## Quick setup (beginner)
1) Install the Zalo plugin:
- From a source checkout: `clawdbot plugins install ./extensions/zalo`
- From npm (if published): `clawdbot plugins install @clawdbot/zalo`
- Or pick **Zalo** in onboarding and confirm the install prompt
2) Set the token:
- Env: `ZALO_BOT_TOKEN=...`
- Or config: `channels.zalo.botToken: "..."`.
3) Restart the gateway (or finish onboarding).
4) DM access is pairing by default; approve the pairing code on first contact.
Minimal config:
```json5
{
channels: {
zalo: {
enabled: true,
botToken: "12345689:abc-xyz",
dmPolicy: "pairing"
}
}
}
```
## What it is
- A Zalo Bot API channel owned by the Gateway.
- Deterministic routing: replies go back to Zalo; the model never chooses channels.
- DMs share the agent's main session.
- Groups are not yet supported (Zalo docs state "coming soon").
## Setup (fast path)
### 1) Create a bot token (Zalo Bot Platform)
1) Go to **https://bot.zaloplatforms.com** and sign in.
2) Create a new bot and configure its settings.
3) Copy the bot token (format: `12345689:abc-xyz`).
### 2) Configure the token (env or config)
Example:
```json5
{
channels: {
zalo: {
enabled: true,
botToken: "12345689:abc-xyz",
dmPolicy: "pairing"
}
}
}
```
Env option: `ZALO_BOT_TOKEN=...` (works for the default account only).
Multi-account support: use `channels.zalo.accounts` with per-account tokens and optional `name`.
3) Restart the gateway. Zalo starts when a token is resolved (env or config).
4) DM access defaults to pairing. Approve the code when the bot is first contacted.
## How it works (behavior)
- Inbound messages are normalized into the shared channel envelope with media placeholders.
- Replies always route back to the same Zalo chat.
- Long-polling by default; webhook mode available with `channels.zalo.webhookUrl`.
## Limits
- Outbound text is chunked to 2000 characters (Zalo API limit).
- Media downloads/uploads are capped by `channels.zalo.mediaMaxMb` (default 5).
- Streaming is blocked by default due to the 2000 char limit making streaming less useful.
## Access control (DMs)
### DM access
- Default: `channels.zalo.dmPolicy = "pairing"`. Unknown senders receive a pairing code; messages are ignored until approved (codes expire after 1 hour).
- Approve via:
- `clawdbot pairing list zalo`
- `clawdbot pairing approve zalo <CODE>`
- Pairing is the default token exchange. Details: [Pairing](/start/pairing)
- `channels.zalo.allowFrom` accepts numeric user IDs.
## Long-polling vs webhook
- Default: long-polling (no public URL required).
- Webhook mode: set `channels.zalo.webhookUrl` and `channels.zalo.webhookSecret`.
- The webhook secret must be 8-256 characters.
- Webhook URL must use HTTPS.
- Zalo sends events with `X-Bot-Api-Secret-Token` header for verification.
- Gateway HTTP handles webhook requests at `channels.zalo.webhookPath` (defaults to the webhook URL path).
**Note:** getUpdates (polling) and webhook are mutually exclusive per Zalo API docs.
## Supported message types
- **Text messages**: Full support with 2000 character chunking.
- **Image messages**: Download and process inbound images; send images via `sendPhoto`.
- **Stickers**: Logged but not fully processed (no agent response).
- **Unsupported types**: Logged (e.g., messages from protected users).
## Capabilities
| Feature | Status |
|---------|--------|
| Direct messages | ✅ Supported |
| Groups | ❌ Coming soon (per Zalo docs) |
| Media (images) | ✅ Supported |
| Reactions | ❌ Not supported |
| Threads | ❌ Not supported |
| Polls | ❌ Not supported |
| Native commands | ❌ Not supported |
| Streaming | ⚠️ Blocked (2000 char limit) |
## Delivery targets (CLI/cron)
- Use a chat id as the target.
- Example: `clawdbot message send --channel zalo --to 123456789 --message "hi"`.
## Troubleshooting
**Bot doesn't respond:**
- Check that the token is valid: `clawdbot channels status --probe`
- Verify the sender is approved (pairing or allowFrom)
- Check gateway logs: `clawdbot logs --follow`
**Webhook not receiving events:**
- Ensure webhook URL uses HTTPS
- Verify secret token is 8-256 characters
- Confirm the gateway HTTP endpoint is reachable on the configured path
- Check that getUpdates polling is not running (they're mutually exclusive)
## Configuration reference (Zalo)
Full configuration: [Configuration](/gateway/configuration)
Provider options:
- `channels.zalo.enabled`: enable/disable channel startup.
- `channels.zalo.botToken`: bot token from Zalo Bot Platform.
- `channels.zalo.tokenFile`: read token from file path.
- `channels.zalo.dmPolicy`: `pairing | allowlist | open | disabled` (default: pairing).
- `channels.zalo.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`.
- `channels.zalo.mediaMaxMb`: inbound/outbound media cap (MB, default 5).
- `channels.zalo.webhookUrl`: enable webhook mode (HTTPS required).
- `channels.zalo.webhookSecret`: webhook secret (8-256 chars).
- `channels.zalo.webhookPath`: webhook path on the gateway HTTP server.
- `channels.zalo.proxy`: proxy URL for API requests.
Multi-account options:
- `channels.zalo.accounts.<id>.botToken`: per-account token.
- `channels.zalo.accounts.<id>.tokenFile`: per-account token file.
- `channels.zalo.accounts.<id>.name`: display name.
- `channels.zalo.accounts.<id>.enabled`: enable/disable account.
- `channels.zalo.accounts.<id>.dmPolicy`: per-account DM policy.
- `channels.zalo.accounts.<id>.allowFrom`: per-account allowlist.
- `channels.zalo.accounts.<id>.webhookUrl`: per-account webhook URL.
- `channels.zalo.accounts.<id>.webhookSecret`: per-account webhook secret.
- `channels.zalo.accounts.<id>.webhookPath`: per-account webhook path.
- `channels.zalo.accounts.<id>.proxy`: per-account proxy URL.
+12
View File
@@ -48,6 +48,8 @@ clawdbot [--dev] [--profile <name>] <command>
onboard
configure (alias: config)
doctor
security
audit
reset
uninstall
update
@@ -180,6 +182,12 @@ clawdbot [--dev] [--profile <name>] <command>
Note: plugins can add additional top-level commands (for example `clawdbot voicecall`).
## Security
- `clawdbot security audit` — audit config + local state for common security foot-guns.
- `clawdbot security audit --deep` — best-effort live Gateway probe.
- `clawdbot security audit --fix` — tighten safe defaults and chmod state/config.
## Plugins
Manage extensions and their config:
@@ -579,8 +587,12 @@ Subcommands:
Common RPCs:
- `config.apply` (validate + write config + restart + wake)
- `config.patch` (merge a partial update without clobbering unrelated keys)
- `update.run` (run update + restart + wake)
Tip: when calling `config.set`/`config.apply`/`config.patch` directly, pass `baseHash` from
`config.get` if a config already exists.
## Models
See [/concepts/models](/concepts/models) for fallback behavior and scanning strategy.
+64
View File
@@ -46,6 +46,70 @@ If you want...
- Direct chats use the main session (or per-sender if configured).
- Heartbeats are skipped for group sessions.
## Pattern: personal DMs + public groups (single agent)
Yes — this works well if your “personal” traffic is **DMs** and your “public” traffic is **groups**.
Why: in single-agent mode, DMs typically land in the **main** session key (`agent:main:main`), while groups always use **non-main** session keys (`agent:main:<channel>:group:<id>`). If you enable sandboxing with `mode: "non-main"`, those group sessions run in Docker while your main DM session stays on-host.
This gives you one agent “brain” (shared workspace + memory), but two execution postures:
- **DMs**: full tools (host)
- **Groups**: sandbox + restricted tools (Docker)
> If you need truly separate workspaces/personas (“personal” and “public” must never mix), use a second agent + bindings. See [Multi-Agent Routing](/concepts/multi-agent).
Example (DMs on host, groups sandboxed + messaging-only tools):
```json5
{
agents: {
defaults: {
sandbox: {
mode: "non-main", // groups/channels are non-main -> sandboxed
scope: "session", // strongest isolation (one container per group/channel)
workspaceAccess: "none"
}
}
},
tools: {
sandbox: {
tools: {
// If allow is non-empty, everything else is blocked (deny still wins).
allow: ["group:messaging", "group:sessions"],
deny: ["group:runtime", "group:fs", "group:ui", "nodes", "cron", "gateway"]
}
}
}
}
```
Want “groups can only see folder X” instead of “no host access”? Keep `workspaceAccess: "none"` and mount only allowlisted paths into the sandbox:
```json5
{
agents: {
defaults: {
sandbox: {
mode: "non-main",
scope: "session",
workspaceAccess: "none",
docker: {
binds: [
// hostPath:containerPath:mode
"~/FriendsShared:/data:ro"
]
}
}
}
}
}
```
Related:
- Configuration keys and defaults: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox)
- Debugging why a tool is blocked: [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)
- Bind mounts details: [Sandboxing](/gateway/sandboxing#custom-bind-mounts)
## Display labels
- UI labels use `displayName` when available, formatted as `<channel>:<token>`.
- `#room` is reserved for rooms/channels; group chats use `g-<slug>` (lowercase, spaces -> `-`, keep `#@+._-`).
+10
View File
@@ -17,6 +17,16 @@ An **agent** is a fully scoped brain with its own:
- **State directory** (`agentDir`) for auth profiles, model registry, and per-agent config.
- **Session store** (chat history + routing state) under `~/.clawdbot/agents/<agentId>/sessions`.
Auth profiles are **per-agent**. Each agent reads from its own:
```
~/.clawdbot/agents/<agentId>/agent/auth-profiles.json
```
Main agent credentials are **not** shared automatically. Never reuse `agentDir`
across agents (it causes auth/session collisions). If you want to share creds,
copy `auth-profiles.json` into the other agent's `agentDir`.
Skills are per-agent via each workspace’s `skills/` folder, with shared skills
available from `~/.clawdbot/skills`. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills).
+1
View File
@@ -145,6 +145,7 @@ Behavior:
- Always non-blocking: returns `{ status: "accepted", runId, childSessionKey }` immediately.
- After completion, Clawdbot runs a sub-agent **announce step** and posts the result to the requester chat channel.
- Reply exactly `ANNOUNCE_SKIP` during the announce step to stay silent.
- Announce replies are normalized to `Status`/`Result`/`Notes`; `Status` comes from runtime outcome (not model text).
- Sub-agent sessions are auto-archived after `agents.defaults.subagents.archiveAfterMinutes` (default: 60).
- Announce replies include a stats line (runtime, tokens, sessionKey/sessionId, transcript path, and optional cost).
+2
View File
@@ -889,9 +889,11 @@
"tools",
"plugin",
"tools/exec",
"tools/web",
"tools/apply-patch",
"tools/elevated",
"tools/browser",
"tools/chrome-extension",
"tools/browser-linux-troubleshooting",
"tools/slash-commands",
"tools/thinking",
+1 -1
View File
@@ -384,7 +384,7 @@ Save to `~/.clawdbot/clawdbot.json` and you can DM the bot from that number.
},
skills: {
allowBundled: ["brave-search", "gemini"],
allowBundled: ["gemini", "peekaboo"],
load: {
extraDirs: ["~/Projects/agent-scripts/skills"]
},
+70 -1
View File
@@ -32,19 +32,44 @@ It writes a restart sentinel and pings the last active session after the Gateway
Params:
- `raw` (string) — JSON5 payload for the entire config
- `baseHash` (optional) — config hash from `config.get` (required when a config already exists)
- `sessionKey` (optional) — last active session key for the wake-up ping
- `restartDelayMs` (optional) — delay before restart (default 2000)
Example (via `gateway call`):
```bash
clawdbot gateway call config.get --params '{}' # capture payload.hash
clawdbot gateway call config.apply --params '{
"raw": "{\\n agents: { defaults: { workspace: \\"~/clawd\\" } }\\n}\\n",
"baseHash": "<hash-from-config.get>",
"sessionKey": "agent:main:whatsapp:dm:+15555550123",
"restartDelayMs": 1000
}'
```
## Partial updates (RPC)
Use `config.patch` to merge a partial update into the existing config without clobbering
unrelated keys. It applies JSON merge patch semantics:
- objects merge recursively
- `null` deletes a key
- arrays replace
Params:
- `raw` (string) — JSON5 payload containing just the keys to change
- `baseHash` (required) — config hash from `config.get`
Example:
```bash
clawdbot gateway call config.get --params '{}' # capture payload.hash
clawdbot gateway call config.patch --params '{
"raw": "{\\n channels: { telegram: { groups: { \\"*\\": { requireMention: false } } } }\\n}\\n",
"baseHash": "<hash-from-config.get>"
}'
```
## Minimal config (recommended starting point)
```json5
@@ -1580,6 +1605,18 @@ of `every`, keep `HEARTBEAT.md` tiny, and/or choose a cheaper `model`.
Note: `applyPatch` is only under `tools.exec` (no `tools.bash` alias).
Legacy: `tools.bash` is still accepted as an alias.
`tools.web` configures web search + fetch tools:
- `tools.web.search.enabled` (default: true when key is present)
- `tools.web.search.apiKey` (recommended: set via `clawdbot configure --section web`, or use `BRAVE_API_KEY` env var)
- `tools.web.search.maxResults` (1–10, default 5)
- `tools.web.search.timeoutSeconds` (default 30)
- `tools.web.search.cacheTtlMinutes` (default 15)
- `tools.web.fetch.enabled` (default false; sandboxed sessions auto-enable unless set to false)
- `tools.web.fetch.maxChars` (default 50000)
- `tools.web.fetch.timeoutSeconds` (default 30)
- `tools.web.fetch.cacheTtlMinutes` (default 15)
- `tools.web.fetch.userAgent` (optional override)
`agents.defaults.subagents` configures sub-agent defaults:
- `model`: default model for spawned sub-agents (string or `{ primary, fallbacks }`). If omitted, sub-agents inherit the caller’s model unless overridden per agent or per call.
- `maxConcurrent`: max concurrent sub-agent runs (default 1)
@@ -1614,6 +1651,37 @@ Example (coding profile, but deny exec/process everywhere):
}
```
`tools.byProvider` lets you **further restrict** tools for specific providers (or a single `provider/model`).
Per-agent override: `agents.list[].tools.byProvider`.
Order: base profile → provider profile → allow/deny policies.
Provider keys accept either `provider` (e.g. `google-antigravity`) or `provider/model`
(e.g. `openai/gpt-5.2`).
Example (keep global coding profile, but minimal tools for Google Antigravity):
```json5
{
tools: {
profile: "coding",
byProvider: {
"google-antigravity": { profile: "minimal" }
}
}
}
```
Example (provider/model-specific allowlist):
```json5
{
tools: {
allow: ["group:fs", "group:runtime", "sessions_list"],
byProvider: {
"openai/gpt-5.2": { allow: ["group:fs", "sessions_list"] }
}
}
}
```
`tools.allow` / `tools.deny` configure a global tool allow/deny policy (deny wins).
This is applied even when the Docker sandbox is **off**.
@@ -1629,6 +1697,7 @@ Tool groups (shorthands) work in **global** and **per-agent** tool policies:
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:web`: `web_search`, `web_fetch`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
@@ -2154,7 +2223,7 @@ Example:
```json5
{
skills: {
allowBundled: ["brave-search", "gemini"],
allowBundled: ["gemini", "peekaboo"],
load: {
extraDirs: [
"~/Projects/agent-scripts/skills",
@@ -51,12 +51,15 @@ See [Sandboxing](/gateway/sandboxing) for the full matrix (scope, workspace moun
Two layers matter:
- **Tool profile**: `tools.profile` and `agents.list[].tools.profile` (base allowlist)
- **Provider tool profile**: `tools.byProvider[provider].profile` and `agents.list[].tools.byProvider[provider].profile`
- **Global/per-agent tool policy**: `tools.allow`/`tools.deny` and `agents.list[].tools.allow`/`agents.list[].tools.deny`
- **Provider tool policy**: `tools.byProvider[provider].allow/deny` and `agents.list[].tools.byProvider[provider].allow/deny`
- **Sandbox tool policy** (only applies when sandboxed): `tools.sandbox.tools.allow`/`tools.sandbox.tools.deny` and `agents.list[].tools.sandbox.tools.*`
Rules of thumb:
- `deny` always wins.
- If `allow` is non-empty, everything else is treated as blocked.
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`).
### Tool groups (shorthands)
@@ -90,6 +93,7 @@ Available groups:
Elevated does **not** grant extra tools; it only affects `exec`.
- If you’re sandboxed, `/elevated on` (or `exec` with `elevated: true`) runs on the host.
- If you’re already running direct, elevated is effectively a no-op (still gated).
- Elevated is **not** skill-scoped and does **not** override tool allow/deny.
Gates:
- Enablement: `tools.elevated.enabled` (and optionally `agents.list[].tools.elevated.enabled`)
+41
View File
@@ -12,6 +12,23 @@ Clawdbot is both a product and an experiment: you’re wiring frontier-model beh
- where the bot is allowed to act
- what the bot can touch
## Quick check: `clawdbot security audit`
Run this regularly (especially after changing config or exposing network surfaces):
```bash
clawdbot security audit
clawdbot security audit --deep
clawdbot security audit --fix
```
It flags common footguns (Gateway auth exposure, browser control exposure, elevated allowlists, filesystem permissions).
`--fix` applies safe guardrails:
- Tighten `groupPolicy="open"` to `groupPolicy="allowlist"` (and per-account variants) for common channels.
- Turn `logging.redactSensitive="off"` back to `"tools"`.
- Tighten local perms (`~/.clawdbot` → `700`, config file → `600`).
## The Threat Model
Your AI assistant can:
@@ -190,6 +207,27 @@ you terminate TLS or proxy in front of the gateway, disable
See [Tailscale](/gateway/tailscale) and [Web overview](/web).
### 0.6.1) Browser control server over Tailscale (recommended)
If your Gateway is remote but the browser runs on another machine, you’ll often run a **separate browser control server**
on the browser machine (see [Browser tool](/tools/browser)). Treat this like an admin API.
Recommended pattern:
```bash
# on the machine that runs Chrome
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
tailscale serve https / http://127.0.0.1:18791
```
Then on the Gateway, set:
- `browser.controlUrl` to the `https://…` Serve URL (MagicDNS/ts.net)
- and authenticate with the same token (`CLAWDBOT_BROWSER_CONTROL_TOKEN` env preferred)
Avoid:
- `--bind 0.0.0.0` (LAN-visible surface)
- Tailscale Funnel for browser control endpoints (public exposure)
### 0.7) Secrets on disk (what’s sensitive)
Assume anything under `~/.clawdbot/` (or `$CLAWDBOT_STATE_DIR/`) may contain secrets or private data:
@@ -320,6 +358,9 @@ access those accounts and data. Treat browser profiles as **sensitive state**:
- Treat browser downloads as untrusted input; prefer an isolated downloads directory.
- Disable browser sync/password managers in the agent profile if possible (reduces blast radius).
- For remote gateways, assume “browser control” is equivalent to “operator access” to whatever that profile can reach.
- Treat `browser.controlUrl` endpoints as an admin API: tailnet-only + token auth. Prefer Tailscale Serve over LAN binds.
- Keep `browser.controlToken` separate from `gateway.auth.token` (you can reuse it, but that increases blast radius).
- Chrome extension relay mode is **not** “safer”; it can take over your existing Chrome tabs. Assume it can act as you in whatever that tab/profile can reach.
## Per-agent access profiles (multi-agent)
+30
View File
@@ -76,6 +76,36 @@ clawdbot gateway --tailscale funnel --auth password
- Serve/Funnel only expose the **Gateway control UI + WS**. Node **bridge** traffic
uses the separate bridge port (default `18790`) and is **not** proxied by Serve.
## Browser control server (remote Gateway + local browser)
If you run the Gateway on one machine but want to drive a browser on another machine, use a **separate browser control server**
and publish it through Tailscale **Serve** (tailnet-only):
```bash
# on the machine that runs Chrome
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
tailscale serve https / http://127.0.0.1:18791
```
Then point the Gateway config at the HTTPS URL:
```json5
{
browser: {
enabled: true,
controlUrl: "https://<magicdns>/"
}
}
```
And authenticate from the Gateway with the same token (prefer env):
```bash
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
```
Avoid Funnel for browser control endpoints unless you explicitly want public exposure.
## Tailscale prerequisites + limits
- Serve requires HTTPS enabled for your tailnet; the CLI prompts if it is missing.
+17 -4
View File
@@ -18,6 +18,15 @@ This allows you to run multiple agents with different security profiles:
- Family/work agents with restricted tools
- Public-facing agents in sandboxes
Auth is per-agent: each agent reads from its own `agentDir` auth store at:
```
~/.clawdbot/agents/<agentId>/agent/auth-profiles.json
```
Credentials are **not** shared between agents. Never reuse `agentDir` across agents.
If you want to share creds, copy `auth-profiles.json` into the other agent's `agentDir`.
For how sandboxing behaves at runtime, see [Sandboxing](/gateway/sandboxing).
For debugging “why is this blocked?”, see [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated) and `clawdbot sandbox explain`.
@@ -188,14 +197,18 @@ agents.list[].sandbox.prune.* > agents.defaults.sandbox.prune.*
### Tool Restrictions
The filtering order is:
1. **Tool profile** (`tools.profile` or `agents.list[].tools.profile`)
2. **Global tool policy** (`tools.allow` / `tools.deny`)
3. **Agent-specific tool policy** (`agents.list[].tools`)
4. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
5. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
2. **Provider tool profile** (`tools.byProvider[provider].profile` or `agents.list[].tools.byProvider[provider].profile`)
3. **Global tool policy** (`tools.allow` / `tools.deny`)
4. **Provider tool policy** (`tools.byProvider[provider].allow/deny`)
5. **Agent-specific tool policy** (`agents.list[].tools.allow/deny`)
6. **Agent provider policy** (`agents.list[].tools.byProvider[provider].allow/deny`)
7. **Sandbox tool policy** (`tools.sandbox.tools` or `agents.list[].tools.sandbox.tools`)
8. **Subagent tool policy** (`tools.subagents.tools`, if applicable)
Each level can further restrict tools, but cannot grant back denied tools from earlier levels.
If `agents.list[].tools.sandbox.tools` is set, it replaces `tools.sandbox.tools` for that agent.
If `agents.list[].tools.profile` is set, it overrides `tools.profile` for that agent.
Provider tool keys accept either `provider` (e.g. `google-antigravity`) or `provider/model` (e.g. `openai/gpt-5.2`).
### Tool groups (shorthands)
+1
View File
@@ -29,6 +29,7 @@ Label:
Plist location (per‑user):
- `~/Library/LaunchAgents/com.clawdbot.gateway.plist`
(or `~/Library/LaunchAgents/com.clawdbot.<profile>.plist`)
Manager:
- The macOS app owns LaunchAgent install/update in Local mode.
+1
View File
@@ -37,6 +37,7 @@ Clawdbot plugins are **TypeScript modules** loaded at runtime via jiti. They can
register:
- Gateway RPC methods
- Gateway HTTP handlers
- Agent tools
- CLI commands
- Background services
+81
View File
@@ -34,6 +34,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [How can I use different models for different tasks?](#how-can-i-use-different-models-for-different-tasks)
- [How do I install skills on Linux?](#how-do-i-install-skills-on-linux)
- [Do you have a Notion or HeyGen integration?](#do-you-have-a-notion-or-heygen-integration)
- [How do I install the Chrome extension for browser takeover?](#how-do-i-install-the-chrome-extension-for-browser-takeover)
- [Sandboxing and memory](#sandboxing-and-memory)
- [Is there a dedicated sandboxing doc?](#is-there-a-dedicated-sandboxing-doc)
- [How do I bind a host folder into the sandbox?](#how-do-i-bind-a-host-folder-into-the-sandbox)
@@ -50,6 +51,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
- [I set `gateway.bind: "lan"` (or `"tailnet"`) and now nothing listens / the UI says unauthorized](#i-set-gatewaybind-lan-or-tailnet-and-now-nothing-listens-the-ui-says-unauthorized)
- [Why do I need a token on localhost now?](#why-do-i-need-a-token-on-localhost-now)
- [Do I have to restart after changing config?](#do-i-have-to-restart-after-changing-config)
- [How do I enable web search (and web fetch)?](#how-do-i-enable-web-search-and-web-fetch)
- [How do I run a central Gateway with specialized workers across devices?](#how-do-i-run-a-central-gateway-with-specialized-workers-across-devices)
- [Can the Clawdbot browser run headless?](#can-the-clawdbot-browser-run-headless)
- [Remote gateways + nodes](#remote-gateways-nodes)
@@ -398,12 +400,38 @@ clawdhub update --all
ClawdHub installs into `./skills` under your current directory (or falls back to your configured Clawdbot workspace); Clawdbot treats that as `<workspace>/skills` on the next session. For shared skills across agents, place them in `~/.clawdbot/skills/<name>/SKILL.md`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills) and [ClawdHub](/tools/clawdhub).
### How do I install the Chrome extension for browser takeover?
Use the built-in installer, then load the unpacked extension in Chrome:
```bash
clawdbot browser extension install
clawdbot browser extension path
```
Then Chrome → `chrome://extensions` → enable “Developer mode” → “Load unpacked” → pick that folder.
Full guide (including remote Gateway via Tailscale + security notes): [Chrome extension](/tools/chrome-extension)
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
You still need to click the extension button on the tab you want to control (it doesn’t auto-attach).
## Sandboxing and memory
### Is there a dedicated sandboxing doc?
Yes. See [Sandboxing](/gateway/sandboxing). For Docker-specific setup (full gateway in Docker or sandbox images), see [Docker](/install/docker).
### Can I keep DMs “personal” but make groups “public/sandboxed” with one agent?
Yes — if your private traffic is **DMs** and your public traffic is **groups**.
Use `agents.defaults.sandbox.mode: "non-main"` so group/channel sessions (non-main keys) run in Docker, while the main DM session stays on-host. Then restrict what tools are available in sandboxed sessions via `tools.sandbox.tools`.
Setup walkthrough + example config: [Groups: personal DMs + public groups](/concepts/groups#pattern-personal-dms-public-groups-single-agent)
Key config reference: [Gateway configuration](/gateway/configuration#agentsdefaultssandbox)
### How do I bind a host folder into the sandbox?
Set `agents.defaults.sandbox.docker.binds` to `["host:path:mode"]` (e.g., `"/home/user/src:/src:ro"`). Global + per-agent binds merge; per-agent binds are ignored when `scope: "shared"`. Use `:ro` for anything sensitive and remember binds bypass the sandbox filesystem walls. See [Sandboxing](/gateway/sandboxing#custom-bind-mounts) and [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated#bind-mounts-security-quick-check) for examples and safety notes.
@@ -548,6 +576,37 @@ The Gateway watches the config and supports hot‑reload:
- `gateway.reload.mode: "hybrid"` (default): hot‑apply safe changes, restart for critical ones
- `hot`, `restart`, `off` are also supported
### How do I enable web search (and web fetch)?
`web_fetch` works without an API key. `web_search` requires a Brave Search API
key. **Recommended:** run `clawdbot configure --section web` to store it in
`tools.web.search.apiKey`. Environment alternative: set `BRAVE_API_KEY` for the
Gateway process.
```json5
{
tools: {
web: {
search: {
enabled: true,
apiKey: "BRAVE_API_KEY_HERE",
maxResults: 5
},
fetch: {
enabled: true
}
}
}
}
```
Notes:
- If you use allowlists, add `web_search`/`web_fetch` or `group:web`.
- In sandboxed sessions, `web_fetch` auto-enables unless explicitly disabled.
- Daemons read env vars from `~/.clawdbot/.env` (or the service environment).
Docs: [Web tools](/tools/web).
### How do I run a central Gateway with specialized workers across devices?
The common pattern is **one Gateway** (e.g. Raspberry Pi) plus **nodes** and **agents**:
@@ -988,6 +1047,21 @@ Z.AI (GLM models):
If you reference a provider/model but the required provider key is missing, you’ll get a runtime auth error (e.g. `No API key found for provider "zai"`).
### “No API key found for provider …” after adding a new agent
This usually means the **new agent** has an empty auth store. Auth is per-agent and
stored in:
```
~/.clawdbot/agents/<agentId>/agent/auth-profiles.json
```
Fix options:
- Run `clawdbot agents add <id>` and configure auth during the wizard.
- Or copy `auth-profiles.json` from the main agent’s `agentDir` into the new agent’s `agentDir`.
Do **not** reuse `agentDir` across agents; it causes auth/session collisions.
## Model failover and “All models failed”
### How does failover work?
@@ -1042,6 +1116,13 @@ If your model config includes Google Gemini as a fallback (or you switched to a
Fix: either provide Google auth, or remove/avoid Google models in `agents.defaults.model.fallbacks` / aliases so fallback doesn’t route there.
### “LLM request rejected: messages.*.thinking.signature required (google‑antigravity)”
Cause: the session history contains **thinking blocks without signatures** (often from
an aborted/partial stream). Google Antigravity requires signatures for thinking blocks.
Fix: start a **new session** or set `/thinking off` for that agent.
## Auth profiles: what they are and how to manage them
Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-account patterns, CLI sync)
+3
View File
@@ -40,6 +40,9 @@ run on host, set an explicit per-agent override:
- Node `>=22`
- `pnpm` (optional; recommended if you build from source)
- **Recommended:** Brave Search API key for web search. Easiest path:
`clawdbot configure --section web` (stores `tools.web.search.apiKey`).
See [Web tools](/tools/web).
macOS: if you plan to build the apps, install Xcode / CLT. For the CLI + gateway only, Node is enough.
Windows: use **WSL2** (Ubuntu recommended). WSL2 is strongly recommended; native Windows is untested and more problematic. Install WSL2 first, then run the Linux steps inside WSL. See [Windows (WSL2)](/platforms/windows).
+7
View File
@@ -24,6 +24,10 @@ Follow‑up reconfiguration:
clawdbot configure
```
Recommended: set up a Brave Search API key so the agent can use `web_search`
(`web_fetch` works without a key). Easiest path: `clawdbot configure --section web`
which stores `tools.web.search.apiKey`. Docs: [Web tools](/tools/web).
## QuickStart vs Advanced
The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control).
@@ -285,6 +289,9 @@ Typical fields in `~/.clawdbot/clawdbot.json`:
WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp/<accountId>/`.
Sessions are stored under `~/.clawdbot/agents/<agentId>/sessions/`.
Some channels are delivered as plugins. When you pick one during onboarding, the wizard
will prompt to install it (npm or a local path) before it can be configured.
## Related docs
- macOS app onboarding: [Onboarding](/start/onboarding)
+144 -1
View File
@@ -106,6 +106,95 @@ Example:
Use `profiles.<name>.cdpUrl` for **remote CDP** if you want the Gateway to talk
directly to a Chrome instance without a remote control server.
### Running the control server on the browser machine
Run a standalone browser control server (recommended when your Gateway is remote):
```bash
# on the machine that runs Chrome
clawdbot browser serve --bind <browser-host> --port 18791 --token <token>
```
Then point your Gateway at it:
```json5
{
browser: {
enabled: true,
controlUrl: "http://<browser-host>:18791",
// Option A (recommended): keep token in env on the Gateway
// (avoid writing secrets into config files)
// controlToken: "<token>"
}
}
```
And set the auth token in the Gateway environment:
```bash
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
```
Option B: store the token in the Gateway config instead (same shared token):
```json5
{
browser: {
enabled: true,
controlUrl: "http://<browser-host>:18791",
controlToken: "<token>"
}
}
```
## Security
This section covers the **browser control server** (`browser.controlUrl`) used for agent browser automation.
Key ideas:
- Treat the browser control server like an admin API: **private network only**.
- Use **token auth** always when the server is reachable off-machine.
- Prefer **Tailnet-only** connectivity over LAN exposure.
### Tokens (what is shared with what?)
- `browser.controlToken` / `CLAWDBOT_BROWSER_CONTROL_TOKEN` is **only** for authenticating browser control HTTP requests to `browser.controlUrl`.
- It is **not** the Gateway token (`gateway.auth.token`) and **not** a node pairing token.
- You *can* reuse the same string value, but it’s better to keep them separate to reduce blast radius.
### Binding (don’t expose to your LAN by accident)
Recommended:
- Keep `clawdbot browser serve` bound to loopback (`127.0.0.1`) and publish it via Tailscale.
- Or bind to a Tailnet IP only (never `0.0.0.0`) and require a token.
Avoid:
- `--bind 0.0.0.0` (LAN-visible). Even with token auth, traffic is plain HTTP unless you also add TLS.
### TLS / HTTPS (recommended approach: terminate in front)
Best practice here: keep `clawdbot browser serve` on HTTP and terminate TLS in front.
If you’re already using Tailscale, you have two good options:
1) **Tailnet-only, still HTTP** (transport is encrypted by Tailscale):
- Keep `controlUrl` as `http://…` but ensure it’s only reachable over your tailnet.
2) **Serve HTTPS via Tailscale** (nice UX: `https://…` URL):
```bash
# on the browser machine
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
tailscale serve https / http://127.0.0.1:18791
```
Then set your Gateway config `browser.controlUrl` to the HTTPS URL (MagicDNS/ts.net) and keep using the same token.
Notes:
- Do **not** use Tailscale Funnel for this unless you explicitly want to make the endpoint public.
- For Tailnet setup/background, see [Gateway web surfaces](/web/index) and the [Gateway CLI](/cli/gateway).
## Profiles (multi-browser)
Clawdbot supports multiple named profiles. Each profile has its own:
@@ -120,6 +209,54 @@ Defaults:
All control endpoints accept `?profile=<name>`; the CLI uses `--browser-profile`.
## Chrome extension relay (use your existing Chrome)
Clawdbot can also drive **your existing Chrome tabs** (no separate “clawd” Chrome instance) via a local CDP relay + a Chrome extension.
Full guide: [Chrome extension](/tools/chrome-extension)
Flow:
- You run a **browser control server** (Gateway on the same machine, or `clawdbot browser serve`).
- A local **relay server** listens at a loopback `cdpUrl` (default: `http://127.0.0.1:18792`).
- You click the **Clawdbot Browser Relay** extension icon on a tab to attach (it does not auto-attach).
- The agent controls that tab via the normal `browser` tool, by selecting the right profile.
If the Gateway runs on the same machine as Chrome (default setup), you usually **do not** need `clawdbot browser serve`.
Use `browser serve` only when the Gateway runs elsewhere (remote mode).
### Sandboxed sessions
If the agent session is sandboxed, the `browser` tool may default to `target="sandbox"` (sandbox browser).
Chrome extension relay takeover requires host browser control, so either:
- run the session unsandboxed, or
- set `agents.defaults.sandbox.browser.allowHostControl: true` and use `target="host"` when calling the tool.
### Setup
1) Create a profile that uses the extension driver:
```bash
clawdbot browser create-profile \
--name chrome \
--driver extension \
--cdp-url http://127.0.0.1:18792 \
--color "#00AA00"
```
2) Load the extension (dev/unpacked):
- Chrome → `chrome://extensions` → enable “Developer mode”
- `clawdbot browser extension install`
- “Load unpacked” → select the directory printed by `clawdbot browser extension path`
- Pin the extension, then click it on the tab you want to control (badge shows `ON`).
3) Use it:
- CLI: `clawdbot browser --browser-profile chrome tabs`
- Agent tool: `browser` with `profile="chrome"`
Notes:
- This mode relies on Playwright-on-CDP for most operations (screenshots/snapshots/actions).
- Detach by clicking the extension icon again.
## Isolation guarantees
- **Dedicated user data dir**: never touches your personal Chrome profile.
@@ -164,7 +301,8 @@ All endpoints accept `?profile=<name>`.
Some features (navigate/act/AI snapshot/role snapshot, element screenshots, PDF) require
Playwright. If Playwright isn’t installed, those endpoints return a clear 501
error. ARIA snapshots and basic screenshots still work.
error. ARIA snapshots and basic screenshots still work for clawd-managed Chrome.
For the Chrome extension relay driver, ARIA snapshots and screenshots require Playwright.
## How it works (internal)
@@ -204,6 +342,8 @@ Inspection:
- `clawdbot browser snapshot`
- `clawdbot browser snapshot --format aria --limit 200`
- `clawdbot browser snapshot --interactive --compact --depth 6`
- `clawdbot browser snapshot --efficient`
- `clawdbot browser snapshot --labels`
- `clawdbot browser snapshot --selector "#main" --interactive`
- `clawdbot browser snapshot --frame "iframe#main" --interactive`
- `clawdbot browser console --level error`
@@ -260,9 +400,11 @@ Notes:
- `snapshot`:
- `--format ai` (default when Playwright is installed): returns an AI snapshot with numeric refs (`aria-ref="<n>"`).
- `--format aria`: returns the accessibility tree (no refs; inspection only).
- `--efficient` (or `--mode efficient`): compact role snapshot preset (interactive + compact + depth + lower maxChars).
- Role snapshot options (`--interactive`, `--compact`, `--depth`, `--selector`) force a role-based snapshot with refs like `ref=e12`.
- `--frame "<iframe selector>"` scopes role snapshots to an iframe (pairs with role refs like `e12`).
- `--interactive` outputs a flat, easy-to-pick list of interactive elements (best for driving actions).
- `--labels` adds a viewport-only screenshot with overlayed ref labels (prints `MEDIA:<path>`).
- `click`/`type`/etc require a `ref` from `snapshot` (either numeric `12` or role ref `e12`).
CSS selectors are intentionally not supported for actions.
@@ -279,6 +421,7 @@ Clawdbot supports two “snapshot” styles:
- Output: a role-based list/tree with `[ref=e12]` (and optional `[nth=1]`).
- Actions: `clawdbot browser click e12`, `clawdbot browser highlight e12`.
- Internally, the ref is resolved via `getByRole(...)` (plus `nth()` for duplicates).
- Add `--labels` to include a viewport screenshot with overlayed `e12` labels.
Ref behavior:
- Refs are **not stable across navigations**; if something fails, re-run `snapshot` and use a fresh ref.
+181
View File
@@ -0,0 +1,181 @@
---
summary: "Chrome extension: let Clawdbot drive your existing Chrome tab"
read_when:
- You want the agent to drive an existing Chrome tab (toolbar button)
- You need remote Gateway + local browser automation via Tailscale
- You want to understand the security implications of browser takeover
---
# Chrome extension (browser relay)
The Clawdbot Chrome extension lets the agent control your **existing Chrome tabs** (your normal Chrome window) instead of launching a separate clawd-managed Chrome profile.
Attach/detach happens via a **single Chrome toolbar button**.
## What it is (concept)
There are three parts:
- **Browser control server** (HTTP): the API the agent/tool calls (`browser.controlUrl`)
- **Local relay server** (loopback CDP): bridges between the control server and the extension (`http://127.0.0.1:18792` by default)
- **Chrome MV3 extension**: attaches to the active tab using `chrome.debugger` and pipes CDP messages to the relay
Clawdbot then controls the attached tab through the normal `browser` tool surface (selecting the right profile).
## Install / load (unpacked)
1) Install the extension to a stable local path:
```bash
clawdbot browser extension install
```
2) Print the installed extension directory path:
```bash
clawdbot browser extension path
```
3) Chrome → `chrome://extensions`
- Enable “Developer mode”
- “Load unpacked” → select the directory printed above
4) Pin the extension.
## Updates (no build step)
The extension ships inside the Clawdbot release (npm package) as static files. There is no separate “build” step.
After upgrading Clawdbot:
- Re-run `clawdbot browser extension install` to refresh the installed files under your Clawdbot state directory.
- Chrome → `chrome://extensions` → click “Reload” on the extension.
## Create a browser profile for the extension
```bash
clawdbot browser create-profile \
--name chrome \
--driver extension \
--cdp-url http://127.0.0.1:18792 \
--color "#00AA00"
```
Then target it:
- CLI: `clawdbot browser --browser-profile chrome tabs`
- Agent tool: `browser` with `profile="chrome"`
## Attach / detach (toolbar button)
- Open the tab you want Clawdbot to control.
- Click the extension icon.
- Badge shows `ON` when attached.
- Click again to detach.
## Which tab does it control?
- It does **not** automatically control “whatever tab you’re looking at”.
- It controls **only the tab(s) you explicitly attached** by clicking the toolbar button.
- To switch: open the other tab and click the extension icon there.
## Badge + common errors
- `ON`: attached; Clawdbot can drive that tab.
- `…`: connecting to the local relay.
- `!`: relay not reachable (most common: browser relay server isn’t running on this machine).
If you see `!`:
- Make sure the Gateway is running locally (default setup), or run `clawdbot browser serve` on this machine (remote gateway setup).
- Open the extension Options page; it shows whether the relay is reachable.
## Do I need `clawdbot browser serve`?
### Local Gateway (same machine as Chrome) — usually **no**
If the Gateway is running on the same machine as Chrome and your `browser.controlUrl` is loopback (default),
you typically **do not** need `clawdbot browser serve`.
The Gateway’s built-in browser control server will start on `http://127.0.0.1:18791/` and Clawdbot will
auto-start the local relay server when you use a profile with `driver="extension"`.
### Remote Gateway (Gateway runs elsewhere) — **yes**
If your Gateway runs on another machine, run `clawdbot browser serve` on the machine that runs Chrome
(and publish it via Tailscale Serve / TLS). See the section below.
## Sandboxing (tool containers)
If your agent session is sandboxed (`agents.defaults.sandbox.mode != "off"`), the `browser` tool can be restricted:
- By default, sandboxed sessions often target the **sandbox browser** (`target="sandbox"`), not your host Chrome.
- Chrome extension relay takeover requires controlling the **host** browser control server.
Options:
- Easiest: use the extension from a **non-sandboxed** session/agent.
- Or allow host browser control for sandboxed sessions:
```json5
{
agents: {
defaults: {
sandbox: {
browser: {
allowHostControl: true
}
}
}
}
}
```
Then ensure the tool isn’t denied by tool policy, and (if needed) call `browser` with `target="host"`.
Debugging: `clawdbot sandbox explain`
## Remote Gateway (recommended: Tailscale Serve)
Goal: Gateway runs on one machine, but Chrome runs somewhere else.
On the **browser machine**:
```bash
clawdbot browser serve --bind 127.0.0.1 --port 18791 --token <token>
tailscale serve https / http://127.0.0.1:18791
```
On the **Gateway machine**:
- Set `browser.controlUrl` to the HTTPS Serve URL (MagicDNS/ts.net).
- Provide the token (prefer env):
```bash
export CLAWDBOT_BROWSER_CONTROL_TOKEN="<token>"
```
Then the agent can drive the browser by calling the remote `browser.controlUrl` API, while the extension + relay stay local on the browser machine.
## How “extension path” works
`clawdbot browser extension path` prints the **installed** on-disk directory containing the extension files.
The CLI intentionally does **not** print a `node_modules` path. Always run `clawdbot browser extension install` first to copy the extension to a stable location under your Clawdbot state directory.
If you move or delete that install directory, Chrome will mark the extension as broken until you reload it from a valid path.
## Security implications (read this)
This is powerful and risky. Treat it like giving the model “hands on your browser”.
- The extension uses Chrome’s debugger API (`chrome.debugger`). When attached, the model can:
- click/type/navigate in that tab
- read page content
- access whatever the tab’s logged-in session can access
- **This is not isolated** like the dedicated clawd-managed profile.
- If you attach to your daily-driver profile/tab, you’re granting access to that account state.
Recommendations:
- Prefer a dedicated Chrome profile (separate from your personal browsing) for extension relay usage.
- Keep the browser control server tailnet-only (Tailscale) and require a token.
- Avoid exposing browser control over LAN (`0.0.0.0`) and avoid Funnel (public).
Related:
- Browser tool overview: [Browser](/tools/browser)
- Security audit: [Security](/gateway/security)
- Tailscale setup: [Tailscale](/gateway/tailscale)
+1
View File
@@ -20,6 +20,7 @@ read_when:
- **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.
+81
View File
@@ -68,6 +68,59 @@ Example (global coding profile, messaging-only support agent):
}
```
## Provider-specific tool policy
Use `tools.byProvider` to **further restrict** tools for specific providers
(or a single `provider/model`) without changing your global defaults.
Per-agent override: `agents.list[].tools.byProvider`.
This is applied **after** the base tool profile and **before** allow/deny lists,
so it can only narrow the tool set.
Provider keys accept either `provider` (e.g. `google-antigravity`) or
`provider/model` (e.g. `openai/gpt-5.2`).
Example (keep global coding profile, but minimal tools for Google Antigravity):
```json5
{
tools: {
profile: "coding",
byProvider: {
"google-antigravity": { profile: "minimal" }
}
}
}
```
Example (provider/model-specific allowlist for a flaky endpoint):
```json5
{
tools: {
allow: ["group:fs", "group:runtime", "sessions_list"],
byProvider: {
"openai/gpt-5.2": { allow: ["group:fs", "sessions_list"] }
}
}
}
```
Example (agent-specific override for a single provider):
```json5
{
agents: {
list: [
{
id: "support",
tools: {
byProvider: {
"google-antigravity": { allow: ["message", "sessions_list"] }
}
}
}
]
}
}
```
## Tool groups (shorthands)
Tool policies (global, agent, sandbox) support `group:*` entries that expand to multiple tools.
@@ -78,6 +131,7 @@ Available groups:
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
- `group:sessions`: `sessions_list`, `sessions_history`, `sessions_send`, `sessions_spawn`, `session_status`
- `group:memory`: `memory_search`, `memory_get`
- `group:web`: `web_search`, `web_fetch`
- `group:ui`: `browser`, `canvas`
- `group:automation`: `cron`, `gateway`
- `group:messaging`: `message`
@@ -135,6 +189,33 @@ Notes:
- `log` supports line-based `offset`/`limit` (omit `offset` to grab the last N lines).
- `process` is scoped per agent; sessions from other agents are not visible.
### `web_search`
Search the web using Brave Search API.
Core parameters:
- `query` (required)
- `count` (1–10; default from `tools.web.search.maxResults`)
Notes:
- Requires a Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`).
- Enable via `tools.web.search.enabled`.
- Responses are cached (default 15 min).
- See [Web tools](/tools/web) for setup.
### `web_fetch`
Fetch and extract readable content from a URL (HTML → markdown/text).
Core parameters:
- `url` (required)
- `extractMode` (`markdown` | `text`)
- `maxChars` (truncate long pages)
Notes:
- Enable via `tools.web.fetch.enabled`.
- Responses are cached (default 15 min).
- For JS-heavy sites, prefer the browser tool.
- See [Web tools](/tools/web) for setup.
### `browser`
Control the dedicated clawd browser.
+1 -1
View File
@@ -11,7 +11,7 @@ All skills-related configuration lives under `skills` in `~/.clawdbot/clawdbot.j
```json5
{
skills: {
allowBundled: ["brave-search", "gemini"],
allowBundled: ["gemini", "peekaboo"],
load: {
extraDirs: [
"~/Projects/agent-scripts/skills",
+7
View File
@@ -105,6 +105,13 @@ Fields under `metadata.clawdbot`:
- `primaryEnv` — env var name associated with `skills.entries.<name>.apiKey`.
- `install` — optional array of installer specs used by the macOS Skills UI (brew/node/go/uv).
Note on sandboxing:
- `requires.bins` is checked on the **host** at skill load time.
- If an agent is sandboxed, the binary must also exist **inside the container**.
Install it via `agents.defaults.sandbox.docker.setupCommand` (or a custom image).
Example: the `summarize` skill (`skills/summarize/SKILL.md`) needs the `summarize` CLI
in the sandbox container to run there.
Installer example:
```markdown
+6 -1
View File
@@ -49,8 +49,13 @@ Sub-agents report back via an announce step:
- The announce step runs inside the sub-agent session (not the requester session).
- If the sub-agent replies exactly `ANNOUNCE_SKIP`, nothing is posted.
- Otherwise the announce reply is posted to the requester chat channel via the gateway `send` method.
- Announce messages are normalized to a stable template:
- `Status:` derived from the run outcome (`success`, `error`, `timeout`, or `unknown`).
- `Result:` the summary content from the announce step (or `(not available)` if missing).
- `Notes:` error details and other useful context.
- `Status` is not inferred from model output; it comes from runtime outcome signals.
Announce payloads include a stats line at the end:
Announce payloads include a stats line at the end (even when wrapped):
- Runtime (e.g., `runtime 5m12s`)
- Token usage (input/output/total)
- Estimated cost when model pricing is configured (`models.providers.*.models[].cost`)
+112
View File
@@ -0,0 +1,112 @@
---
summary: "Web search + fetch tools (Brave Search API)"
read_when:
- You want to enable web_search or web_fetch
- You need Brave Search API key setup
---
# Web tools
Clawdbot ships two lightweight web tools:
- `web_search` — Brave Search API queries (fast, structured results).
- `web_fetch` — HTTP fetch + readable extraction (HTML → markdown/text).
These are **not** browser automation. For JS-heavy sites or logins, use the
[Browser tool](/tools/browser).
## How it works
- `web_search` calls Brave’s Search API and returns structured results
(title, URL, snippet). No browser is involved.
- Results are cached by query for 15 minutes (configurable).
- `web_fetch` does a plain HTTP GET and extracts readable content
(HTML → markdown/text). It does **not** execute JavaScript.
- In sandboxed sessions, `web_fetch` is enabled automatically (unless explicitly disabled).
## Getting a Brave API key
1) Create a Brave Search API account at https://brave.com/search/api/
2) Generate an API key in the dashboard.
3) Run `clawdbot configure --section web` to store the key in config (recommended), or set `BRAVE_API_KEY` in your environment.
Brave provides a free tier plus paid plans; check the Brave API portal for the
current limits and pricing.
### Where to set the key (recommended)
**Recommended:** run `clawdbot configure --section web`. It stores the key in
`~/.clawdbot/clawdbot.json` under `tools.web.search.apiKey`.
**Environment alternative:** set `BRAVE_API_KEY` in the Gateway process
environment. For a daemon install, put it in `~/.clawdbot/.env` (or your
service environment). See [Env vars](/start/faq#how-does-clawdbot-load-environment-variables).
## web_search
Search the web with Brave’s API.
### Requirements
- `tools.web.search.enabled: true`
- Brave API key (recommended: `clawdbot configure --section web`, or set `BRAVE_API_KEY`)
### Config
```json5
{
tools: {
web: {
search: {
enabled: true,
apiKey: "BRAVE_API_KEY_HERE", // optional if BRAVE_API_KEY is set
maxResults: 5,
timeoutSeconds: 30,
cacheTtlMinutes: 15
}
}
}
}
```
### Tool parameters
- `query` (required)
- `count` (1–10; default from config)
## web_fetch
Fetch a URL and extract readable content.
### Requirements
- `tools.web.fetch.enabled: true`
### Config
```json5
{
tools: {
web: {
fetch: {
enabled: true,
maxChars: 50000,
timeoutSeconds: 30,
cacheTtlMinutes: 15,
userAgent: "clawdbot/2026.1.14"
}
}
}
}
```
### Tool parameters
- `url` (required, http/https only)
- `extractMode` (`markdown` | `text`)
- `maxChars` (truncate long pages)
Notes:
- `web_fetch` is best-effort extraction; some sites will need the browser tool.
- Responses are cached (default 15 minutes) to reduce repeated fetches.
- If you use tool profiles/allowlists, add `web_search`/`web_fetch` or `group:web`.
+2 -1
View File
@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`)
- Stream tool calls + live tool output cards in Chat (agent events)
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.set`)
- Connections: WhatsApp/Telegram status + QR login + Telegram config (`channels.status`, `web.login.*`, `config.patch`)
- Instances: presence list + refresh (`system-presence`)
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
@@ -38,6 +38,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
- Nodes: list + caps (`node.list`)
- Config: view/edit `~/.clawdbot/clawdbot.json` (`config.get`, `config.set`)
- Config: apply + restart with validation (`config.apply`) and wake the last active session
- Config writes include a base-hash guard to prevent clobbering concurrent edits
- Config schema + form rendering (`config.schema`); Raw JSON editor remains available
- Debug: status/health/models snapshots + event log + manual RPC calls (`status`, `health`, `models.list`)
- Logs: live tail of gateway file logs with filter/export (`logs.tail`)
+10
View File
@@ -0,0 +1,10 @@
# Changelog
## 0.1.0
### Features
- Zalo Bot API channel plugin with token-based auth (env/config/file).
- Direct message support (DMs only) with pairing/allowlist/open/disabled policies.
- Polling and webhook delivery modes.
- Text + image messaging with 2000-char chunking and media size caps.
- Multi-account support with per-account config.
+50
View File
@@ -0,0 +1,50 @@
# @clawdbot/zalo
Zalo channel plugin for Clawdbot (Bot API).
## Install (local checkout)
```bash
clawdbot plugins install ./extensions/zalo
```
## Install (npm)
```bash
clawdbot plugins install @clawdbot/zalo
```
Onboarding: select Zalo and confirm the install prompt to fetch the plugin automatically.
## Config
```json5
{
channels: {
zalo: {
enabled: true,
botToken: "12345689:abc-xyz",
dmPolicy: "pairing",
proxy: "http://proxy.local:8080"
}
}
}
```
## Webhook mode
```json5
{
channels: {
zalo: {
webhookUrl: "https://example.com/zalo-webhook",
webhookSecret: "your-secret-8-plus-chars",
webhookPath: "/zalo-webhook"
}
}
}
```
If `webhookPath` is omitted, the plugin uses the webhook URL path.
Restart the gateway after config changes.
+16
View File
@@ -0,0 +1,16 @@
import type { ClawdbotPluginApi } from "../../src/plugins/types.js";
import { zaloDock, zaloPlugin } from "./src/channel.js";
import { handleZaloWebhookRequest } from "./src/monitor.js";
const plugin = {
id: "zalo",
name: "Zalo",
description: "Zalo channel plugin (Bot API)",
register(api: ClawdbotPluginApi) {
api.registerChannel({ plugin: zaloPlugin, dock: zaloDock });
api.registerHttpHandler(handleZaloWebhookRequest);
},
};
export default plugin;
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@clawdbot/zalo",
"version": "0.1.0",
"type": "module",
"description": "Clawdbot Zalo channel plugin",
"clawdbot": {
"extensions": ["./index.ts"]
},
"dependencies": {
"undici": "7.18.2"
}
}
+74
View File
@@ -0,0 +1,74 @@
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: 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: 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: CoreConfig): string {
const zaloConfig = cfg.channels?.zalo as ZaloConfig | undefined;
if (zaloConfig?.defaultAccount?.trim()) return zaloConfig.defaultAccount.trim();
const ids = listZaloAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: CoreConfig,
accountId: string,
): ZaloAccountConfig | undefined {
const accounts = (cfg.channels?.zalo as ZaloConfig | undefined)?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as ZaloAccountConfig | undefined;
}
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) ?? {};
return { ...base, ...account };
}
export function resolveZaloAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedZaloAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = (params.cfg.channels?.zalo as ZaloConfig | undefined)?.enabled !== false;
const merged = mergeZaloAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const tokenResolution = resolveZaloToken(
params.cfg.channels?.zalo as ZaloConfig | undefined,
accountId,
);
return {
accountId,
name: merged.name?.trim() || undefined,
enabled,
token: tokenResolution.token,
tokenSource: tokenResolution.source,
config: merged,
};
}
export function listEnabledZaloAccounts(cfg: CoreConfig): ResolvedZaloAccount[] {
return listZaloAccountIds(cfg)
.map((accountId) => resolveZaloAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
+59
View File
@@ -0,0 +1,59 @@
import type { ChannelMessageActionAdapter, ChannelMessageActionName } from "../../src/channels/plugins/types.js";
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: CoreConfig) {
return listEnabledZaloAccounts(cfg).filter(
(account) => account.enabled && account.tokenSource !== "none",
);
}
export const zaloMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const accounts = listEnabledAccounts(cfg as CoreConfig);
if (accounts.length === 0) return [];
const actions = new Set<ChannelMessageActionName>(["send"]);
return Array.from(actions);
},
supportsButtons: () => false,
extractToolSend: ({ args }) => {
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 === "send") {
const to = readStringParam(params, "to", { required: true });
const content = readStringParam(params, "message", {
required: true,
allowEmpty: true,
});
const mediaUrl = readStringParam(params, "media", { trim: false });
const result = await sendMessageZalo(to ?? "", content ?? "", {
accountId: accountId ?? undefined,
mediaUrl: mediaUrl ?? undefined,
cfg: cfg as CoreConfig,
});
if (!result.ok) {
return jsonResult({
ok: false,
error: result.error ?? "Failed to send Zalo message",
});
}
return jsonResult({ ok: true, to, messageId: result.messageId });
}
throw new Error(`Action ${action} is not supported for provider ${providerId}.`);
},
};
+206
View File
@@ -0,0 +1,206 @@
/**
* Zalo Bot API client
* @see https://bot.zaloplatforms.com/docs
*/
const ZALO_API_BASE = "https://bot-api.zaloplatforms.com";
export type ZaloFetch = (input: string, init?: RequestInit) => Promise<Response>;
export type ZaloApiResponse<T = unknown> = {
ok: boolean;
result?: T;
error_code?: number;
description?: string;
};
export type ZaloBotInfo = {
id: string;
name: string;
avatar?: string;
};
export type ZaloMessage = {
message_id: string;
from: {
id: string;
name?: string;
avatar?: string;
};
chat: {
id: string;
chat_type: "PRIVATE" | "GROUP";
};
date: number;
text?: string;
photo?: string;
caption?: string;
sticker?: string;
};
export type ZaloUpdate = {
event_name:
| "message.text.received"
| "message.image.received"
| "message.sticker.received"
| "message.unsupported.received";
message?: ZaloMessage;
};
export type ZaloSendMessageParams = {
chat_id: string;
text: string;
};
export type ZaloSendPhotoParams = {
chat_id: string;
photo: string;
caption?: string;
};
export type ZaloSetWebhookParams = {
url: string;
secret_token: string;
};
export type ZaloGetUpdatesParams = {
/** Timeout in seconds (passed as string to API) */
timeout?: number;
};
export class ZaloApiError extends Error {
constructor(
message: string,
public readonly errorCode?: number,
public readonly description?: string,
) {
super(message);
this.name = "ZaloApiError";
}
/** True if this is a long-polling timeout (no updates available) */
get isPollingTimeout(): boolean {
return this.errorCode === 408;
}
}
/**
* Call the Zalo Bot API
*/
export async function callZaloApi<T = unknown>(
method: string,
token: string,
body?: Record<string, unknown>,
options?: { timeoutMs?: number; fetch?: ZaloFetch },
): Promise<ZaloApiResponse<T>> {
const url = `${ZALO_API_BASE}/bot${token}/${method}`;
const controller = new AbortController();
const timeoutId = options?.timeoutMs
? setTimeout(() => controller.abort(), options.timeoutMs)
: undefined;
const fetcher = options?.fetch ?? fetch;
try {
const response = await fetcher(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
const data = (await response.json()) as ZaloApiResponse<T>;
if (!data.ok) {
throw new ZaloApiError(
data.description ?? `Zalo API error: ${method}`,
data.error_code,
data.description,
);
}
return data;
} finally {
if (timeoutId) clearTimeout(timeoutId);
}
}
/**
* Validate bot token and get bot info
*/
export async function getMe(
token: string,
timeoutMs?: number,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<ZaloBotInfo>> {
return callZaloApi<ZaloBotInfo>("getMe", token, undefined, { timeoutMs, fetch: fetcher });
}
/**
* Send a text message
*/
export async function sendMessage(
token: string,
params: ZaloSendMessageParams,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<ZaloMessage>> {
return callZaloApi<ZaloMessage>("sendMessage", token, params, { fetch: fetcher });
}
/**
* Send a photo message
*/
export async function sendPhoto(
token: string,
params: ZaloSendPhotoParams,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<ZaloMessage>> {
return callZaloApi<ZaloMessage>("sendPhoto", token, params, { fetch: fetcher });
}
/**
* Get updates using long polling (dev/testing only)
* Note: Zalo returns a single update per call, not an array like Telegram
*/
export async function getUpdates(
token: string,
params?: ZaloGetUpdatesParams,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<ZaloUpdate>> {
const pollTimeoutSec = params?.timeout ?? 30;
const timeoutMs = (pollTimeoutSec + 5) * 1000;
const body = { timeout: String(pollTimeoutSec) };
return callZaloApi<ZaloUpdate>("getUpdates", token, body, { timeoutMs, fetch: fetcher });
}
/**
* Set webhook URL for receiving updates
*/
export async function setWebhook(
token: string,
params: ZaloSetWebhookParams,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<boolean>> {
return callZaloApi<boolean>("setWebhook", token, params, { fetch: fetcher });
}
/**
* Delete webhook configuration
*/
export async function deleteWebhook(
token: string,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<boolean>> {
return callZaloApi<boolean>("deleteWebhook", token, undefined, { fetch: fetcher });
}
/**
* Get current webhook info
*/
export async function getWebhookInfo(
token: string,
fetcher?: ZaloFetch,
): Promise<ZaloApiResponse<{ url?: string; has_custom_certificate?: boolean }>> {
return callZaloApi("getWebhookInfo", token, undefined, { fetch: fetcher });
}
+370
View File
@@ -0,0 +1,370 @@
import type { ChannelDock, ChannelPlugin } from "../../src/channels/plugins/types.js";
import type { ChannelAccountSnapshot } from "../../src/channels/plugins/types.js";
import { listZaloAccountIds, resolveDefaultZaloAccountId, resolveZaloAccount, type ResolvedZaloAccount } from "./accounts.js";
import { zaloMessageActions } from "./actions.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",
label: "Zalo",
selectionLabel: "Zalo (Bot API)",
docsPath: "/channels/zalo",
docsLabel: "zalo",
blurb: "Vietnam-focused messaging platform with Bot API.",
aliases: ["zl"],
order: 80,
quickstartAllowFrom: true,
};
function normalizeZaloMessagingTarget(raw: string): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
return trimmed.replace(/^(zalo|zl):/i, "");
}
export const zaloDock: ChannelDock = {
id: "zalo",
capabilities: {
chatTypes: ["direct"],
media: true,
blockStreaming: true,
},
outbound: { textChunkLimit: 2000 },
config: {
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(zalo|zl):/i, ""))
.map((entry) => entry.toLowerCase()),
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
};
export const zaloPlugin: ChannelPlugin<ResolvedZaloAccount> = {
id: "zalo",
meta,
onboarding: zaloOnboardingAdapter,
capabilities: {
chatTypes: ["direct"],
media: true,
reactions: false,
threads: false,
polls: false,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.zalo"] },
config: {
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 CoreConfig,
sectionKey: "zalo",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg: cfg as CoreConfig,
sectionKey: "zalo",
accountId,
clearBaseFields: ["botToken", "tokenFile", "name"],
}),
isConfigured: (account) => Boolean(account.token?.trim()),
describeAccount: (account): ChannelAccountSnapshot => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.token?.trim()),
tokenSource: account.tokenSource,
}),
resolveAllowFrom: ({ cfg, accountId }) =>
(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
(entry) => String(entry),
),
formatAllowFrom: ({ allowFrom }) =>
allowFrom
.map((entry) => String(entry).trim())
.filter(Boolean)
.map((entry) => entry.replace(/^(zalo|zl):/i, ""))
.map((entry) => entry.toLowerCase()),
},
security: {
resolveDmPolicy: ({ cfg, accountId, account }) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const useAccountPath = Boolean(
(cfg as CoreConfig).channels?.zalo?.accounts?.[resolvedAccountId],
);
const basePath = useAccountPath
? `channels.zalo.accounts.${resolvedAccountId}.`
: "channels.zalo.";
return {
policy: account.config.dmPolicy ?? "pairing",
allowFrom: account.config.allowFrom ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: formatPairingApproveHint("zalo"),
normalizeEntry: (raw) => raw.replace(/^(zalo|zl):/i, ""),
};
},
},
groups: {
resolveRequireMention: () => true,
},
threading: {
resolveReplyToMode: () => "off",
},
actions: zaloMessageActions,
messaging: {
normalizeTarget: normalizeZaloMessagingTarget,
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: "zalo",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "ZALO_BOT_TOKEN can only be used for the default account.";
}
if (!input.useEnv && !input.token && !input.tokenFile) {
return "Zalo requires --token or --token-file (or --use-env).";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const namedConfig = applyAccountNameToChannelSection({
cfg: cfg as CoreConfig,
channelKey: "zalo",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "zalo",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
zalo: {
...next.channels?.zalo,
enabled: true,
...(input.useEnv
? {}
: input.tokenFile
? { tokenFile: input.tokenFile }
: input.token
? { botToken: input.token }
: {}),
},
},
} as CoreConfig;
}
return {
...next,
channels: {
...next.channels,
zalo: {
...next.channels?.zalo,
enabled: true,
accounts: {
...(next.channels?.zalo?.accounts ?? {}),
[accountId]: {
...(next.channels?.zalo?.accounts?.[accountId] ?? {}),
enabled: true,
...(input.tokenFile
? { tokenFile: input.tokenFile }
: input.token
? { botToken: input.token }
: {}),
},
},
},
},
} as CoreConfig;
},
},
pairing: {
idLabel: "zaloUserId",
normalizeAllowEntry: (entry) => entry.replace(/^(zalo|zl):/i, ""),
notifyApproval: async ({ cfg, id }) => {
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 });
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => {
if (!text) return [];
if (limit <= 0 || text.length <= limit) return [text];
const chunks: string[] = [];
let remaining = text;
while (remaining.length > limit) {
const window = remaining.slice(0, limit);
const lastNewline = window.lastIndexOf("\n");
const lastSpace = window.lastIndexOf(" ");
let breakIdx = lastNewline > 0 ? lastNewline : lastSpace;
if (breakIdx <= 0) breakIdx = limit;
const rawChunk = remaining.slice(0, breakIdx);
const chunk = rawChunk.trimEnd();
if (chunk.length > 0) chunks.push(chunk);
const brokeOnSeparator = breakIdx < remaining.length && /\s/.test(remaining[breakIdx]);
const nextStart = Math.min(remaining.length, breakIdx + (brokeOnSeparator ? 1 : 0));
remaining = remaining.slice(nextStart).trimStart();
}
if (remaining.length) chunks.push(remaining);
return chunks;
},
textChunkLimit: 2000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error("Delivering to Zalo requires --to <chatId>"),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,
cfg: cfg as CoreConfig,
});
return {
channel: "zalo",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg }) => {
const result = await sendMessageZalo(to, text, {
accountId: accountId ?? undefined,
mediaUrl,
cfg: cfg as CoreConfig,
});
return {
channel: "zalo",
ok: result.ok,
messageId: result.messageId ?? "",
error: result.error ? new Error(result.error) : undefined,
};
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
collectStatusIssues: collectZaloStatusIssues,
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
tokenSource: snapshot.tokenSource ?? "none",
running: snapshot.running ?? false,
mode: snapshot.mode ?? null,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) =>
probeZalo(account.token, timeoutMs, resolveZaloProxyFetch(account.config.proxy)),
buildAccountSnapshot: ({ account, runtime }) => {
const configured = Boolean(account.token?.trim());
return {
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured,
tokenSource: account.tokenSource,
running: runtime?.running ?? false,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
mode: account.config.webhookUrl ? "webhook" : "polling",
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
dmPolicy: account.config.dmPolicy ?? "pairing",
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
const token = account.token.trim();
let zaloBotLabel = "";
const fetcher = resolveZaloProxyFetch(account.config.proxy);
try {
const probe = await probeZalo(token, 2500, fetcher);
const name = probe.ok ? probe.bot?.name?.trim() : null;
if (name) zaloBotLabel = ` (${name})`;
ctx.setStatus({
accountId: account.accountId,
bot: probe.bot,
});
} catch {
// ignore probe errors
}
ctx.log?.info(`[${account.accountId}] starting provider${zaloBotLabel}`);
const { monitorZaloProvider } = await import("./monitor.js");
return monitorZaloProvider({
token,
account,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
useWebhook: Boolean(account.config.webhookUrl),
webhookUrl: account.config.webhookUrl,
webhookSecret: account.config.webhookSecret,
webhookPath: account.config.webhookPath,
fetcher,
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;
}
+679
View File
@@ -0,0 +1,679 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import type { ResolvedZaloAccount } from "./accounts.js";
import {
ZaloApiError,
deleteWebhook,
getUpdates,
sendMessage,
sendPhoto,
setWebhook,
type ZaloFetch,
type ZaloMessage,
type ZaloUpdate,
} from "./api.js";
import { loadCoreChannelDeps } from "./core-bridge.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import type { CoreConfig } from "./types.js";
export type ZaloRuntimeEnv = {
log?: (message: string) => void;
error?: (message: string) => void;
};
export type ZaloMonitorOptions = {
token: string;
account: ResolvedZaloAccount;
config: CoreConfig;
runtime: ZaloRuntimeEnv;
abortSignal: AbortSignal;
useWebhook?: boolean;
webhookUrl?: string;
webhookSecret?: string;
webhookPath?: string;
fetcher?: ZaloFetch;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
};
export type ZaloMonitorResult = {
stop: () => void;
};
const ZALO_TEXT_LIMIT = 2000;
const DEFAULT_MEDIA_MAX_MB = 5;
function logVerbose(deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>, message: string): void {
if (deps.shouldLogVerbose()) {
console.log(`[zalo] ${message}`);
}
}
function isSenderAllowed(senderId: string, allowFrom: string[]): boolean {
if (allowFrom.includes("*")) return true;
const normalizedSenderId = senderId.toLowerCase();
return allowFrom.some((entry) => {
const normalized = entry.toLowerCase().replace(/^(zalo|zl):/i, "");
return normalized === normalizedSenderId;
});
}
async function readJsonBody(req: IncomingMessage, maxBytes: number) {
const chunks: Buffer[] = [];
let total = 0;
return await new Promise<{ ok: boolean; value?: unknown; error?: string }>((resolve) => {
req.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > maxBytes) {
resolve({ ok: false, error: "payload too large" });
req.destroy();
return;
}
chunks.push(chunk);
});
req.on("end", () => {
try {
const raw = Buffer.concat(chunks).toString("utf8");
if (!raw.trim()) {
resolve({ ok: false, error: "empty payload" });
return;
}
resolve({ ok: true, value: JSON.parse(raw) as unknown });
} catch (err) {
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
}
});
req.on("error", (err) => {
resolve({ ok: false, error: err instanceof Error ? err.message : String(err) });
});
});
}
type WebhookTarget = {
token: string;
account: ResolvedZaloAccount;
config: CoreConfig;
runtime: ZaloRuntimeEnv;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
secret: string;
path: string;
mediaMaxMb: number;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
};
const webhookTargets = new Map<string, WebhookTarget[]>();
function normalizeWebhookPath(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) return "/";
const withSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
if (withSlash.length > 1 && withSlash.endsWith("/")) {
return withSlash.slice(0, -1);
}
return withSlash;
}
function resolveWebhookPath(webhookPath?: string, webhookUrl?: string): string | null {
const trimmedPath = webhookPath?.trim();
if (trimmedPath) return normalizeWebhookPath(trimmedPath);
if (webhookUrl?.trim()) {
try {
const parsed = new URL(webhookUrl);
return normalizeWebhookPath(parsed.pathname || "/");
} catch {
return null;
}
}
return null;
}
export function registerZaloWebhookTarget(target: WebhookTarget): () => void {
const key = normalizeWebhookPath(target.path);
const normalizedTarget = { ...target, path: key };
const existing = webhookTargets.get(key) ?? [];
const next = [...existing, normalizedTarget];
webhookTargets.set(key, next);
return () => {
const updated = (webhookTargets.get(key) ?? []).filter(
(entry) => entry !== normalizedTarget,
);
if (updated.length > 0) {
webhookTargets.set(key, updated);
} else {
webhookTargets.delete(key);
}
};
}
export async function handleZaloWebhookRequest(
req: IncomingMessage,
res: ServerResponse,
): Promise<boolean> {
const url = new URL(req.url ?? "/", "http://localhost");
const path = normalizeWebhookPath(url.pathname);
const targets = webhookTargets.get(path);
if (!targets || targets.length === 0) return false;
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
return true;
}
const headerToken = String(req.headers["x-bot-api-secret-token"] ?? "");
const target = targets.find((entry) => entry.secret === headerToken);
if (!target) {
res.statusCode = 401;
res.end("unauthorized");
return true;
}
const body = await readJsonBody(req, 1024 * 1024);
if (!body.ok) {
res.statusCode = body.error === "payload too large" ? 413 : 400;
res.end(body.error ?? "invalid payload");
return true;
}
const payload = body.value as { ok?: boolean; result?: ZaloUpdate };
if (!payload?.ok || !payload.result) {
res.statusCode = 400;
res.end("invalid payload");
return true;
}
target.statusSink?.({ lastInboundAt: Date.now() });
processUpdate(
payload.result,
target.token,
target.account,
target.config,
target.runtime,
target.deps,
target.mediaMaxMb,
target.statusSink,
target.fetcher,
).catch((err) => {
target.runtime.error?.(`[${target.account.accountId}] Zalo webhook failed: ${String(err)}`);
});
res.statusCode = 200;
res.end("ok");
return true;
}
function startPollingLoop(params: {
token: string;
account: ResolvedZaloAccount;
config: CoreConfig;
runtime: ZaloRuntimeEnv;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
abortSignal: AbortSignal;
isStopped: () => boolean;
mediaMaxMb: number;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
}) {
const {
token,
account,
config,
runtime,
deps,
abortSignal,
isStopped,
mediaMaxMb,
statusSink,
fetcher,
} = params;
const pollTimeout = 30;
const poll = async () => {
if (isStopped() || abortSignal.aborted) return;
try {
const response = await getUpdates(token, { timeout: pollTimeout }, fetcher);
if (response.ok && response.result) {
statusSink?.({ lastInboundAt: Date.now() });
await processUpdate(
response.result,
token,
account,
config,
runtime,
deps,
mediaMaxMb,
statusSink,
fetcher,
);
}
} catch (err) {
if (err instanceof ZaloApiError && err.isPollingTimeout) {
// no updates
} else if (!isStopped() && !abortSignal.aborted) {
console.error(`[${account.accountId}] Zalo polling error:`, err);
await new Promise((resolve) => setTimeout(resolve, 5000));
}
}
if (!isStopped() && !abortSignal.aborted) {
setImmediate(poll);
}
};
void poll();
}
async function processUpdate(
update: ZaloUpdate,
token: string,
account: ResolvedZaloAccount,
config: CoreConfig,
runtime: ZaloRuntimeEnv,
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
mediaMaxMb: number,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
): Promise<void> {
const { event_name, message } = update;
if (!message) return;
switch (event_name) {
case "message.text.received":
await handleTextMessage(
message,
token,
account,
config,
runtime,
deps,
statusSink,
fetcher,
);
break;
case "message.image.received":
await handleImageMessage(
message,
token,
account,
config,
runtime,
deps,
mediaMaxMb,
statusSink,
fetcher,
);
break;
case "message.sticker.received":
console.log(`[${account.accountId}] Received sticker from ${message.from.id}`);
break;
case "message.unsupported.received":
console.log(
`[${account.accountId}] Received unsupported message type from ${message.from.id}`,
);
break;
}
}
async function handleTextMessage(
message: ZaloMessage,
token: string,
account: ResolvedZaloAccount,
config: CoreConfig,
runtime: ZaloRuntimeEnv,
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
): Promise<void> {
const { text } = message;
if (!text?.trim()) return;
await processMessageWithPipeline({
message,
token,
account,
config,
runtime,
deps,
text,
mediaPath: undefined,
mediaType: undefined,
statusSink,
fetcher,
});
}
async function handleImageMessage(
message: ZaloMessage,
token: string,
account: ResolvedZaloAccount,
config: CoreConfig,
runtime: ZaloRuntimeEnv,
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>,
mediaMaxMb: number,
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void,
fetcher?: ZaloFetch,
): Promise<void> {
const { photo, caption } = message;
let mediaPath: string | undefined;
let mediaType: string | undefined;
if (photo) {
try {
const maxBytes = mediaMaxMb * 1024 * 1024;
const fetched = await deps.fetchRemoteMedia({ url: photo });
const saved = await deps.saveMediaBuffer(
fetched.buffer,
fetched.contentType,
"inbound",
maxBytes,
);
mediaPath = saved.path;
mediaType = saved.contentType;
} catch (err) {
console.error(`[${account.accountId}] Failed to download Zalo image:`, err);
}
}
await processMessageWithPipeline({
message,
token,
account,
config,
runtime,
deps,
text: caption,
mediaPath,
mediaType,
statusSink,
fetcher,
});
}
async function processMessageWithPipeline(params: {
message: ZaloMessage;
token: string;
account: ResolvedZaloAccount;
config: CoreConfig;
runtime: ZaloRuntimeEnv;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
text?: string;
mediaPath?: string;
mediaType?: string;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
}): Promise<void> {
const {
message,
token,
account,
config,
runtime,
deps,
text,
mediaPath,
mediaType,
statusSink,
fetcher,
} = params;
const { from, chat, message_id, date } = message;
const isGroup = chat.chat_type === "GROUP";
const chatId = chat.id;
const senderId = from.id;
const senderName = from.name;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const configAllowFrom = (account.config.allowFrom ?? []).map((v) => String(v));
if (!isGroup) {
if (dmPolicy === "disabled") {
logVerbose(deps, `Blocked zalo DM from ${senderId} (dmPolicy=disabled)`);
return;
}
if (dmPolicy !== "open") {
const storeAllowFrom = await deps.readChannelAllowFromStore("zalo").catch(() => []);
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowFrom];
const allowed = isSenderAllowed(senderId, effectiveAllowFrom);
if (!allowed) {
if (dmPolicy === "pairing") {
const { code, created } = await deps.upsertChannelPairingRequest({
channel: "zalo",
id: senderId,
meta: { name: senderName ?? undefined },
});
if (created) {
logVerbose(deps, `zalo pairing request sender=${senderId}`);
try {
await sendMessage(
token,
{
chat_id: chatId,
text: deps.buildPairingReply({
channel: "zalo",
idLine: `Your Zalo user id: ${senderId}`,
code,
}),
},
fetcher,
);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
logVerbose(deps, `zalo pairing reply failed for ${senderId}: ${String(err)}`);
}
}
} else {
logVerbose(deps, `Blocked unauthorized zalo sender ${senderId} (dmPolicy=${dmPolicy})`);
}
return;
}
}
}
const route = deps.resolveAgentRoute({
cfg: config,
channel: "zalo",
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "dm",
id: chatId,
},
});
const rawBody = text?.trim() || (mediaPath ? "<media:image>" : "");
const fromLabel = isGroup
? `group:${chatId} from ${senderName || senderId}`
: senderName || `user:${senderId}`;
const body = deps.formatAgentEnvelope({
channel: "Zalo",
from: fromLabel,
timestamp: date ? date * 1000 : undefined,
body: rawBody,
});
const ctxPayload = {
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: isGroup ? `group:${chatId}` : `zalo:${senderId}`,
To: `zalo:${chatId}`,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: isGroup ? "group" : "direct",
SenderName: senderName || undefined,
SenderId: senderId,
Provider: "zalo",
Surface: "zalo",
MessageSid: message_id,
MediaPath: mediaPath,
MediaType: mediaType,
MediaUrl: mediaPath,
OriginatingChannel: "zalo",
OriginatingTo: `zalo:${chatId}`,
};
await deps.dispatchReplyWithBufferedBlockDispatcher({
ctx: ctxPayload,
cfg: config,
dispatcherOptions: {
deliver: async (payload) => {
await deliverZaloReply({
payload,
token,
chatId,
runtime,
deps,
statusSink,
fetcher,
});
},
onError: (err, info) => {
runtime.error?.(`[${account.accountId}] Zalo ${info.kind} reply failed: ${String(err)}`);
},
},
});
}
async function deliverZaloReply(params: {
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string };
token: string;
chatId: string;
runtime: ZaloRuntimeEnv;
deps: Awaited<ReturnType<typeof loadCoreChannelDeps>>;
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
fetcher?: ZaloFetch;
}): Promise<void> {
const { payload, token, chatId, runtime, deps, statusSink, fetcher } = params;
const mediaList = payload.mediaUrls?.length
? payload.mediaUrls
: payload.mediaUrl
? [payload.mediaUrl]
: [];
if (mediaList.length > 0) {
let first = true;
for (const mediaUrl of mediaList) {
const caption = first ? payload.text : undefined;
first = false;
try {
await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Zalo photo send failed: ${String(err)}`);
}
}
return;
}
if (payload.text) {
const chunks = deps.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT);
for (const chunk of chunks) {
try {
await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher);
statusSink?.({ lastOutboundAt: Date.now() });
} catch (err) {
runtime.error?.(`Zalo message send failed: ${String(err)}`);
}
}
}
}
export async function monitorZaloProvider(
options: ZaloMonitorOptions,
): Promise<ZaloMonitorResult> {
const {
token,
account,
config,
runtime,
abortSignal,
useWebhook,
webhookUrl,
webhookSecret,
webhookPath,
statusSink,
fetcher: fetcherOverride,
} = options;
const deps = await loadCoreChannelDeps();
const effectiveMediaMaxMb = account.config.mediaMaxMb ?? DEFAULT_MEDIA_MAX_MB;
const fetcher = fetcherOverride ?? resolveZaloProxyFetch(account.config.proxy);
let stopped = false;
const stopHandlers: Array<() => void> = [];
const stop = () => {
stopped = true;
for (const handler of stopHandlers) {
handler();
}
};
if (useWebhook) {
if (!webhookUrl || !webhookSecret) {
throw new Error("Zalo webhookUrl and webhookSecret are required for webhook mode");
}
if (!webhookUrl.startsWith("https://")) {
throw new Error("Zalo webhook URL must use HTTPS");
}
if (webhookSecret.length < 8 || webhookSecret.length > 256) {
throw new Error("Zalo webhook secret must be 8-256 characters");
}
const path = resolveWebhookPath(webhookPath, webhookUrl);
if (!path) {
throw new Error("Zalo webhookPath could not be derived");
}
await setWebhook(token, { url: webhookUrl, secret_token: webhookSecret }, fetcher);
const unregister = registerZaloWebhookTarget({
token,
account,
config,
runtime,
deps,
path,
secret: webhookSecret,
statusSink: (patch) => statusSink?.(patch),
mediaMaxMb: effectiveMediaMaxMb,
fetcher,
});
stopHandlers.push(unregister);
abortSignal.addEventListener(
"abort",
() => {
void deleteWebhook(token, fetcher).catch(() => {});
},
{ once: true },
);
return { stop };
}
try {
await deleteWebhook(token, fetcher);
} catch {
// ignore
}
startPollingLoop({
token,
account,
config,
runtime,
deps,
abortSignal,
isStopped: () => stopped,
mediaMaxMb: effectiveMediaMaxMb,
statusSink,
fetcher,
});
return { stop };
}
+384
View File
@@ -0,0 +1,384 @@
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../../src/channels/plugins/onboarding-types.js";
import type { WizardPrompter } from "../../src/wizard/prompts.js";
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: CoreConfig, dmPolicy: "pairing" | "allowlist" | "open" | "disabled") {
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.zalo?.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
} as CoreConfig;
}
function setZaloUpdateMode(
cfg: CoreConfig,
accountId: string,
mode: UpdateMode,
webhookUrl?: string,
webhookSecret?: string,
webhookPath?: string,
): CoreConfig {
const isDefault = accountId === DEFAULT_ACCOUNT_ID;
if (mode === "polling") {
if (isDefault) {
const {
webhookUrl: _url,
webhookSecret: _secret,
webhookPath: _path,
...rest
} = cfg.channels?.zalo ?? {};
return {
...cfg,
channels: {
...cfg.channels,
zalo: rest,
},
} as CoreConfig;
}
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
string,
Record<string, unknown>
>;
const existing = accounts[accountId] ?? {};
const {
webhookUrl: _url,
webhookSecret: _secret,
webhookPath: _path,
...rest
} = existing;
accounts[accountId] = rest;
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
accounts,
},
},
} as CoreConfig;
}
if (isDefault) {
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
webhookUrl,
webhookSecret,
webhookPath,
},
},
} as CoreConfig;
}
const accounts = { ...(cfg.channels?.zalo?.accounts ?? {}) } as Record<
string,
Record<string, unknown>
>;
accounts[accountId] = {
...(accounts[accountId] ?? {}),
webhookUrl,
webhookSecret,
webhookPath,
};
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
accounts,
},
},
} as CoreConfig;
}
async function noteZaloTokenHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Open Zalo Bot Platform: https://bot.zaloplatforms.com",
"2) Create a bot and get the token",
"3) Token looks like 12345689:abc-xyz",
"Tip: you can also set ZALO_BOT_TOKEN in your env.",
"Docs: https://docs.clawd.bot/channels/zalo",
].join("\n"),
"Zalo bot token",
);
}
async function promptZaloAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
const { cfg, prompter, accountId } = params;
const resolved = resolveZaloAccount({ cfg, accountId });
const existingAllowFrom = resolved.config.allowFrom ?? [];
const entry = await prompter.text({
message: "Zalo allowFrom (user id)",
placeholder: "123456789",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const raw = String(value ?? "").trim();
if (!raw) return "Required";
if (!/^\d+$/.test(raw)) return "Use a numeric Zalo user id";
return undefined;
},
});
const normalized = String(entry).trim();
const merged = [
...existingAllowFrom.map((item) => String(item).trim()).filter(Boolean),
normalized,
];
const unique = [...new Set(merged)];
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
} as CoreConfig;
}
return {
...cfg,
channels: {
...cfg.channels,
zalo: {
...cfg.channels?.zalo,
enabled: true,
accounts: {
...(cfg.channels?.zalo?.accounts ?? {}),
[accountId]: {
...(cfg.channels?.zalo?.accounts?.[accountId] ?? {}),
enabled: cfg.channels?.zalo?.accounts?.[accountId]?.enabled ?? true,
dmPolicy: "allowlist",
allowFrom: unique,
},
},
},
},
} as CoreConfig;
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "Zalo",
channel,
policyKey: "channels.zalo.dmPolicy",
allowFromKey: "channels.zalo.allowFrom",
getCurrent: (cfg) => (cfg.channels?.zalo?.dmPolicy ?? "pairing") as "pairing",
setPolicy: (cfg, policy) => setZaloDmPolicy(cfg as CoreConfig, policy),
};
export const zaloOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
dmPolicy,
getStatus: async ({ cfg }) => {
const configured = listZaloAccountIds(cfg as CoreConfig).some((accountId) =>
Boolean(resolveZaloAccount({ cfg: cfg as CoreConfig, accountId }).token),
);
return {
channel,
configured,
statusLines: [`Zalo: ${configured ? "configured" : "needs token"}`],
selectionHint: configured ? "recommended · configured" : "recommended · newcomer-friendly",
quickstartScore: configured ? 1 : 10,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds, forceAllowFrom }) => {
const zaloOverride = accountOverrides.zalo?.trim();
const defaultZaloAccountId = resolveDefaultZaloAccountId(cfg as CoreConfig);
let zaloAccountId = zaloOverride
? normalizeAccountId(zaloOverride)
: defaultZaloAccountId;
if (shouldPromptAccountIds && !zaloOverride) {
zaloAccountId = await promptAccountId({
cfg: cfg as CoreConfig,
prompter,
label: "Zalo",
currentId: zaloAccountId,
listAccountIds: listZaloAccountIds,
defaultAccountId: defaultZaloAccountId,
});
}
let next = cfg as CoreConfig;
const resolvedAccount = resolveZaloAccount({ cfg: next, accountId: zaloAccountId });
const accountConfigured = Boolean(resolvedAccount.token);
const allowEnv = zaloAccountId === DEFAULT_ACCOUNT_ID;
const canUseEnv = allowEnv && Boolean(process.env.ZALO_BOT_TOKEN?.trim());
const hasConfigToken = Boolean(
resolvedAccount.config.botToken || resolvedAccount.config.tokenFile,
);
let token: string | null = null;
if (!accountConfigured) {
await noteZaloTokenHelp(prompter);
}
if (canUseEnv && !resolvedAccount.config.botToken) {
const keepEnv = await prompter.confirm({
message: "ZALO_BOT_TOKEN detected. Use env var?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
zalo: {
...next.channels?.zalo,
enabled: true,
},
},
} as CoreConfig;
} else {
token = String(
await prompter.text({
message: "Enter Zalo bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (hasConfigToken) {
const keep = await prompter.confirm({
message: "Zalo token already configured. Keep it?",
initialValue: true,
});
if (!keep) {
token = String(
await prompter.text({
message: "Enter Zalo bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
token = String(
await prompter.text({
message: "Enter Zalo bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (token) {
if (zaloAccountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
zalo: {
...next.channels?.zalo,
enabled: true,
botToken: token,
},
},
} as CoreConfig;
} else {
next = {
...next,
channels: {
...next.channels,
zalo: {
...next.channels?.zalo,
enabled: true,
accounts: {
...(next.channels?.zalo?.accounts ?? {}),
[zaloAccountId]: {
...(next.channels?.zalo?.accounts?.[zaloAccountId] ?? {}),
enabled: true,
botToken: token,
},
},
},
},
} as CoreConfig;
}
}
const wantsWebhook = await prompter.confirm({
message: "Use webhook mode for Zalo?",
initialValue: false,
});
if (wantsWebhook) {
const webhookUrl = String(
await prompter.text({
message: "Webhook URL (https://...) ",
validate: (value) => (value?.trim()?.startsWith("https://") ? undefined : "HTTPS URL required"),
}),
).trim();
const defaultPath = (() => {
try {
return new URL(webhookUrl).pathname || "/zalo-webhook";
} catch {
return "/zalo-webhook";
}
})();
const webhookSecret = String(
await prompter.text({
message: "Webhook secret (8-256 chars)",
validate: (value) => {
const raw = String(value ?? "");
if (raw.length < 8 || raw.length > 256) return "8-256 chars";
return undefined;
},
}),
).trim();
const webhookPath = String(
await prompter.text({
message: "Webhook path (optional)",
initialValue: defaultPath,
}),
).trim();
next = setZaloUpdateMode(
next,
zaloAccountId,
"webhook",
webhookUrl,
webhookSecret,
webhookPath || undefined,
);
} else {
next = setZaloUpdateMode(next, zaloAccountId, "polling");
}
if (forceAllowFrom) {
next = await promptZaloAllowFrom({
cfg: next,
prompter,
accountId: zaloAccountId,
});
}
return { cfg: next, accountId: zaloAccountId };
},
};
+46
View File
@@ -0,0 +1,46 @@
import { getMe, ZaloApiError, type ZaloBotInfo, type ZaloFetch } from "./api.js";
export type ZaloProbeResult = {
ok: boolean;
bot?: ZaloBotInfo;
error?: string;
elapsedMs: number;
};
export async function probeZalo(
token: string,
timeoutMs = 5000,
fetcher?: ZaloFetch,
): Promise<ZaloProbeResult> {
if (!token?.trim()) {
return { ok: false, error: "No token provided", elapsedMs: 0 };
}
const startTime = Date.now();
try {
const response = await getMe(token.trim(), timeoutMs, fetcher);
const elapsedMs = Date.now() - startTime;
if (response.ok && response.result) {
return { ok: true, bot: response.result, elapsedMs };
}
return { ok: false, error: "Invalid response from Zalo API", elapsedMs };
} catch (err) {
const elapsedMs = Date.now() - startTime;
if (err instanceof ZaloApiError) {
return { ok: false, error: err.description ?? err.message, elapsedMs };
}
if (err instanceof Error) {
if (err.name === "AbortError") {
return { ok: false, error: `Request timed out after ${timeoutMs}ms`, elapsedMs };
}
return { ok: false, error: err.message, elapsedMs };
}
return { ok: false, error: String(err), elapsedMs };
}
}
+18
View File
@@ -0,0 +1,18 @@
import { ProxyAgent, fetch as undiciFetch } from "undici";
import type { Dispatcher } from "undici";
import type { ZaloFetch } from "./api.js";
const proxyCache = new Map<string, ZaloFetch>();
export function resolveZaloProxyFetch(proxyUrl?: string | null): ZaloFetch | undefined {
const trimmed = proxyUrl?.trim();
if (!trimmed) return undefined;
const cached = proxyCache.get(trimmed);
if (cached) return cached;
const agent = new ProxyAgent(trimmed);
const fetcher: ZaloFetch = (input, init) =>
undiciFetch(input, { ...(init ?? {}), dispatcher: agent as Dispatcher });
proxyCache.set(trimmed, fetcher);
return fetcher;
}
+116
View File
@@ -0,0 +1,116 @@
import type { CoreConfig } from "./types.js";
import type { ZaloFetch } from "./api.js";
import { sendMessage, sendPhoto } from "./api.js";
import { resolveZaloAccount } from "./accounts.js";
import { resolveZaloProxyFetch } from "./proxy.js";
import { resolveZaloToken } from "./token.js";
export type ZaloSendOptions = {
token?: string;
accountId?: string;
cfg?: CoreConfig;
mediaUrl?: string;
caption?: string;
verbose?: boolean;
proxy?: string;
};
export type ZaloSendResult = {
ok: boolean;
messageId?: string;
error?: string;
};
function resolveSendContext(options: ZaloSendOptions): {
token: string;
fetcher?: ZaloFetch;
} {
if (options.cfg) {
const account = resolveZaloAccount({
cfg: options.cfg,
accountId: options.accountId,
});
const token = options.token || account.token;
const proxy = options.proxy ?? account.config.proxy;
return { token, fetcher: resolveZaloProxyFetch(proxy) };
}
const token = options.token ?? resolveZaloToken(undefined, options.accountId).token;
const proxy = options.proxy;
return { token: token || process.env.ZALO_BOT_TOKEN?.trim() || "", fetcher: resolveZaloProxyFetch(proxy) };
}
export async function sendMessageZalo(
chatId: string,
text: string,
options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
const { token, fetcher } = resolveSendContext(options);
if (!token) {
return { ok: false, error: "No Zalo bot token configured" };
}
if (!chatId?.trim()) {
return { ok: false, error: "No chat_id provided" };
}
if (options.mediaUrl) {
return sendPhotoZalo(chatId, options.mediaUrl, {
...options,
token,
caption: text || options.caption,
});
}
try {
const response = await sendMessage(token, {
chat_id: chatId.trim(),
text: text.slice(0, 2000),
}, fetcher);
if (response.ok && response.result) {
return { ok: true, messageId: response.result.message_id };
}
return { ok: false, error: "Failed to send message" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
export async function sendPhotoZalo(
chatId: string,
photoUrl: string,
options: ZaloSendOptions = {},
): Promise<ZaloSendResult> {
const { token, fetcher } = resolveSendContext(options);
if (!token) {
return { ok: false, error: "No Zalo bot token configured" };
}
if (!chatId?.trim()) {
return { ok: false, error: "No chat_id provided" };
}
if (!photoUrl?.trim()) {
return { ok: false, error: "No photo URL provided" };
}
try {
const response = await sendPhoto(token, {
chat_id: chatId.trim(),
photo: photoUrl.trim(),
caption: options.caption?.slice(0, 2000),
}, fetcher);
if (response.ok && response.result) {
return { ok: true, messageId: response.result.message_id };
}
return { ok: false, error: "Failed to send photo" };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
+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 "../../../src/wizard/prompts.js";
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>`;
}
+50
View File
@@ -0,0 +1,50 @@
import type { ChannelAccountSnapshot, ChannelStatusIssue } from "../../src/channels/plugins/types.js";
type ZaloAccountStatus = {
accountId?: unknown;
enabled?: unknown;
configured?: unknown;
dmPolicy?: unknown;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
const asString = (value: unknown): string | undefined =>
typeof value === "string" ? value : typeof value === "number" ? String(value) : undefined;
function readZaloAccountStatus(value: ChannelAccountSnapshot): ZaloAccountStatus | null {
if (!isRecord(value)) return null;
return {
accountId: value.accountId,
enabled: value.enabled,
configured: value.configured,
dmPolicy: value.dmPolicy,
};
}
export function collectZaloStatusIssues(
accounts: ChannelAccountSnapshot[],
): ChannelStatusIssue[] {
const issues: ChannelStatusIssue[] = [];
for (const entry of accounts) {
const account = readZaloAccountStatus(entry);
if (!account) continue;
const accountId = asString(account.accountId) ?? "default";
const enabled = account.enabled !== false;
const configured = account.configured === true;
if (!enabled || !configured) continue;
if (account.dmPolicy === "open") {
issues.push({
channel: "zalo",
accountId,
kind: "config",
message:
'Zalo dmPolicy is "open", allowing any user to message the bot without pairing.',
fix: 'Set channels.zalo.dmPolicy to "pairing" or "allowlist" to restrict access.',
});
}
}
return issues;
}
+54
View File
@@ -0,0 +1,54 @@
import { readFileSync } from "node:fs";
import type { ZaloConfig } from "./types.js";
import { DEFAULT_ACCOUNT_ID } from "./shared/account-ids.js";
export type ZaloTokenResolution = {
token: string;
source: "env" | "config" | "configFile" | "none";
};
export function resolveZaloToken(
config: ZaloConfig | undefined,
accountId?: string | null,
): ZaloTokenResolution {
const resolvedAccountId = accountId ?? DEFAULT_ACCOUNT_ID;
const isDefaultAccount = resolvedAccountId === DEFAULT_ACCOUNT_ID;
const baseConfig = config;
const accountConfig =
resolvedAccountId !== DEFAULT_ACCOUNT_ID
? (baseConfig?.accounts?.[resolvedAccountId] as ZaloConfig | undefined)
: undefined;
if (accountConfig) {
const token = accountConfig.botToken?.trim();
if (token) return { token, source: "config" };
const tokenFile = accountConfig.tokenFile?.trim();
if (tokenFile) {
try {
const fileToken = readFileSync(tokenFile, "utf8").trim();
if (fileToken) return { token: fileToken, source: "configFile" };
} catch {
// ignore read failures
}
}
}
if (isDefaultAccount) {
const token = baseConfig?.botToken?.trim();
if (token) return { token, source: "config" };
const tokenFile = baseConfig?.tokenFile?.trim();
if (tokenFile) {
try {
const fileToken = readFileSync(tokenFile, "utf8").trim();
if (fileToken) return { token: fileToken, source: "configFile" };
} catch {
// ignore read failures
}
}
const envToken = process.env.ZALO_BOT_TOKEN?.trim();
if (envToken) return { token: envToken, source: "env" };
}
return { token: "", source: "none" };
}
+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,
};
}
+49
View File
@@ -0,0 +1,49 @@
export type ZaloAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** If false, do not start this Zalo account. Default: true. */
enabled?: boolean;
/** Bot token from Zalo Bot Creator. */
botToken?: string;
/** Path to file containing the bot token. */
tokenFile?: string;
/** Webhook URL for receiving updates (HTTPS required). */
webhookUrl?: string;
/** Webhook secret token (8-256 chars) for request verification. */
webhookSecret?: string;
/** Webhook path for the gateway HTTP server (defaults to webhook URL path). */
webhookPath?: string;
/** Direct message access policy (default: pairing). */
dmPolicy?: "pairing" | "allowlist" | "open" | "disabled";
/** Allowlist for DM senders (Zalo user IDs). */
allowFrom?: Array<string | number>;
/** Max inbound media size in MB. */
mediaMaxMb?: number;
/** Proxy URL for API requests. */
proxy?: string;
};
export type ZaloConfig = {
/** Optional per-account Zalo configuration (multi-account). */
accounts?: Record<string, ZaloAccountConfig>;
/** Default account ID when multiple accounts are configured. */
defaultAccount?: string;
} & ZaloAccountConfig;
export type ZaloTokenSource = "env" | "config" | "configFile" | "none";
export type ResolvedZaloAccount = {
accountId: string;
name?: string;
enabled: boolean;
token: string;
tokenSource: ZaloTokenSource;
config: ZaloAccountConfig;
};
export type CoreConfig = {
channels?: {
zalo?: ZaloConfig;
};
[key: string]: unknown;
};
+9 -4
View File
@@ -40,6 +40,8 @@
"dist/*.js",
"dist/*.json",
"docs/**",
"extensions/**",
"assets/**",
"skills/**",
"README.md",
"README-header.png",
@@ -135,10 +137,10 @@
"@grammyjs/runner": "^2.0.3",
"@grammyjs/transformer-throttler": "^1.2.1",
"@homebridge/ciao": "^1.3.4",
"@mariozechner/pi-agent-core": "^0.45.3",
"@mariozechner/pi-agent-core": "0.45.7",
"@mariozechner/pi-ai": "0.45.7",
"@mariozechner/pi-coding-agent": "^0.45.3",
"@mariozechner/pi-tui": "^0.45.3",
"@mariozechner/pi-coding-agent": "^0.45.7",
"@mariozechner/pi-tui": "^0.45.7",
"@microsoft/agents-hosting": "^1.1.1",
"@microsoft/agents-hosting-express": "^1.1.1",
"@microsoft/agents-hosting-extensions-teams": "^1.1.1",
@@ -159,6 +161,7 @@
"express": "^5.2.1",
"file-type": "^21.3.0",
"grammy": "^1.39.2",
"hono": "4.11.4",
"jiti": "^2.6.1",
"json5": "^2.2.3",
"long": "5.3.2",
@@ -207,9 +210,11 @@
"pnpm": {
"minimumReleaseAge": 2880,
"overrides": {
"@sinclair/typebox": "0.34.47"
"@sinclair/typebox": "0.34.47",
"hono": "4.11.4"
},
"patchedDependencies": {
"@mariozechner/pi-agent-core@0.45.7": "patches/@mariozechner__pi-agent-core.patch",
"@mariozechner/pi-ai@0.45.7": "patches/@mariozechner__pi-ai@0.45.7.patch"
}
},
+41 -29
View File
@@ -6,8 +6,12 @@ settings:
overrides:
'@sinclair/typebox': 0.34.47
hono: 4.11.4
patchedDependencies:
'@mariozechner/pi-agent-core@0.45.7':
hash: 01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4
path: patches/@mariozechner__pi-agent-core.patch
'@mariozechner/pi-ai@0.45.7':
hash: e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9
path: patches/@mariozechner__pi-ai@0.45.7.patch
@@ -18,7 +22,7 @@ importers:
dependencies:
'@buape/carbon':
specifier: 0.0.0-beta-20260110172854
version: 0.0.0-beta-20260110172854(hono@4.11.3)
version: 0.0.0-beta-20260110172854(hono@4.11.4)
'@clack/prompts':
specifier: ^0.11.0
version: 0.11.0
@@ -32,17 +36,17 @@ importers:
specifier: ^1.3.4
version: 1.3.4
'@mariozechner/pi-agent-core':
specifier: ^0.45.3
version: 0.45.3(ws@8.19.0)(zod@4.3.5)
specifier: 0.45.7
version: 0.45.7(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai':
specifier: 0.45.7
version: 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-coding-agent':
specifier: ^0.45.3
version: 0.45.3(ws@8.19.0)(zod@4.3.5)
specifier: ^0.45.7
version: 0.45.7(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui':
specifier: ^0.45.3
version: 0.45.3
specifier: ^0.45.7
version: 0.45.7
'@microsoft/agents-hosting':
specifier: ^1.1.1
version: 1.1.1
@@ -103,6 +107,9 @@ importers:
grammy:
specifier: ^1.39.2
version: 1.39.2
hono:
specifier: 4.11.4
version: 4.11.4
jiti:
specifier: ^2.6.1
version: 2.6.1
@@ -690,7 +697,7 @@ packages:
resolution: {integrity: sha512-Shz/KjlIeAhfiuE93NDKVdZ7HdBVLQAfdbaXEaoAVO3ic9ibRSLGIQGkcBbFyuLr+7/1D5ZCINM8B+6IvXeMtw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
hono: 4.11.4
'@huggingface/jinja@0.5.3':
resolution: {integrity: sha512-asqfZ4GQS0hD876Uw4qiUb7Tr/V5Q+JZuo2L+BtdrD4U40QU58nIRq3ZSgAzJgT874VLjhGVacaYfrdpXtEvtA==}
@@ -952,8 +959,8 @@ packages:
peerDependencies:
lit: ^3.3.1
'@mariozechner/pi-agent-core@0.45.3':
resolution: {integrity: sha512-yWgIKuhhCOxLdbNUcX5oNELdUSqkvnoYsXWOq6/IDiolvQMag4fWNsabeAd2OqiueH8k9sFx3G5cFR53PtHLEw==}
'@mariozechner/pi-agent-core@0.45.7':
resolution: {integrity: sha512-AwPwojEyJFBpmUIpgSfXinRh+hbd8TW0uZX2qNpRybyzbP0PnMp0tBthaG0Ao/wAIws4KLZQQEToQiDADpEUhg==}
engines: {node: '>=20.0.0'}
'@mariozechner/pi-ai@0.45.7':
@@ -961,13 +968,13 @@ packages:
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-coding-agent@0.45.3':
resolution: {integrity: sha512-Rt1SLmZ5Z2Ch3s/PjIJXP3VNCjbjm43T8q2cujMO/zRCGjgElcfO66ls2MoLoAxCGyfy/9FA1OrCoCrGsWkWcw==}
'@mariozechner/pi-coding-agent@0.45.7':
resolution: {integrity: sha512-+2TtZXIF6Iy4WxwpyeK9MXyYdfsuvIcRimotRDzhT7nM6TMLxJkDzHovqrcQF9/fAFd9wNHmRBA52PQIBicTSw==}
engines: {node: '>=20.0.0'}
hasBin: true
'@mariozechner/pi-tui@0.45.3':
resolution: {integrity: sha512-Z/ThVu1JHLQYDR1TgZdCXlaR9rimZ5wuYUO+dy773Cz0xFOg+E6YSfPDRu7t3TErLt59v/gnBw72cIdfIXoYpw==}
'@mariozechner/pi-tui@0.45.7':
resolution: {integrity: sha512-c4pMvyuC28PfpAYCsO01E651r+SAiNg216plmc0ej5qnizQm2x7X14C3UfxMfmWAMJ+U9Y0O7PucPdmXeHbjnw==}
engines: {node: '>=20.0.0'}
'@microsoft/agents-activity@1.1.1':
@@ -2703,8 +2710,8 @@ packages:
resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==}
engines: {node: '>=12.0.0'}
hono@4.11.3:
resolution: {integrity: sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==}
hono@4.11.4:
resolution: {integrity: sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA==}
engines: {node: '>=16.9.0'}
hookified@1.15.0:
@@ -4045,6 +4052,10 @@ packages:
jsdom:
optional: true
wasm-vips@0.0.16:
resolution: {integrity: sha512-4/bEq8noAFt7DX3VT+Vt5AgNtnnOLwvmrDbduWfiv9AV+VYkbUU4f9Dam9e6khRqPinyClFHCqiwATTTJEiGwA==}
engines: {node: '>=16.4.0'}
web-streams-polyfill@3.3.3:
resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==}
engines: {node: '>= 8'}
@@ -4639,14 +4650,14 @@ snapshots:
'@borewit/text-codec@0.2.1': {}
'@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.3)':
'@buape/carbon@0.0.0-beta-20260110172854(hono@4.11.4)':
dependencies:
'@types/node': 24.10.7
discord-api-types: 0.38.36
optionalDependencies:
'@cloudflare/workers-types': 4.20251205.0
'@discordjs/voice': 0.19.0
'@hono/node-server': 1.19.6(hono@4.11.3)
'@hono/node-server': 1.19.6(hono@4.11.4)
'@types/bun': 1.3.3
'@types/ws': 8.18.1
ws: 8.18.3
@@ -4842,9 +4853,9 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@hono/node-server@1.19.6(hono@4.11.3)':
'@hono/node-server@1.19.6(hono@4.11.4)':
dependencies:
hono: 4.11.3
hono: 4.11.4
optional: true
'@huggingface/jinja@0.5.3': {}
@@ -5065,10 +5076,10 @@ snapshots:
transitivePeerDependencies:
- tailwindcss
'@mariozechner/pi-agent-core@0.45.3(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-agent-core@0.45.7(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/pi-ai': 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.45.3
'@mariozechner/pi-tui': 0.45.7
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
@@ -5100,13 +5111,13 @@ snapshots:
- ws
- zod
'@mariozechner/pi-coding-agent@0.45.3(ws@8.19.0)(zod@4.3.5)':
'@mariozechner/pi-coding-agent@0.45.7(ws@8.19.0)(zod@4.3.5)':
dependencies:
'@mariozechner/clipboard': 0.3.0
'@mariozechner/jiti': 2.6.2
'@mariozechner/pi-agent-core': 0.45.3(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-agent-core': 0.45.7(patch_hash=01312ceb1f6be7e42822c24c9a7a4f7db56b24ae114a364855bd3819779d1cf4)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-ai': 0.45.7(patch_hash=e8b10ed06e0bcda571016bc4979ef2c5bf8a99ccade0201a3f74bc1914754fe9)(ws@8.19.0)(zod@4.3.5)
'@mariozechner/pi-tui': 0.45.3
'@mariozechner/pi-tui': 0.45.7
chalk: 5.6.2
cli-highlight: 2.1.11
diff: 8.0.3
@@ -5115,7 +5126,7 @@ snapshots:
marked: 15.0.12
minimatch: 10.1.1
proper-lockfile: 4.1.2
sharp: 0.34.5
wasm-vips: 0.0.16
transitivePeerDependencies:
- '@modelcontextprotocol/sdk'
- aws-crt
@@ -5125,7 +5136,7 @@ snapshots:
- ws
- zod
'@mariozechner/pi-tui@0.45.3':
'@mariozechner/pi-tui@0.45.7':
dependencies:
'@types/mime-types': 2.1.4
chalk: 5.6.2
@@ -7015,8 +7026,7 @@ snapshots:
highlight.js@11.11.1: {}
hono@4.11.3:
optional: true
hono@4.11.4: {}
hookified@1.15.0: {}
@@ -8432,6 +8442,8 @@ snapshots:
- tsx
- yaml
wasm-vips@0.0.16: {}
web-streams-polyfill@3.3.3: {}
webidl-conversions@3.0.1: {}
+1 -1
View File
@@ -10,4 +10,4 @@ onlyBuiltDependencies:
- sharp
patchedDependencies:
'@mariozechner/pi-ai@0.45.3': patches/@mariozechner__pi-ai@0.45.3.patch
'@mariozechner/pi-ai@0.45.7': patches/@mariozechner__pi-ai@0.45.7.patch
-30
View File
@@ -1,30 +0,0 @@
---
name: brave-search
description: Web search and content extraction via Brave Search API.
homepage: https://brave.com/search/api
metadata: {"clawdbot":{"emoji":"🦁","requires":{"bins":["node"],"env":["BRAVE_API_KEY"]},"primaryEnv":"BRAVE_API_KEY"}}
---
# Brave Search
Headless web search (and lightweight content extraction) using Brave Search API. No browser required.
## Search
```bash
node {baseDir}/scripts/search.mjs "query"
node {baseDir}/scripts/search.mjs "query" -n 10
node {baseDir}/scripts/search.mjs "query" --content
node {baseDir}/scripts/search.mjs "query" -n 3 --content
```
## Extract a page
```bash
node {baseDir}/scripts/content.mjs "https://example.com/article"
```
Notes:
- Needs `BRAVE_API_KEY`.
- Content extraction is best-effort (good for articles; not for app-like sites).
- If a site is blocked or too JS-heavy, prefer the `summarize` skill (it can use a Firecrawl fallback).
-53
View File
@@ -1,53 +0,0 @@
#!/usr/bin/env node
function usage() {
console.error(`Usage: content.mjs <url>`);
process.exit(2);
}
export async function fetchAsMarkdown(url) {
const resp = await fetch(url, {
headers: { "User-Agent": "clawdbot-brave-search/1.0" },
});
const html = await resp.text();
// Very lightweight “readability-ish” extraction without dependencies:
// - drop script/style/nav/footer
// - strip tags
// - keep paragraphs
const cleaned = html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<(nav|footer|header)[\s\S]*?<\/\1>/gi, " ")
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/p>/gi, "\n\n")
.replace(/<\/div>/gi, "\n")
.replace(/<[^>]+>/g, " ")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/\s+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.replace(/[ \t]{2,}/g, " ")
.trim();
if (!resp.ok) {
return `> Fetch failed (${resp.status}).\n\n${cleaned.slice(0, 2000)}\n`;
}
const paras = cleaned
.split("\n\n")
.map((p) => p.trim())
.filter(Boolean)
.slice(0, 30);
return paras.map((p) => `- ${p}`).join("\n") + "\n";
}
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") usage();
const url = args[0];
process.stdout.write(await fetchAsMarkdown(url));
-79
View File
@@ -1,79 +0,0 @@
#!/usr/bin/env node
function usage() {
console.error(`Usage: search.mjs "query" [-n 5] [--content]`);
process.exit(2);
}
const args = process.argv.slice(2);
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") usage();
const query = args[0];
let n = 5;
let withContent = false;
for (let i = 1; i < args.length; i++) {
const a = args[i];
if (a === "-n") {
n = Number.parseInt(args[i + 1] ?? "5", 10);
i++;
continue;
}
if (a === "--content") {
withContent = true;
continue;
}
console.error(`Unknown arg: ${a}`);
usage();
}
const apiKey = (process.env.BRAVE_API_KEY ?? "").trim();
if (!apiKey) {
console.error("Missing BRAVE_API_KEY");
process.exit(1);
}
const endpoint = new URL("https://api.search.brave.com/res/v1/web/search");
endpoint.searchParams.set("q", query);
endpoint.searchParams.set("count", String(Math.max(1, Math.min(n, 20))));
endpoint.searchParams.set("text_decorations", "false");
endpoint.searchParams.set("safesearch", "moderate");
const resp = await fetch(endpoint, {
headers: {
Accept: "application/json",
"X-Subscription-Token": apiKey,
},
});
if (!resp.ok) {
const text = await resp.text().catch(() => "");
throw new Error(`Brave Search failed (${resp.status}): ${text}`);
}
const data = await resp.json();
const results = (data?.web?.results ?? []).slice(0, n);
const lines = [];
for (const r of results) {
const title = String(r?.title ?? "").trim();
const url = String(r?.url ?? "").trim();
const desc = String(r?.description ?? "").trim();
if (!title || !url) continue;
lines.push(`- ${title}\n ${url}${desc ? `\n ${desc}` : ""}`);
}
process.stdout.write(lines.join("\n\n") + "\n");
if (!withContent) process.exit(0);
process.stdout.write("\n---\n\n");
for (const r of results) {
const title = String(r?.title ?? "").trim();
const url = String(r?.url ?? "").trim();
if (!url) continue;
process.stdout.write(`# ${title || url}\n${url}\n\n`);
const child = await import("./content.mjs");
const text = await child.fetchAsMarkdown(url);
process.stdout.write(text.trimEnd() + "\n\n");
}
+8 -1
View File
@@ -6,7 +6,12 @@ import { describe, expect, it, vi } from "vitest";
import { createClawdbotTools } from "./clawdbot-tools.js";
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: vi.fn(async () => ({ ok: true })),
callGatewayTool: vi.fn(async (method: string) => {
if (method === "config.get") {
return { hash: "hash-1" };
}
return { ok: true };
}),
}));
describe("gateway tool", () => {
@@ -71,11 +76,13 @@ describe("gateway tool", () => {
raw,
});
expect(callGatewayTool).toHaveBeenCalledWith("config.get", expect.any(Object), {});
expect(callGatewayTool).toHaveBeenCalledWith(
"config.apply",
expect.any(Object),
expect.objectContaining({
raw: raw.trim(),
baseHash: "hash-1",
sessionKey: "agent:main:whatsapp:dm:+15555550123",
}),
);
+11
View File
@@ -17,6 +17,7 @@ import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
import { createSessionsSendTool } from "./tools/sessions-send-tool.js";
import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js";
import { createWebFetchTool, createWebSearchTool } from "./tools/web-tools.js";
export function createClawdbotTools(options?: {
browserControlUrl?: string;
@@ -56,6 +57,14 @@ export function createClawdbotTools(options?: {
config: options?.config,
agentSessionKey: options?.agentSessionKey,
});
const webSearchTool = createWebSearchTool({
config: options?.config,
sandboxed: options?.sandboxed,
});
const webFetchTool = createWebFetchTool({
config: options?.config,
sandboxed: options?.sandboxed,
});
const tools: AnyAgentTool[] = [
createBrowserTool({
defaultControlUrl: options?.browserControlUrl,
@@ -103,6 +112,8 @@ export function createClawdbotTools(options?: {
config: options?.config,
}),
...(memorySearchTool && memoryGetTool ? [memorySearchTool, memoryGetTool] : []),
...(webSearchTool ? [webSearchTool] : []),
...(webFetchTool ? [webFetchTool] : []),
...(imageTool ? [imageTool] : []),
];
+12 -1
View File
@@ -1,3 +1,5 @@
import path from "node:path";
import { type Api, getEnvApiKey, type Model } from "@mariozechner/pi-ai";
import type { ClawdbotConfig } from "../config/config.js";
import type { ModelProviderConfig } from "../config/types.js";
@@ -8,6 +10,7 @@ import {
listProfilesForProvider,
resolveApiKeyForProfile,
resolveAuthProfileOrder,
resolveAuthStorePathForDisplay,
} from "./auth-profiles.js";
import { normalizeProviderId } from "./model-selection.js";
@@ -94,7 +97,15 @@ export async function resolveApiKeyForProvider(params: {
}
}
throw new Error(`No API key found for provider "${provider}".`);
const authStorePath = resolveAuthStorePathForDisplay(params.agentDir);
const resolvedAgentDir = path.dirname(authStorePath);
throw new Error(
[
`No API key found for provider "${provider}".`,
`Auth store: ${authStorePath} (agentDir: ${resolvedAgentDir}).`,
"Configure auth for this agent (clawdbot agents add <id>) or copy auth-profiles.json from the main agentDir.",
].join(" "),
);
}
export type EnvApiKeyResult = { apiKey: string; source: string };
+1
View File
@@ -26,6 +26,7 @@ export {
} from "./pi-embedded-helpers/errors.js";
export {
downgradeGeminiHistory,
downgradeGeminiThinkingBlocks,
isGoogleModelApi,
sanitizeGoogleTurnOrdering,
} from "./pi-embedded-helpers/google.js";
+53
View File
@@ -26,6 +26,59 @@ type GeminiToolCallBlock = {
input?: unknown;
};
type GeminiThinkingBlock = {
type?: unknown;
thinking?: unknown;
thinkingSignature?: unknown;
};
export function downgradeGeminiThinkingBlocks(messages: AgentMessage[]): AgentMessage[] {
const out: AgentMessage[] = [];
for (const msg of messages) {
if (!msg || typeof msg !== "object") {
out.push(msg);
continue;
}
const role = (msg as { role?: unknown }).role;
if (role !== "assistant") {
out.push(msg);
continue;
}
const assistantMsg = msg as Extract<AgentMessage, { role: "assistant" }>;
if (!Array.isArray(assistantMsg.content)) {
out.push(msg);
continue;
}
// Gemini rejects thinking blocks that lack a signature; downgrade to text for safety.
let hasDowngraded = false;
type AssistantContentBlock = (typeof assistantMsg.content)[number];
const nextContent = assistantMsg.content.flatMap((block): AssistantContentBlock[] => {
if (!block || typeof block !== "object") return [block as AssistantContentBlock];
const record = block as GeminiThinkingBlock;
if (record.type !== "thinking") return [block];
const signature =
typeof record.thinkingSignature === "string" ? record.thinkingSignature.trim() : "";
if (signature.length > 0) return [block];
const thinking = typeof record.thinking === "string" ? record.thinking : "";
const trimmed = thinking.trim();
hasDowngraded = true;
if (!trimmed) return [];
return [{ type: "text" as const, text: thinking }];
});
if (!hasDowngraded) {
out.push(msg);
continue;
}
if (nextContent.length === 0) {
continue;
}
out.push({ ...assistantMsg, content: nextContent } as AgentMessage);
}
return out;
}
export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[] {
const downgradedIds = new Set<string>();
const out: AgentMessage[] = [];
@@ -0,0 +1,205 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import { SessionManager } from "@mariozechner/pi-coding-agent";
import { describe, expect, it } from "vitest";
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
describe("sanitizeSessionHistory (google thinking)", () => {
it("downgrades thinking blocks without signatures for Google models", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
sessionManager,
sessionId: "session:google",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; text?: string }>;
};
expect(assistant.content?.map((block) => block.type)).toEqual(["text"]);
expect(assistant.content?.[0]?.text).toBe("reasoning");
});
it("keeps thinking blocks with signatures for Google models", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning", thinkingSignature: "sig" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
sessionManager,
sessionId: "session:google",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string; thinkingSignature?: string }>;
};
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
expect(assistant.content?.[0]?.thinking).toBe("reasoning");
expect(assistant.content?.[0]?.thinkingSignature).toBe("sig");
});
it("preserves order when downgrading mixed assistant content", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [
{ type: "text", text: "hello" },
{ type: "thinking", thinking: "internal note" },
{ type: "text", text: "world" },
],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
sessionManager,
sessionId: "session:google-mixed",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; text?: string }>;
};
expect(assistant.content?.map((block) => block.type)).toEqual(["text", "text", "text"]);
expect(assistant.content?.[1]?.text).toBe("internal note");
});
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [
{ type: "thinking", thinking: "signed", thinkingSignature: "sig" },
{ type: "thinking", thinking: "unsigned" },
],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
sessionManager,
sessionId: "session:google-mixed-signatures",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string; thinking?: string; text?: string }>;
};
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking", "text"]);
expect(assistant.content?.[0]?.thinking).toBe("signed");
expect(assistant.content?.[1]?.text).toBe("unsigned");
});
it("drops empty unsigned thinking blocks for Google models", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: " " }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "google-antigravity",
sessionManager,
sessionId: "session:google-empty",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant");
expect(assistant).toBeUndefined();
});
it("keeps thinking blocks for non-Google models", async () => {
const sessionManager = SessionManager.inMemory();
const input = [
{
role: "user",
content: "hi",
},
{
role: "assistant",
content: [{ type: "thinking", thinking: "reasoning" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "openai",
sessionManager,
sessionId: "session:openai",
});
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
content?: Array<{ type?: string }>;
};
expect(assistant.content?.map((block) => block.type)).toEqual(["thinking"]);
});
it("sanitizes tool call ids for OpenAI-compatible APIs", async () => {
const sessionManager = SessionManager.inMemory();
const longId = `call_${"a".repeat(60)}`;
const input = [
{
role: "assistant",
content: [{ type: "toolCall", id: longId, name: "read", arguments: {} }],
},
{
role: "toolResult",
toolCallId: longId,
toolName: "read",
content: [{ type: "text", text: "ok" }],
},
] satisfies AgentMessage[];
const out = await sanitizeSessionHistory({
messages: input,
modelApi: "openai-responses",
sessionManager,
sessionId: "session:openai",
});
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const toolCall = assistant.content?.[0] as { id?: string };
expect(toolCall.id).toBeDefined();
expect(toolCall.id?.length).toBeLessThanOrEqual(40);
const toolResult = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
expect(toolResult.toolCallId).toBe(toolCall.id);
});
});
+1
View File
@@ -113,6 +113,7 @@ export async function compactEmbeddedPiSession(params: {
const apiKeyInfo = await getApiKeyForModel({
model,
cfg: params.config,
agentDir,
});
if (model.provider === "github-copilot") {
+19 -4
View File
@@ -3,6 +3,7 @@ import type { SessionManager } from "@mariozechner/pi-coding-agent";
import { registerUnhandledRejectionHandler } from "../../infra/unhandled-rejections.js";
import {
downgradeGeminiThinkingBlocks,
downgradeGeminiHistory,
isCompactionFailureError,
isGoogleModelApi,
@@ -36,6 +37,17 @@ const GOOGLE_SCHEMA_UNSUPPORTED_KEYWORDS = new Set([
"minProperties",
"maxProperties",
]);
const OPENAI_TOOL_CALL_ID_APIS = new Set([
"openai",
"openai-completions",
"openai-responses",
"openai-codex-responses",
]);
function shouldSanitizeToolCallIds(modelApi?: string | null): boolean {
if (!modelApi) return false;
return isGoogleModelApi(modelApi) || OPENAI_TOOL_CALL_ID_APIS.has(modelApi);
}
function findUnsupportedSchemaKeywords(schema: unknown, path: string): string[] {
if (!schema || typeof schema !== "object") return [];
@@ -144,14 +156,17 @@ export async function sanitizeSessionHistory(params: {
sessionId: string;
}): Promise<AgentMessage[]> {
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
sanitizeToolCallIds: isGoogleModelApi(params.modelApi),
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
enforceToolCallLast: params.modelApi === "anthropic-messages",
});
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
const downgraded = isGoogleModelApi(params.modelApi)
? downgradeGeminiHistory(repairedTools)
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
const downgradedThinking = isGoogleModelApi(params.modelApi)
? downgradeGeminiThinkingBlocks(repairedTools)
: repairedTools;
const downgraded = isGoogleModelApi(params.modelApi)
? downgradeGeminiHistory(downgradedThinking)
: downgradedThinking;
return applyGoogleTurnOrderingFix({
messages: downgraded,
+1
View File
@@ -133,6 +133,7 @@ export async function runEmbeddedPiAgent(
cfg: params.config,
profileId: candidate,
store: authStore,
agentDir,
});
};
+56 -3
View File
@@ -129,6 +129,59 @@ describe("Agent-specific tool filtering", () => {
expect(toolNames).not.toContain("edit");
});
it("should apply provider-specific tool policy", () => {
const cfg: ClawdbotConfig = {
tools: {
allow: ["read", "write", "exec"],
byProvider: {
"google-antigravity": {
allow: ["read"],
},
},
},
};
const tools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir: "/tmp/test-provider",
agentDir: "/tmp/agent-provider",
modelProvider: "google-antigravity",
modelId: "claude-opus-4-5-thinking",
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toContain("read");
expect(toolNames).not.toContain("exec");
expect(toolNames).not.toContain("write");
expect(toolNames).not.toContain("apply_patch");
});
it("should apply provider-specific tool profile overrides", () => {
const cfg: ClawdbotConfig = {
tools: {
profile: "coding",
byProvider: {
"google-antigravity": {
profile: "minimal",
},
},
},
};
const tools = createClawdbotCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir: "/tmp/test-provider-profile",
agentDir: "/tmp/agent-provider-profile",
modelProvider: "google-antigravity",
modelId: "claude-opus-4-5-thinking",
});
const toolNames = tools.map((t) => t.name);
expect(toolNames).toEqual(["session_status"]);
});
it("should allow different tool policies for different agents", () => {
const cfg: ClawdbotConfig = {
agents: {
@@ -178,7 +231,7 @@ describe("Agent-specific tool filtering", () => {
expect(familyToolNames).not.toContain("apply_patch");
});
it("should prefer agent-specific tool policy over global", () => {
it("should apply global tool policy before agent-specific policy", () => {
const cfg: ClawdbotConfig = {
tools: {
deny: ["browser"], // Global deny
@@ -204,8 +257,8 @@ describe("Agent-specific tool filtering", () => {
});
const toolNames = tools.map((t) => t.name);
// Agent policy overrides global: browser is allowed again
expect(toolNames).toContain("browser");
// Global policy still applies; agent policy further restricts
expect(toolNames).not.toContain("browser");
expect(toolNames).not.toContain("exec");
expect(toolNames).not.toContain("process");
expect(toolNames).not.toContain("apply_patch");
+68 -5
View File
@@ -41,24 +41,87 @@ export function filterToolsByPolicy(tools: AnyAgentTool[], policy?: SandboxToolP
return tools.filter((tool) => isToolAllowedByPolicyName(tool.name, policy));
}
type ToolPolicyConfig = {
allow?: string[];
deny?: string[];
profile?: string;
};
function pickToolPolicy(config?: ToolPolicyConfig): SandboxToolPolicy | undefined {
if (!config) return undefined;
const allow = Array.isArray(config.allow) ? config.allow : undefined;
const deny = Array.isArray(config.deny) ? config.deny : undefined;
if (!allow && !deny) return undefined;
return { allow, deny };
}
function normalizeProviderKey(value: string): string {
return value.trim().toLowerCase();
}
function resolveProviderToolPolicy(params: {
byProvider?: Record<string, ToolPolicyConfig>;
modelProvider?: string;
modelId?: string;
}): ToolPolicyConfig | undefined {
const provider = params.modelProvider?.trim();
if (!provider || !params.byProvider) return undefined;
const entries = Object.entries(params.byProvider);
if (entries.length === 0) return undefined;
const lookup = new Map<string, ToolPolicyConfig>();
for (const [key, value] of entries) {
const normalized = normalizeProviderKey(key);
if (!normalized) continue;
lookup.set(normalized, value);
}
const normalizedProvider = normalizeProviderKey(provider);
const rawModelId = params.modelId?.trim().toLowerCase();
const fullModelId =
rawModelId && !rawModelId.includes("/") ? `${normalizedProvider}/${rawModelId}` : rawModelId;
const candidates = [...(fullModelId ? [fullModelId] : []), normalizedProvider];
for (const key of candidates) {
const match = lookup.get(key);
if (match) return match;
}
return undefined;
}
export function resolveEffectiveToolPolicy(params: {
config?: ClawdbotConfig;
sessionKey?: string;
modelProvider?: string;
modelId?: string;
}) {
const agentId = params.sessionKey ? resolveAgentIdFromSessionKey(params.sessionKey) : undefined;
const agentConfig =
params.config && agentId ? resolveAgentConfig(params.config, agentId) : undefined;
const agentTools = agentConfig?.tools;
const hasAgentToolPolicy =
Array.isArray(agentTools?.allow) ||
Array.isArray(agentTools?.deny) ||
typeof agentTools?.profile === "string";
const globalTools = params.config?.tools;
const profile = agentTools?.profile ?? globalTools?.profile;
const providerPolicy = resolveProviderToolPolicy({
byProvider: globalTools?.byProvider,
modelProvider: params.modelProvider,
modelId: params.modelId,
});
const agentProviderPolicy = resolveProviderToolPolicy({
byProvider: agentTools?.byProvider,
modelProvider: params.modelProvider,
modelId: params.modelId,
});
return {
agentId,
policy: hasAgentToolPolicy ? agentTools : globalTools,
globalPolicy: pickToolPolicy(globalTools),
globalProviderPolicy: pickToolPolicy(providerPolicy),
agentPolicy: pickToolPolicy(agentTools),
agentProviderPolicy: pickToolPolicy(agentProviderPolicy),
profile,
providerProfile: agentProviderPolicy?.profile ?? providerPolicy?.profile,
};
}
+30 -5
View File
@@ -111,13 +111,20 @@ export function createClawdbotCodingTools(options?: {
const sandbox = options?.sandbox?.enabled ? options.sandbox : undefined;
const {
agentId,
policy: effectiveToolsPolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
profile,
providerProfile,
} = resolveEffectiveToolPolicy({
config: options?.config,
sessionKey: options?.sessionKey,
modelProvider: options?.modelProvider,
modelId: options?.modelId,
});
const profilePolicy = resolveToolProfilePolicy(profile);
const providerProfilePolicy = resolveToolProfilePolicy(providerProfile);
const scopeKey = options?.exec?.scopeKey ?? (agentId ? `agent:${agentId}` : undefined);
const subagentPolicy =
isSubagentSessionKey(options?.sessionKey) && options?.sessionKey
@@ -125,7 +132,11 @@ export function createClawdbotCodingTools(options?: {
: undefined;
const allowBackground = isToolAllowedByPolicies("process", [
profilePolicy,
effectiveToolsPolicy,
providerProfilePolicy,
globalPolicy,
globalProviderPolicy,
agentPolicy,
agentProviderPolicy,
sandbox?.tools,
subagentPolicy,
]);
@@ -229,10 +240,24 @@ export function createClawdbotCodingTools(options?: {
}),
];
const toolsFiltered = profilePolicy ? filterToolsByPolicy(tools, profilePolicy) : tools;
const policyFiltered = effectiveToolsPolicy
? filterToolsByPolicy(toolsFiltered, effectiveToolsPolicy)
const providerProfileFiltered = providerProfilePolicy
? filterToolsByPolicy(toolsFiltered, providerProfilePolicy)
: toolsFiltered;
const sandboxed = sandbox ? filterToolsByPolicy(policyFiltered, sandbox.tools) : policyFiltered;
const globalFiltered = globalPolicy
? filterToolsByPolicy(providerProfileFiltered, globalPolicy)
: providerProfileFiltered;
const globalProviderFiltered = globalProviderPolicy
? filterToolsByPolicy(globalFiltered, globalProviderPolicy)
: globalFiltered;
const agentFiltered = agentPolicy
? filterToolsByPolicy(globalProviderFiltered, agentPolicy)
: globalProviderFiltered;
const agentProviderFiltered = agentProviderPolicy
? filterToolsByPolicy(agentFiltered, agentProviderPolicy)
: agentFiltered;
const sandboxed = sandbox
? filterToolsByPolicy(agentProviderFiltered, sandbox.tools)
: agentProviderFiltered;
const subagentFiltered = subagentPolicy
? filterToolsByPolicy(sandboxed, subagentPolicy)
: sandboxed;
+2 -2
View File
@@ -2,7 +2,7 @@
// This module scrubs/normalizes tool schemas to keep Gemini happy.
// Keywords that Cloud Code Assist API rejects (not compliant with their JSON Schema subset)
const UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
export const GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS = new Set([
"patternProperties",
"additionalProperties",
"$schema",
@@ -254,7 +254,7 @@ function cleanSchemaForGeminiWithDefs(
const cleaned: Record<string, unknown> = {};
for (const [key, value] of Object.entries(obj)) {
if (UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) continue;
if (GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS.has(key)) continue;
if (key === "const") {
cleaned.enum = [value];
+103
View File
@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const sendSpy = vi.fn(async () => ({}));
vi.mock("../gateway/call.js", () => ({
callGateway: vi.fn(async (req: unknown) => {
const typed = req as { method?: string; params?: { message?: string } };
if (typed.method === "send") {
return await sendSpy(typed);
}
if (typed.method === "agent.wait") {
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
}
if (typed.method === "sessions.patch") return {};
if (typed.method === "sessions.delete") return {};
return {};
}),
}));
vi.mock("./tools/agent-step.js", () => ({
runAgentStep: vi.fn(async () => "did some stuff"),
readLatestAssistantReply: vi.fn(async () => "raw subagent reply"),
}));
vi.mock("./tools/sessions-announce-target.js", () => ({
resolveAnnounceTarget: vi.fn(async () => ({
provider: "telegram",
to: "+15550001111",
accountId: "default",
})),
}));
vi.mock("./tools/sessions-send-helpers.js", () => ({
isAnnounceSkip: () => false,
}));
vi.mock("../config/sessions.js", () => ({
loadSessionStore: vi.fn(async () => ({ entries: {} })),
resolveAgentIdFromSessionKey: () => "main",
resolveStorePath: () => "/tmp/sessions.json",
}));
vi.mock("../config/config.js", () => ({
loadConfig: () => ({
session: { mainKey: "agent:main:main" },
}),
}));
describe("subagent announce formatting", () => {
beforeEach(() => {
sendSpy.mockClear();
});
it("wraps unstructured announce into Status/Result/Notes", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-123",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: true,
startedAt: 10,
endedAt: 20,
});
expect(sendSpy).toHaveBeenCalled();
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
expect(msg).toContain("Status:");
expect(msg).toContain("Status: error");
expect(msg).toContain("Result:");
expect(msg).toContain("Notes:");
expect(msg).toContain("boom");
});
it("keeps runtime status even when announce reply is structured", async () => {
const agentStep = await import("./tools/agent-step.js");
vi.mocked(agentStep.runAgentStep).mockResolvedValueOnce(
"- **Status:** success\n\n- **Result:** did some stuff\n\n- **Notes:** all good",
);
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-456",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: true,
startedAt: 10,
endedAt: 20,
});
const msg = sendSpy.mock.calls[0]?.[0]?.params?.message as string;
expect(msg).toContain("Status: error");
expect(msg).toContain("Result:");
expect(msg).toContain("Notes:");
});
});
+116 -3
View File
@@ -183,6 +183,85 @@ export function buildSubagentSystemPrompt(params: {
return lines.join("\n");
}
export type SubagentRunOutcome = {
status: "ok" | "error" | "timeout" | "unknown";
error?: string;
};
const ANNOUNCE_SECTION_RE = /^\s*[-*]?\s*(?:\*\*)?(status|result|notes)(?:\*\*)?\s*:\s*(.*)$/i;
function parseAnnounceSections(announce: string) {
const sections = {
status: [] as string[],
result: [] as string[],
notes: [] as string[],
};
let current: keyof typeof sections | null = null;
let sawSection = false;
for (const line of announce.split(/\r?\n/)) {
const match = line.match(ANNOUNCE_SECTION_RE);
if (match) {
const key = match[1]?.toLowerCase() as keyof typeof sections;
current = key;
sawSection = true;
const rest = match[2]?.trim();
if (rest) sections[key].push(rest);
continue;
}
if (current) sections[current].push(line);
}
const normalize = (lines: string[]) => {
const joined = lines.join("\n").trim();
return joined.length > 0 ? joined : undefined;
};
return {
sawSection,
status: normalize(sections.status),
result: normalize(sections.result),
notes: normalize(sections.notes),
};
}
function normalizeAnnounceBody(params: {
outcome: SubagentRunOutcome;
announceReply: string;
statsLine?: string;
}) {
const announce = params.announceReply.trim();
const statsLine = params.statsLine?.trim();
const statusLabel =
params.outcome.status === "ok"
? "success"
: params.outcome.status === "timeout"
? "timeout"
: params.outcome.status === "unknown"
? "unknown"
: "error";
const parsed = parseAnnounceSections(announce);
const resultText = parsed.result ?? (announce || "(not available)");
const notesParts: string[] = [];
if (parsed.notes) notesParts.push(parsed.notes);
if (params.outcome.error) notesParts.push(`- Error: ${params.outcome.error}`);
const notesBlock = notesParts.length ? notesParts.join("\n") : "- (none)";
const message = [
`Status: ${statusLabel}`,
"",
"Result:",
resultText,
"",
"Notes:",
notesBlock,
].join("\n");
return statsLine ? `${message}\n\n${statsLine}` : message;
}
function buildSubagentAnnouncePrompt(params: {
requesterSessionKey?: string;
requesterChannel?: string;
@@ -202,6 +281,10 @@ function buildSubagentAnnouncePrompt(params: {
"",
"**You MUST announce your result.** The requester is waiting for your response.",
"Provide a brief, useful summary of what you accomplished.",
"Reply with Result and Notes only (no Status line; status is added by the system).",
"Format:",
"Result: <summary>",
"Notes: <extra context>",
'Only reply "ANNOUNCE_SKIP" if the task completely failed with no useful output.',
"Your reply will be posted to the requester chat.",
].filter(Boolean);
@@ -222,10 +305,12 @@ export async function runSubagentAnnounceFlow(params: {
startedAt?: number;
endedAt?: number;
label?: string;
outcome?: SubagentRunOutcome;
}): Promise<boolean> {
let didAnnounce = false;
try {
let reply = params.roundOneReply;
let outcome: SubagentRunOutcome | undefined = params.outcome;
if (!reply && params.waitForCompletion !== false) {
const waitMs = Math.min(params.timeoutMs, 60_000);
const wait = (await callGateway({
@@ -235,8 +320,30 @@ export async function runSubagentAnnounceFlow(params: {
timeoutMs: waitMs,
},
timeoutMs: waitMs + 2000,
})) as { status?: string };
if (wait?.status !== "ok") return false;
})) as {
status?: string;
error?: string;
startedAt?: number;
endedAt?: number;
};
if (wait?.status === "timeout") {
outcome = { status: "timeout" };
} else if (wait?.status === "error") {
outcome = { status: "error", error: wait.error };
} else if (wait?.status === "ok") {
outcome = { status: "ok" };
}
if (typeof wait?.startedAt === "number" && !params.startedAt) {
params.startedAt = wait.startedAt;
}
if (typeof wait?.endedAt === "number" && !params.endedAt) {
params.endedAt = wait.endedAt;
}
if (wait?.status === "timeout") {
// No lifecycle end seen before timeout. Still attempt an announce so
// requesters are not left hanging.
if (!outcome) outcome = { status: "timeout" };
}
reply = await readLatestAssistantReply({
sessionKey: params.childSessionKey,
});
@@ -248,6 +355,8 @@ export async function runSubagentAnnounceFlow(params: {
});
}
if (!outcome) outcome = { status: "unknown" };
const announceTarget = await resolveAnnounceTarget({
sessionKey: params.requesterSessionKey,
displayKey: params.requesterDisplayKey,
@@ -278,7 +387,11 @@ export async function runSubagentAnnounceFlow(params: {
startedAt: params.startedAt,
endedAt: params.endedAt,
});
const message = statsLine ? `${announceReply.trim()}\n\n${statsLine}` : announceReply.trim();
const message = normalizeAnnounceBody({
outcome,
announceReply,
statsLine,
});
await callGateway({
method: "send",
+15 -2
View File
@@ -1,7 +1,7 @@
import { loadConfig } from "../config/config.js";
import { callGateway } from "../gateway/call.js";
import { onAgentEvent } from "../infra/agent-events.js";
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
import { runSubagentAnnounceFlow, type SubagentRunOutcome } from "./subagent-announce.js";
import {
loadSubagentRegistryFromDisk,
saveSubagentRegistryToDisk,
@@ -20,6 +20,7 @@ export type SubagentRunRecord = {
createdAt: number;
startedAt?: number;
endedAt?: number;
outcome?: SubagentRunOutcome;
archiveAtMs?: number;
announceCompletedAt?: number;
announceHandled: boolean;
@@ -62,6 +63,7 @@ function resumeSubagentRun(runId: string) {
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
});
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
@@ -176,6 +178,12 @@ function ensureListener() {
const endedAt =
typeof evt.data?.endedAt === "number" ? (evt.data.endedAt as number) : Date.now();
entry.endedAt = endedAt;
if (phase === "error") {
const error = typeof evt.data?.error === "string" ? (evt.data.error as string) : undefined;
entry.outcome = { status: "error", error };
} else {
entry.outcome = { status: "ok" };
}
persistSubagentRuns();
if (!beginSubagentAnnounce(evt.runId)) {
@@ -194,6 +202,7 @@ function ensureListener() {
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
});
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(evt.runId, entry.cleanup, didAnnounce);
@@ -272,7 +281,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
timeoutMs,
},
timeoutMs: timeoutMs + 10_000,
})) as { status?: string; startedAt?: number; endedAt?: number };
})) as { status?: string; startedAt?: number; endedAt?: number; error?: string };
if (wait?.status !== "ok" && wait?.status !== "error") return;
const entry = subagentRuns.get(runId);
if (!entry) return;
@@ -289,6 +298,9 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
entry.endedAt = Date.now();
mutated = true;
}
entry.outcome =
wait.status === "error" ? { status: "error", error: wait.error } : { status: "ok" };
mutated = true;
if (mutated) persistSubagentRuns();
if (!beginSubagentAnnounce(runId)) return;
const announce = runSubagentAnnounceFlow({
@@ -304,6 +316,7 @@ async function waitForSubagentCompletion(runId: string, waitTimeoutMs: number) {
startedAt: entry.startedAt,
endedAt: entry.endedAt,
label: entry.label,
outcome: entry.outcome,
});
void announce.then((didAnnounce) => {
finalizeSubagentAnnounce(runId, entry.cleanup, didAnnounce);
+4
View File
@@ -54,6 +54,8 @@ export function buildAgentSystemPrompt(params: {
ls: "List directory contents",
exec: "Run shell commands",
process: "Manage background exec sessions",
web_search: "Search the web (Brave API)",
web_fetch: "Fetch and extract readable content from a URL",
// Channel docking: add login tools here when a channel needs interactive linking.
browser: "Control web browser",
canvas: "Present/eval/snapshot the Canvas",
@@ -81,6 +83,8 @@ export function buildAgentSystemPrompt(params: {
"ls",
"exec",
"process",
"web_search",
"web_fetch",
"browser",
"canvas",
"nodes",
+44
View File
@@ -65,4 +65,48 @@ describe("sanitizeToolCallIdsForCloudCodeAssist", () => {
expect(r1.toolCallId).toBe(a.id);
expect(r2.toolCallId).toBe(b.id);
});
it("caps tool call IDs at 40 chars while preserving uniqueness", () => {
const longA = `call_${"a".repeat(60)}`;
const longB = `call_${"a".repeat(59)}b`;
const input = [
{
role: "assistant",
content: [
{ type: "toolCall", id: longA, name: "read", arguments: {} },
{ type: "toolCall", id: longB, name: "read", arguments: {} },
],
},
{
role: "toolResult",
toolCallId: longA,
toolName: "read",
content: [{ type: "text", text: "one" }],
},
{
role: "toolResult",
toolCallId: longB,
toolName: "read",
content: [{ type: "text", text: "two" }],
},
] satisfies AgentMessage[];
const out = sanitizeToolCallIdsForCloudCodeAssist(input);
const assistant = out[0] as Extract<AgentMessage, { role: "assistant" }>;
const a = assistant.content?.[0] as { id?: string };
const b = assistant.content?.[1] as { id?: string };
expect(typeof a.id).toBe("string");
expect(typeof b.id).toBe("string");
expect(a.id).not.toBe(b.id);
expect(a.id?.length).toBeLessThanOrEqual(40);
expect(b.id?.length).toBeLessThanOrEqual(40);
expect(isValidCloudCodeAssistToolId(a.id as string)).toBe(true);
expect(isValidCloudCodeAssistToolId(b.id as string)).toBe(true);
const r1 = out[1] as Extract<AgentMessage, { role: "toolResult" }>;
const r2 = out[2] as Extract<AgentMessage, { role: "toolResult" }>;
expect(r1.toolCallId).toBe(a.id);
expect(r2.toolCallId).toBe(b.id);
});
});
+8 -4
View File
@@ -24,21 +24,25 @@ function shortHash(text: string): string {
}
function makeUniqueToolId(params: { id: string; used: Set<string> }): string {
const base = sanitizeToolCallId(params.id);
const MAX_LEN = 40;
const base = sanitizeToolCallId(params.id).slice(0, MAX_LEN);
if (!params.used.has(base)) return base;
const hash = shortHash(params.id);
const maxBaseLen = 64 - 1 - hash.length;
const maxBaseLen = MAX_LEN - 1 - hash.length;
const clippedBase = base.length > maxBaseLen ? base.slice(0, maxBaseLen) : base;
const candidate = `${clippedBase}_${hash}`;
if (!params.used.has(candidate)) return candidate;
for (let i = 2; i < 1000; i += 1) {
const next = `${candidate}_${i}`;
const suffix = `_${i}`;
const next = `${candidate.slice(0, MAX_LEN - suffix.length)}${suffix}`;
if (!params.used.has(next)) return next;
}
return `${candidate}_${Date.now()}`;
const ts = `_${Date.now()}`;
return `${candidate.slice(0, MAX_LEN - ts.length)}${ts}`;
}
function rewriteAssistantToolCallIds(params: {
+10
View File
@@ -277,6 +277,16 @@
"title": "Memory Get",
"detailKeys": ["path", "from", "lines"]
},
"web_search": {
"emoji": "🔎",
"title": "Web Search",
"detailKeys": ["query", "count"]
},
"web_fetch": {
"emoji": "📄",
"title": "Web Fetch",
"detailKeys": ["url", "extractMode", "maxChars"]
},
"whatsapp_login": {
"emoji": "🟢",
"title": "WhatsApp Login",
Loaded 100 of 212 files, more files were not shown because too many files have changed in this diff. Show more