Compare commits

..
172 changed files with 7882 additions and 10563 deletions

No files matched your search

-4
View File
@@ -7,10 +7,6 @@
[exclude-files] [exclude-files]
# pnpm lockfiles contain lots of high-entropy package integrity blobs. # pnpm lockfiles contain lots of high-entropy package integrity blobs.
pattern = (^|/)pnpm-lock\.yaml$ pattern = (^|/)pnpm-lock\.yaml$
# Generated output and vendored assets.
pattern = (^|/)(dist|vendor)/
# Local config file with allowlist patterns.
pattern = (^|/)\.detect-secrets\.cfg$
[exclude-lines] [exclude-lines]
# Fastlane checks for private key marker; not a real key. # Fastlane checks for private key marker; not a real key.
-17
View File
@@ -1,17 +0,0 @@
# actionlint configuration
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
self-hosted-runner:
labels:
# Blacksmith CI runners
- blacksmith-4vcpu-ubuntu-2404
- blacksmith-4vcpu-windows-2025
# Ignore patterns for known issues
paths:
.github/workflows/**/*.yml:
ignore:
# Ignore shellcheck warnings (we run shellcheck separately)
- 'shellcheck reported issue.+'
# Ignore intentional if: false for disabled jobs
- 'constant expression "false" in condition'
-113
View File
@@ -1,113 +0,0 @@
# Dependabot configuration
# https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
registries:
npm-npmjs:
type: npm-registry
url: https://registry.npmjs.org
replaces-base: true
updates:
# npm dependencies (root)
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
production:
dependency-type: production
update-types:
- minor
- patch
development:
dependency-type: development
update-types:
- minor
- patch
open-pull-requests-limit: 10
registries:
- npm-npmjs
# GitHub Actions
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
actions:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - macOS app
- package-ecosystem: swift
directory: /apps/macos
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - shared ClawdbotKit
- package-ecosystem: swift
directory: /apps/shared/ClawdbotKit
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Swift Package Manager - Swabble
- package-ecosystem: swift
directory: /Swabble
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
swift-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
# Gradle - Android app
- package-ecosystem: gradle
directory: /apps/android
schedule:
interval: weekly
cooldown:
default-days: 7
groups:
android-deps:
patterns:
- "*"
update-types:
- minor
- patch
open-pull-requests-limit: 5
-105
View File
@@ -1,105 +0,0 @@
# Pre-commit hooks for clawdbot
# Install: prek install
# Run manually: prek run --all-files
#
# See https://pre-commit.com for more information
repos:
# Basic file hygiene
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v6.0.0
hooks:
- id: trailing-whitespace
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
- id: end-of-file-fixer
exclude: '^(docs/|dist/|vendor/|.*\.snap$)'
- id: check-yaml
args: [--allow-multiple-documents]
- id: check-added-large-files
args: [--maxkb=500]
- id: check-merge-conflict
# Secret detection (same as CI)
- repo: https://github.com/Yelp/detect-secrets
rev: v1.5.0
hooks:
- id: detect-secrets
args:
- --baseline
- .secrets.baseline
- --exclude-files
- '(^|/)(dist/|vendor/|pnpm-lock\.yaml$|\.detect-secrets\.cfg$)'
- --exclude-lines
- 'key_content\.include\?\("BEGIN PRIVATE KEY"\)'
- --exclude-lines
- 'case \.apiKeyEnv: "API key \(env var\)"'
- --exclude-lines
- 'case apikey = "apiKey"'
- --exclude-lines
- '"gateway\.remote\.password"'
- --exclude-lines
- '"gateway\.auth\.password"'
- --exclude-lines
- '"talk\.apiKey"'
- --exclude-lines
- '=== "string"'
- --exclude-lines
- 'typeof remote\?\.password === "string"'
# Shell script linting
- repo: https://github.com/koalaman/shellcheck-precommit
rev: v0.11.0
hooks:
- id: shellcheck
args: [--severity=error] # Only fail on errors, not warnings/info
# Exclude vendor and scripts with embedded code or known issues
exclude: '^(vendor/|scripts/e2e/)'
# GitHub Actions linting
- repo: https://github.com/rhysd/actionlint
rev: v1.7.10
hooks:
- id: actionlint
# GitHub Actions security audit
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.22.0
hooks:
- id: zizmor
args: [--persona=regular, --min-severity=medium, --min-confidence=medium]
exclude: '^(vendor/|Swabble/)'
# Project checks (same commands as CI)
- repo: local
hooks:
# oxlint --type-aware src test
- id: oxlint
name: oxlint
entry: scripts/pre-commit/run-node-tool.sh oxlint --type-aware src test
language: system
pass_filenames: false
types_or: [javascript, jsx, ts, tsx]
# oxfmt --check src test
- id: oxfmt
name: oxfmt
entry: scripts/pre-commit/run-node-tool.sh oxfmt --check src test
language: system
pass_filenames: false
types_or: [javascript, jsx, ts, tsx]
# swiftlint (same as CI)
- id: swiftlint
name: swiftlint
entry: swiftlint --config .swiftlint.yml
language: system
pass_filenames: false
types: [swift]
# swiftformat --lint (same as CI)
- id: swiftformat
name: swiftformat
entry: swiftformat --lint apps/macos/Sources --config .swiftformat
language: system
pass_filenames: false
types: [swift]
+123 -1796
View File
File diff suppressed because it is too large. Load diff
-25
View File
@@ -1,25 +0,0 @@
# ShellCheck configuration
# https://www.shellcheck.net/wiki/
# Disable common false positives and style suggestions
# SC2034: Variable appears unused (often exported or used indirectly)
disable=SC2034
# SC2155: Declare and assign separately (common idiom, rarely causes issues)
disable=SC2155
# SC2295: Expansions inside ${..} need quoting (info-level, rarely causes issues)
disable=SC2295
# SC1012: \r is literal (tr -d '\r' works as intended on most systems)
disable=SC1012
# SC2026: Word outside quotes (info-level, often intentional)
disable=SC2026
# SC2016: Expressions don't expand in single quotes (often intentional in sed/awk)
disable=SC2016
# SC2129: Consider using { cmd1; cmd2; } >> file (style preference)
disable=SC2129
-1
View File
@@ -37,7 +37,6 @@
## Build, Test, and Development Commands ## Build, Test, and Development Commands
- Runtime baseline: Node **22+** (keep Node + Bun paths working). - Runtime baseline: Node **22+** (keep Node + Bun paths working).
- Install deps: `pnpm install` - Install deps: `pnpm install`
- Pre-commit hooks: `prek install` (runs same checks as CI)
- Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches). - Also supported: `bun install` (keep `pnpm-lock.yaml` + Bun patching in sync when touching deps/patches).
- Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`. - Prefer Bun for TypeScript execution (scripts, dev, tests): `bun <file.ts>` / `bunx <tool>`.
- Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`. - Run CLI in dev: `pnpm clawdbot ...` (bun) or `pnpm dev`.
-13
View File
@@ -12,43 +12,30 @@ Docs: https://docs.clawd.bot
- TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts - TTS: add Edge TTS provider fallback, defaulting to keyless Edge with MP3 retry on format failures. (#1668) Thanks @steipete. https://docs.clawd.bot/tts
- Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web - Web search: add Brave freshness filter parameter for time-scoped results. (#1688) Thanks @JonUleis. https://docs.clawd.bot/tools/web
- TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts - TTS: add auto mode enum (off/always/inbound/tagged) with per-session `/tts` override. (#1667) Thanks @sebslight. https://docs.clawd.bot/tts
- Dev: add prek pre-commit hooks + dependabot config for weekly updates. (#1720) Thanks @dguido.
- Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround). - Docs: expand FAQ (migration, scheduling, concurrency, model recommendations, OpenAI subscription auth, Pi sizing, hackable install, docs SSL workaround).
- Docs: add verbose installer troubleshooting guidance. - Docs: add verbose installer troubleshooting guidance.
- Docs: add macOS VM guide with local/hosted options + VPS/nodes guidance. (#1693) Thanks @f-trycua.
- Docs: update Fly.io guide notes. - Docs: update Fly.io guide notes.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock - Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands - Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg. - Gateway: expose config.patch in the gateway tool with safe partial updates + restart sentinel. (#1653) Thanks @Glucksberg.
- Telegram: add `channels.telegram.linkPreview` to toggle outbound link previews. (#1700) Thanks @zerone0x. https://docs.clawd.bot/channels/telegram
- Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal. - Telegram: treat DM topics as separate sessions and keep DM history limits stable with thread suffixes. (#1597) Thanks @rohannagpal.
- Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal. - Telegram: add verbose raw-update logging for inbound Telegram updates. (#1597) Thanks @rohannagpal.
- Diagnostics: add diagnostic flags for targeted debug logs (config + env override). https://docs.clawd.bot/diagnostics/flags
### Fixes ### Fixes
- macOS: rearm gateway receive loop before push handling to avoid node invoke stalls. (#1752) Thanks @ngutman.
- Gateway: include inline config env vars in service install environments. (#1735) Thanks @Seredeep.
- BlueBubbles: route phone-number targets to DMs, avoid leaking routing IDs, and auto-create missing DMs (Private API required). (#1751) Thanks @tyler6204. https://docs.clawd.bot/channels/bluebubbles
- BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing. - BlueBubbles: keep part-index GUIDs in reply tags when short IDs are missing.
- Web UI: hide internal `message_id` hints in chat bubbles. - Web UI: hide internal `message_id` hints in chat bubbles.
- Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent. - Web UI: show Stop button during active runs, swap back to New session when idle. (#1664) Thanks @ndbroadbent.
- Web UI: clear stale disconnect banners on reconnect; allow form saves with unsupported schema paths but block missing schema. (#1707) Thanks @Glucksberg.
- Heartbeat: normalize target identifiers for consistent routing. - Heartbeat: normalize target identifiers for consistent routing.
- TUI: reload history after gateway reconnect to restore session state. (#1663) - TUI: reload history after gateway reconnect to restore session state. (#1663)
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639) - Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
- Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338. - Telegram: set fetch duplex="half" for uploads on Node 22 to avoid sendPhoto failures. (#1684) Thanks @commdata2338.
- Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev. - Signal: repair reaction sends (group/UUID targets + CLI author flags). (#1651) Thanks @vilkasdev.
- Signal: add configurable signal-cli startup timeout + external daemon mode docs. (#1677) https://docs.clawd.bot/channels/signal
- Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco. - Exec: keep approvals for elevated ask unless full mode. (#1616) Thanks @ivancasco.
- Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz. - Agents: auto-compact on context overflow prompt errors before failing. (#1627) Thanks @rodrigouroz.
- Agents: use the active auth profile for auto-compaction recovery. - Agents: use the active auth profile for auto-compaction recovery.
- Models: default missing custom provider fields so minimal configs are accepted. - Models: default missing custom provider fields so minimal configs are accepted.
- Media understanding: skip image understanding when the primary model already supports vision. (#1747) Thanks @tyler6204.
- Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671) - Gateway: skip Tailscale DNS probing when tailscale.mode is off. (#1671)
- Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b. - Gateway: reduce log noise for late invokes + remote node probes; debounce skills refresh. (#1607) Thanks @petter-b.
- Gateway: clarify Control UI/WebChat auth error hints for missing tokens. (#1690)
- Gateway: listen on IPv6 loopback when bound to 127.0.0.1 so localhost webhooks work.
- Gateway: store lock files in the temp directory to avoid stale locks on persistent volumes. (#1676)
- macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman. - macOS: default direct-transport `ws://` URLs to port 18789; document `gateway.remote.transport`. (#1603) Thanks @ngutman.
- Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634) - Voice Call: return stream TwiML for outbound conversation calls on initial Twilio webhook. (#1634)
- Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy. - Google Chat: tighten email allowlist matching, typing cleanup, media caps, and onboarding/docs/tests. (#1635) Thanks @iHildy.
+1
View File
@@ -12,3 +12,4 @@ If you believe you’ve found a security issue in Clawdbot, please report it pri
For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see: For threat model + hardening guidance (including `clawdbot security audit --deep` and `--fix`), see:
- `https://docs.clawd.bot/gateway/security` - `https://docs.clawd.bot/gateway/security`
@@ -12,3 +12,4 @@ data class CameraHudState(
val kind: CameraHudKind, val kind: CameraHudKind,
val message: String, val message: String,
) )
@@ -12,3 +12,4 @@ enum class VoiceWakeMode(val rawValue: String) {
} }
} }
} }
@@ -1,3 +1,4 @@
<resources> <resources>
<color name="ic_launcher_background">#0A0A0A</color> <color name="ic_launcher_background">#0A0A0A</color>
</resources> </resources>
@@ -1,3 +1,4 @@
<resources> <resources>
<string name="app_name">Clawdbot Node</string> <string name="app_name">Clawdbot Node</string>
</resources> </resources>
@@ -23,3 +23,4 @@ class VoiceWakeCommandExtractorTest {
assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude"))) assertNull(VoiceWakeCommandExtractor.extractCommand("hey claude!", listOf("claude")))
} }
} }
+1
View File
@@ -16,3 +16,4 @@ dependencyResolutionManagement {
rootProject.name = "ClawdbotNodeAndroid" rootProject.name = "ClawdbotNodeAndroid"
include(":app") include(":app")
+1
View File
@@ -3,3 +3,4 @@ parent_config: ../../.swiftlint.yml
included: included:
- Sources - Sources
- ../shared/ClawdisNodeKit/Sources - ../shared/ClawdisNodeKit/Sources
@@ -427,8 +427,8 @@ public actor GatewayChannelActor {
Task { await self.handleReceiveFailure(err) } Task { await self.handleReceiveFailure(err) }
case let .success(msg): case let .success(msg):
Task { Task {
await self.listen()
await self.handle(msg) await self.handle(msg)
await self.listen()
} }
} }
} }
@@ -574,22 +574,46 @@ public actor GatewayChannelActor {
params: [String: AnyCodable]?, params: [String: AnyCodable]?,
timeoutMs: Double? = nil) async throws -> Data timeoutMs: Double? = nil) async throws -> Data
{ {
try await self.connectOrThrow(context: "gateway connect") do {
try await self.connect()
} catch {
throw self.wrap(error, context: "gateway connect")
}
let id = UUID().uuidString
let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs let effectiveTimeout = timeoutMs ?? self.defaultRequestTimeoutMs
let payload = try self.encodeRequest(method: method, params: params, kind: "request") // Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
let data: Data
do {
data = try self.encoder.encode(frame)
} catch {
self.logger.error(
"gateway request encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in let response = try await withCheckedThrowingContinuation { (cont: CheckedContinuation<GatewayFrame, Error>) in
self.pending[payload.id] = cont self.pending[id] = cont
Task { [weak self] in Task { [weak self] in
guard let self else { return } guard let self else { return }
try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000)) try? await Task.sleep(nanoseconds: UInt64(effectiveTimeout * 1_000_000))
await self.timeoutRequest(id: payload.id, timeoutMs: effectiveTimeout) await self.timeoutRequest(id: id, timeoutMs: effectiveTimeout)
} }
Task { Task {
do { do {
try await self.task?.send(.data(payload.data)) try await self.task?.send(.data(data))
} catch { } catch {
let wrapped = self.wrap(error, context: "gateway send \(method)") let wrapped = self.wrap(error, context: "gateway send \(method)")
let waiter = self.pending.removeValue(forKey: payload.id) let waiter = self.pending.removeValue(forKey: id)
// Treat send failures as a broken socket: mark disconnected and trigger reconnect. // Treat send failures as a broken socket: mark disconnected and trigger reconnect.
self.connected = false self.connected = false
self.task?.cancel(with: .goingAway, reason: nil) self.task?.cancel(with: .goingAway, reason: nil)
@@ -633,42 +657,6 @@ public actor GatewayChannelActor {
return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"]) return NSError(domain: ns.domain, code: ns.code, userInfo: [NSLocalizedDescriptionKey: "\(context): \(desc)"])
} }
private func connectOrThrow(context: String) async throws {
do {
try await self.connect()
} catch {
throw self.wrap(error, context: context)
}
}
private func encodeRequest(
method: String,
params: [String: AnyCodable]?,
kind: String) throws -> (id: String, data: Data)
{
let id = UUID().uuidString
// Encode request using the generated models to avoid JSONSerialization/ObjC bridging pitfalls.
let paramsObject: ProtoAnyCodable? = params.map { entries in
let dict = entries.reduce(into: [String: ProtoAnyCodable]()) { dict, entry in
dict[entry.key] = ProtoAnyCodable(entry.value.value)
}
return ProtoAnyCodable(dict)
}
let frame = RequestFrame(
type: "req",
id: id,
method: method,
params: paramsObject)
do {
let data = try self.encoder.encode(frame)
return (id: id, data: data)
} catch {
self.logger.error(
"gateway \(kind) encode failed \(method, privacy: .public) error=\(error.localizedDescription, privacy: .public)")
throw error
}
}
private func failPending(_ error: Error) async { private func failPending(_ error: Error) async {
let waiters = self.pending let waiters = self.pending
self.pending.removeAll() self.pending.removeAll()
@@ -1,212 +0,0 @@
import Foundation
import Testing
@testable import ClawdbotKit
import ClawdbotProtocol
private final class FakeWebSocketTask: WebSocketTasking, @unchecked Sendable {
private let lock = NSLock()
private var queue: [URLSessionWebSocketTask.Message] = []
private var pendingHandler: (@Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)?
private var pendingContinuation: CheckedContinuation<URLSessionWebSocketTask.Message, Error>?
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
var state: URLSessionTask.State = .running
func resume() {}
func cancel(with closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) {
state = .canceling
}
func send(_ message: URLSessionWebSocketTask.Message) async throws {
guard case let .data(data) = message else { return }
guard let frame = try? decoder.decode(RequestFrame.self, from: data) else { return }
switch frame.method {
case "connect":
enqueueResponse(id: frame.id, payload: helloOkPayload())
default:
enqueueResponse(id: frame.id, payload: ["ok": true])
}
}
func receive() async throws -> URLSessionWebSocketTask.Message {
try await withCheckedThrowingContinuation { cont in
lock.lock()
if !queue.isEmpty {
let msg = queue.removeFirst()
lock.unlock()
cont.resume(returning: msg)
return
}
pendingContinuation = cont
lock.unlock()
}
}
func receive(
completionHandler: @escaping @Sendable (Result<URLSessionWebSocketTask.Message, Error>) -> Void)
{
lock.lock()
if !queue.isEmpty {
let msg = queue.removeFirst()
lock.unlock()
completionHandler(.success(msg))
return
}
pendingHandler = completionHandler
lock.unlock()
}
func enqueue(_ message: URLSessionWebSocketTask.Message) {
lock.lock()
if let handler = pendingHandler {
pendingHandler = nil
lock.unlock()
handler(.success(message))
return
}
if let continuation = pendingContinuation {
pendingContinuation = nil
lock.unlock()
continuation.resume(returning: message)
return
}
queue.append(message)
lock.unlock()
}
private func enqueueResponse(id: String, payload: [String: Any]) {
let response = ResponseFrame(
type: "res",
id: id,
ok: true,
payload: ClawdbotProtocol.AnyCodable(payload),
error: nil)
guard let data = try? encoder.encode(response) else { return }
enqueue(.data(data))
}
private func helloOkPayload() -> [String: Any] {
[
"type": "hello.ok",
"protocol": 1,
"server": [:],
"features": [:],
"snapshot": [
"presence": [],
"health": [:],
"stateVersion": [
"presence": 0,
"health": 0,
],
"uptimeMs": 0,
],
"policy": [
"tickIntervalMs": 1000,
],
]
}
}
private final class FakeWebSocketSession: WebSocketSessioning {
let task: FakeWebSocketTask
init(task: FakeWebSocketTask) {
self.task = task
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
WebSocketTaskBox(task: task)
}
}
private actor AsyncSignal {
private var continuation: CheckedContinuation<Result<Void, Error>, Never>?
private var stored: Result<Void, Error>?
func finish(_ result: Result<Void, Error>) {
if let continuation {
self.continuation = nil
continuation.resume(returning: result)
return
}
stored = result
}
func wait() async throws {
let result = await withCheckedContinuation { cont in
if let stored {
self.stored = nil
cont.resume(returning: stored)
return
}
continuation = cont
}
switch result {
case .success:
return
case let .failure(error):
throw error
}
}
}
private enum TestError: Error {
case timeout
}
struct GatewayChannelTests {
@Test
func listenRearmsBeforePushHandler() async throws {
let task = FakeWebSocketTask()
let session = FakeWebSocketSession(task: task)
let signal = AsyncSignal()
let url = URL(string: "ws://example.invalid")!
final class ChannelBox { var channel: GatewayChannelActor? }
let box = ChannelBox()
let channel = GatewayChannelActor(
url: url,
token: nil,
session: WebSocketSessionBox(session: session),
pushHandler: { push in
guard case let .event(evt) = push, evt.event == "test.event" else { return }
guard let channel = box.channel else { return }
let params: [String: ClawdbotKit.AnyCodable] = [
"event": ClawdbotKit.AnyCodable("test"),
"payloadJSON": ClawdbotKit.AnyCodable(NSNull()),
]
do {
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 50)
await signal.finish(.success(()))
} catch {
await signal.finish(.failure(error))
}
})
box.channel = channel
let challenge = EventFrame(
type: "event",
event: "connect.challenge",
payload: ClawdbotProtocol.AnyCodable(["nonce": "test-nonce"]),
seq: nil,
stateversion: nil)
let encoder = JSONEncoder()
task.enqueue(.data(try encoder.encode(challenge)))
try await channel.connect()
let event = EventFrame(
type: "event",
event: "test.event",
payload: ClawdbotProtocol.AnyCodable([:]),
seq: nil,
stateversion: nil)
task.enqueue(.data(try encoder.encode(event)))
try await AsyncTimeout.withTimeout(seconds: 1, onTimeout: { TestError.timeout }) {
try await signal.wait()
}
}
}
-1
View File
@@ -1 +0,0 @@
@import"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap";:root{--bg: #0c0d12;--bg-accent: #0d0e14;--bg-elevated: #181a21;--bg-hover: #252830;--bg-muted: #252830;--card: #13151c;--card-foreground: #f8fafc;--card-highlight: rgba(255, 255, 255, .04);--popover: #13151c;--popover-foreground: #f8fafc;--panel: #0c0d12;--panel-strong: #181a21;--panel-hover: #252830;--chrome: rgba(12, 13, 18, .95);--chrome-strong: rgba(12, 13, 18, .98);--text: #f8fafc;--text-strong: #ffffff;--chat-text: #f8fafc;--muted: #94a3b8;--muted-strong: #64748b;--muted-foreground: #94a3b8;--border: #333842;--border-strong: #454d5c;--border-hover: #5a6373;--input: #333842;--ring: #ff4d4d;--accent: #ff4d4d;--accent-hover: #ff6666;--accent-muted: #ff4d4d;--accent-subtle: rgba(255, 77, 77, .12);--accent-foreground: #f8fafc;--primary: #ff4d4d;--primary-foreground: #ffffff;--secondary: #252830;--secondary-foreground: #f8fafc;--accent-2: #3b82f6;--accent-2-muted: rgba(59, 130, 246, .7);--ok: #22c55e;--ok-muted: rgba(34, 197, 94, .7);--ok-subtle: rgba(34, 197, 94, .1);--destructive: #ef4444;--destructive-foreground: #fafafa;--warn: #eab308;--warn-muted: rgba(234, 179, 8, .7);--warn-subtle: rgba(234, 179, 8, .1);--danger: #ef4444;--danger-muted: rgba(239, 68, 68, .7);--danger-subtle: rgba(239, 68, 68, .1);--info: #3b82f6;--focus: rgba(255, 77, 77, .2);--focus-ring: 0 0 0 2px var(--bg), 0 0 0 4px var(--ring);--grid-line: rgba(255, 255, 255, .03);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "JetBrains Mono", ui-monospace, SFMono-Regular, "SF Mono", Menlo, Monaco, Consolas, monospace;--font-body: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--font-display: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, .05);--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, .1), 0 2px 4px -2px rgba(0, 0, 0, .1);--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, .1), 0 4px 6px -4px rgba(0, 0, 0, .1);--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, .1), 0 8px 10px -6px rgba(0, 0, 0, .1);--radius-sm: 4px;--radius-md: 6px;--radius-lg: 8px;--radius-xl: 12px;--radius-full: 9999px;--radius: 6px;--ease-out: cubic-bezier(.16, 1, .3, 1);--ease-in-out: cubic-bezier(.4, 0, .2, 1);--duration-fast: .15s;--duration-normal: .2s;--duration-slow: .3s;color-scheme:dark}:root[data-theme=light]{--bg: #f8f8f7;--bg-accent: #f3f2f0;--bg-elevated: #ffffff;--bg-hover: #eae8e6;--bg-muted: #eae8e6;--bg-content: #f0efed;--card: #ffffff;--card-foreground: #1c1917;--card-highlight: rgba(0, 0, 0, .04);--popover: #ffffff;--popover-foreground: #1c1917;--panel: #f8f8f7;--panel-strong: #f0efed;--panel-hover: #e5e3e1;--chrome: rgba(248, 248, 247, .95);--chrome-strong: rgba(248, 248, 247, .98);--text: #44403c;--text-strong: #292524;--chat-text: #44403c;--muted: #5c5856;--muted-strong: #44403c;--muted-foreground: #5c5856;--border: #e0dedc;--border-strong: #d6d3d1;--border-hover: #a8a5a0;--input: #e0dedc;--accent: #b91c1c;--accent-hover: #dc2626;--accent-muted: #b91c1c;--accent-subtle: rgba(185, 28, 28, .18);--accent-foreground: #ffffff;--primary: #b91c1c;--primary-foreground: #ffffff;--secondary: #eae8e6;--secondary-foreground: #44403c;--ok: #15803d;--ok-muted: rgba(21, 128, 61, .75);--ok-subtle: rgba(21, 128, 61, .12);--destructive: #b91c1c;--destructive-foreground: #fafafa;--warn: #a16207;--warn-muted: rgba(161, 98, 7, .75);--warn-subtle: rgba(161, 98, 7, .12);--danger: #b91c1c;--danger-muted: rgba(185, 28, 28, .75);--danger-subtle: rgba(185, 28, 28, .12);--info: #1d4ed8;--focus: rgba(185, 28, 28, .25);--grid-line: rgba(0, 0, 0, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:400 14px/1.5 var(--font-body);letter-spacing:-.011em;background:var(--bg);color:var(--text);-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .4s var(--ease-out) forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:var(--accent);text-decoration:none}a:hover{text-decoration:underline}button,input,textarea,select{font:inherit;color:inherit}::selection{background:var(--accent-subtle);color:var(--text-strong)}::-webkit-scrollbar{width:8px;height:8px}::-webkit-scrollbar-track{background:transparent}::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-full)}::-webLine truncated
+1
View File
@@ -0,0 +1 @@
@import"https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500&family=Unbounded:wght@400;500;600&family=Work+Sans:wght@400;500;600;700&display=swap";:root{--bg: #0a0f14;--bg-accent: #111826;--bg-grad-1: #162031;--bg-grad-2: #1f2a22;--bg-overlay: rgba(255, 255, 255, .05);--bg-glow: rgba(245, 159, 74, .12);--panel: rgba(14, 20, 30, .88);--panel-strong: rgba(18, 26, 38, .96);--chrome: rgba(9, 14, 20, .72);--chrome-strong: rgba(9, 14, 20, .86);--text: rgba(244, 246, 251, .96);--chat-text: rgba(231, 237, 244, .92);--muted: rgba(156, 169, 189, .72);--border: rgba(255, 255, 255, .09);--border-strong: rgba(255, 255, 255, .16);--accent: #f59f4a;--accent-2: #34c7b7;--ok: #2bd97f;--warn: #f2c94c;--danger: #ff6b6b;--focus: rgba(245, 159, 74, .35);--grid-line: rgba(255, 255, 255, .04);--theme-switch-x: 50%;--theme-switch-y: 50%;--mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--font-body: "Work Sans", system-ui, sans-serif;--font-display: "Unbounded", "Times New Roman", serif;color-scheme:dark}:root[data-theme=light]{--bg: #f5f1ea;--bg-accent: #ffffff;--bg-grad-1: #f1e6d6;--bg-grad-2: #e5eef4;--bg-overlay: rgba(28, 32, 46, .05);--bg-glow: rgba(52, 199, 183, .14);--panel: rgba(255, 255, 255, .9);--panel-strong: rgba(255, 255, 255, .97);--chrome: rgba(255, 255, 255, .75);--chrome-strong: rgba(255, 255, 255, .88);--text: rgba(27, 36, 50, .98);--chat-text: rgba(36, 48, 66, .9);--muted: rgba(80, 94, 114, .7);--border: rgba(18, 24, 40, .12);--border-strong: rgba(18, 24, 40, .2);--accent: #e28a3f;--accent-2: #1ba99d;--ok: #1aa86c;--warn: #b3771c;--danger: #d44848;--focus: rgba(226, 138, 63, .35);--grid-line: rgba(18, 24, 40, .06);color-scheme:light}*{box-sizing:border-box}html,body{height:100%}body{margin:0;font:15px/1.5 var(--font-body);background:radial-gradient(1200px 900px at 15% -10%,var(--bg-grad-1) 0%,transparent 55%) fixed,radial-gradient(900px 700px at 80% 10%,var(--bg-grad-2) 0%,transparent 60%) fixed,linear-gradient(160deg,var(--bg) 0%,var(--bg-accent) 100%) fixed;color:var(--text)}body:before{content:"";position:fixed;inset:0;background:linear-gradient(140deg,var(--bg-overlay) 0%,rgba(255,255,255,0) 40%),radial-gradient(620px 420px at 75% 75%,var(--bg-glow),transparent 60%);pointer-events:none;z-index:0}@keyframes theme-circle-transition{0%{clip-path:circle(0% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}to{clip-path:circle(150% at var(--theme-switch-x, 50%) var(--theme-switch-y, 50%))}}html.theme-transition{view-transition-name:theme}html.theme-transition::view-transition-old(theme){mix-blend-mode:normal;animation:none;z-index:1}html.theme-transition::view-transition-new(theme){mix-blend-mode:normal;z-index:2;animation:theme-circle-transition .45s ease-out forwards}@media(prefers-reduced-motion:reduce){html.theme-transition::view-transition-old(theme),html.theme-transition::view-transition-new(theme){animation:none!important}}clawdbot-app{display:block;position:relative;z-index:1;min-height:100vh}a{color:inherit}button,input,textarea,select{font:inherit;color:inherit}@keyframes rise{0%{opacity:0;transform:translateY(6px)}to{opacity:1;transform:translateY(0)}}@keyframes dashboard-enter{0%{opacity:0;transform:translateY(12px)}to{opacity:1;transform:translateY(0)}}.shell{--shell-pad: 16px;--shell-gap: 16px;--shell-nav-width: 220px;--shell-topbar-height: 56px;--shell-focus-duration: .22s;--shell-focus-ease: cubic-bezier(.2, .85, .25, 1);height:100vh;display:grid;grid-template-columns:var(--shell-nav-width) minmax(0,1fr);grid-template-rows:var(--shell-topbar-height) 1fr;grid-template-areas:"topbar topbar" "nav content";gap:0;animation:dashboard-enter .6s ease-out;transition:grid-template-columns var(--shell-focus-duration) var(--shell-focus-ease);overflow:hidden}@supports (height: 100dvh){.shell{height:100dvh}}.shell--chat{min-height:100vh;height:100vh;overflow:hidden}@supports (height: 100dvh){.shell--chat{height:100dvh}}.shell--nav-collapsed,.shell--chat-focus{grid-template-columns:0px minmax(0,1fr)}.shell--onboarding{grid-template-rows:0 1fr}.shell--onboarding .topbar{display:none}.shell--onboarding .content{padding-top:0}.shell--chat-focus .content{padding-top:0;gap:0}.topbar{grid-area:topbar;position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:var(--shell-topbar-height);border-bottom:1px solid var(--border);background:var(--panel);-webkit-backdrop-filter:blur(18px);backdrop-filter:blur(18px)}.topbar-left{display:flex;align-items:center;gap:12px}.topbar .nav-collapse-toggle{width:44px;height:44px;margin-bottom:0}.topbar .nav-collapse-toggle__icon{font-size:22px}.brand{display:flex;flex-direction:column;gap:2px}.brand-title{font-family:var(--font-display);font-size:16px;letter-spacing:1px;text-transform:uppercase;font-weight:600;line-height:1.1}.brand-sub{font-size:10px;color:var(--muted);letter-spacing:.8px;text-transform:uppercase;line-heightLine truncated
File diff suppressed because it is too large. Load diff
-1
View File
@@ -1 +0,0 @@
{"version":3,"file":"index-DQcOTEYz.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/icons.ts","../../../src/shared/text/reasoning-tags.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.googlechat.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-DsXRcnEw.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
{"version":3,"file":"index-bYQnHP3a.js","sources":["../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/css-tag.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/reactive-element.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/lit-html.js","../../../node_modules/.pnpm/lit-element@4.2.2/node_modules/lit-element/lit-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/custom-element.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/property.js","../../../node_modules/.pnpm/@lit+reactive-element@2.1.2/node_modules/@lit/reactive-element/decorators/state.js","../../../ui/src/ui/assistant-identity.ts","../../../ui/src/ui/storage.ts","../../../src/sessions/session-key-utils.ts","../../../ui/src/ui/navigation.ts","../../../ui/src/ui/format.ts","../../../ui/src/ui/chat/message-extract.ts","../../../ui/src/ui/uuid.ts","../../../ui/src/ui/controllers/chat.ts","../../../ui/src/ui/controllers/sessions.ts","../../../ui/src/ui/app-tool-stream.ts","../../../ui/src/ui/app-scroll.ts","../../../ui/src/ui/controllers/config/form-utils.ts","../../../ui/src/ui/controllers/config.ts","../../../ui/src/ui/controllers/cron.ts","../../../ui/src/ui/controllers/channels.ts","../../../ui/src/ui/controllers/debug.ts","../../../ui/src/ui/controllers/logs.ts","../../../node_modules/.pnpm/@noble+ed25519@3.0.0/node_modules/@noble/ed25519/index.js","../../../ui/src/ui/device-identity.ts","../../../ui/src/ui/device-auth.ts","../../../ui/src/ui/controllers/devices.ts","../../../ui/src/ui/controllers/nodes.ts","../../../ui/src/ui/controllers/exec-approvals.ts","../../../ui/src/ui/controllers/presence.ts","../../../ui/src/ui/controllers/skills.ts","../../../ui/src/ui/theme.ts","../../../ui/src/ui/theme-transition.ts","../../../ui/src/ui/app-polling.ts","../../../ui/src/ui/app-settings.ts","../../../ui/src/ui/app-chat.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directive-helpers.js","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/repeat.js","../../../ui/src/ui/chat/message-normalizer.ts","../../../node_modules/.pnpm/lit-html@3.3.2/node_modules/lit-html/directives/unsafe-html.js","../../../node_modules/.pnpm/dompurify@3.3.1/node_modules/dompurify/dist/purify.es.mjs","../../../node_modules/.pnpm/marked@17.0.1/node_modules/marked/lib/marked.esm.js","../../../ui/src/ui/markdown.ts","../../../ui/src/ui/icons.ts","../../../ui/src/ui/chat/copy-as-markdown.ts","../../../ui/src/ui/tool-display.ts","../../../ui/src/ui/chat/constants.ts","../../../ui/src/ui/chat/tool-helpers.ts","../../../ui/src/ui/chat/tool-cards.ts","../../../ui/src/ui/chat/grouped-render.ts","../../../ui/src/ui/views/markdown-sidebar.ts","../../../ui/src/ui/components/resizable-divider.ts","../../../ui/src/ui/views/chat.ts","../../../ui/src/ui/views/config-form.shared.ts","../../../ui/src/ui/views/config-form.node.ts","../../../ui/src/ui/views/config-form.render.ts","../../../ui/src/ui/views/config-form.analyze.ts","../../../ui/src/ui/views/config.ts","../../../ui/src/ui/views/channels.shared.ts","../../../ui/src/ui/views/channels.config.ts","../../../ui/src/ui/views/channels.discord.ts","../../../ui/src/ui/views/channels.imessage.ts","../../../ui/src/ui/views/channels.nostr-profile-form.ts","../../../ui/src/ui/views/channels.nostr.ts","../../../ui/src/ui/views/channels.signal.ts","../../../ui/src/ui/views/channels.slack.ts","../../../ui/src/ui/views/channels.telegram.ts","../../../ui/src/ui/views/channels.whatsapp.ts","../../../ui/src/ui/views/channels.ts","../../../ui/src/ui/presenter.ts","../../../ui/src/ui/views/cron.ts","../../../ui/src/ui/views/debug.ts","../../../ui/src/ui/views/instances.ts","../../../ui/src/ui/views/logs.ts","../../../ui/src/ui/views/nodes.ts","../../../ui/src/ui/views/overview.ts","../../../ui/src/ui/views/sessions.ts","../../../ui/src/ui/views/exec-approval.ts","../../../ui/src/ui/views/skills.ts","../../../ui/src/ui/app-render.helpers.ts","../../../ui/src/ui/app-render.ts","../../../ui/src/ui/app-defaults.ts","../../../ui/src/ui/controllers/agents.ts","../../../src/gateway/protocol/client-info.ts","../../../src/gateway/device-auth.ts","../../../ui/src/ui/gateway.ts","../../../ui/src/ui/controllers/exec-approval.ts","../../../ui/src/ui/controllers/assistant-identity.ts","../../../ui/src/ui/app-gateway.ts","../../../ui/src/ui/app-lifecycle.ts","../../../ui/src/ui/app-channels.ts","../../../ui/src/ui/app.ts"],"sourcesContent":["/**\n * @license\n * Copyright 2019 Google LLC\n * SPDX-License-Identifier: BSD-3-Clause\n */\nconst t=globalThis,e=t.ShadowRoot&&(void 0===t.ShadyCSS||t.ShadyCSS.nativeShadow)&&\"adoptedStyleSheets\"in Document.prototype&&\"replace\"in CSSStyleSheet.prototype,s=Symbol(),o=new WeakMapLine truncated
+2 -2
View File
@@ -6,8 +6,8 @@
<title>Clawdbot Control</title> <title>Clawdbot Control</title>
<meta name="color-scheme" content="dark light" /> <meta name="color-scheme" content="dark light" />
<link rel="icon" href="./favicon.ico" sizes="any" /> <link rel="icon" href="./favicon.ico" sizes="any" />
<script type="module" crossorigin src="./assets/index-DQcOTEYz.js"></script> <script type="module" crossorigin src="./assets/index-DsXRcnEw.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-08nzABV3.css"> <link rel="stylesheet" crossorigin href="./assets/index-BvhR9FCb.css">
</head> </head>
<body> <body>
<clawdbot-app></clawdbot-app> <clawdbot-app></clawdbot-app>
-1
View File
@@ -213,7 +213,6 @@ Prefer `chat_guid` for stable routing:
- `chat_id:123` - `chat_id:123`
- `chat_identifier:...` - `chat_identifier:...`
- Direct handles: `+15555550123`, `user@example.com` - Direct handles: `+15555550123`, `user@example.com`
- If a direct handle does not have an existing DM chat, Clawdbot will create one via `POST /api/v1/chat/new`. This requires the BlueBubbles Private API to be enabled.
## Security ## Security
- Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted. - Webhook requests are authenticated by comparing `guid`/`password` query params or headers against `channels.bluebubbles.password`. Requests from `localhost` are also accepted.
+1 -1
View File
@@ -17,7 +17,7 @@ read_when:
- **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`. - **Proxy:** optional `channels.telegram.proxy` uses `undici.ProxyAgent` through grammY’s `client.baseFetch`.
- **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls). - **Webhook support:** `webhook-set.ts` wraps `setWebhook/deleteWebhook`; `webhook.ts` hosts the callback with health + graceful shutdown. Gateway enables webhook mode when `channels.telegram.webhookUrl` is set (otherwise it long-polls).
- **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel. - **Sessions:** direct chats collapse into the agent main session (`agent:<agentId>:<mainKey>`); groups use `agent:<agentId>:telegram:group:<chatId>`; replies route back to the same channel.
- **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.linkPreview`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`. - **Config knobs:** `channels.telegram.botToken`, `channels.telegram.dmPolicy`, `channels.telegram.groups` (allowlist + mention defaults), `channels.telegram.allowFrom`, `channels.telegram.groupAllowFrom`, `channels.telegram.groupPolicy`, `channels.telegram.mediaMaxMb`, `channels.telegram.proxy`, `channels.telegram.webhookSecret`, `channels.telegram.webhookUrl`.
- **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming. - **Draft streaming:** optional `channels.telegram.streamMode` uses `sendMessageDraft` in private topic chats (Bot API 9.3+). This is separate from channel block streaming.
- **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome. - **Tests:** grammy mocks cover DM + group mention gating and outbound send; more media/webhook fixtures still welcome.
-17
View File
@@ -74,22 +74,6 @@ Example:
Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern. Multi-account support: use `channels.signal.accounts` with per-account config and optional `name`. See [`gateway/configuration`](/gateway/configuration#telegramaccounts--discordaccounts--slackaccounts--signalaccounts--imessageaccounts) for the shared pattern.
## External daemon mode (httpUrl)
If you want to manage `signal-cli` yourself (slow JVM cold starts, container init, or shared CPUs), run the daemon separately and point Clawdbot at it:
```json5
{
channels: {
signal: {
httpUrl: "http://127.0.0.1:8080",
autoStart: false
}
}
}
```
This skips auto-spawn and the startup wait inside Clawdbot. For slow starts when auto-spawning, set `channels.signal.startupTimeoutMs`.
## Access control (DMs + groups) ## Access control (DMs + groups)
DMs: DMs:
- Default: `channels.signal.dmPolicy = "pairing"`. - Default: `channels.signal.dmPolicy = "pairing"`.
@@ -158,7 +142,6 @@ Provider options:
- `channels.signal.httpUrl`: full daemon URL (overrides host/port). - `channels.signal.httpUrl`: full daemon URL (overrides host/port).
- `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080). - `channels.signal.httpHost`, `channels.signal.httpPort`: daemon bind (default 127.0.0.1:8080).
- `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset). - `channels.signal.autoStart`: auto-spawn daemon (default true if `httpUrl` unset).
- `channels.signal.startupTimeoutMs`: startup wait timeout in ms (cap 120000).
- `channels.signal.receiveMode`: `on-start | manual`. - `channels.signal.receiveMode`: `on-start | manual`.
- `channels.signal.ignoreAttachments`: skip attachment downloads. - `channels.signal.ignoreAttachments`: skip attachment downloads.
- `channels.signal.ignoreStories`: ignore stories from the daemon. - `channels.signal.ignoreStories`: ignore stories from the daemon.
-1
View File
@@ -525,7 +525,6 @@ Provider options:
- `channels.telegram.replyToMode`: `off | first | all` (default: `first`). - `channels.telegram.replyToMode`: `off | first | all` (default: `first`).
- `channels.telegram.textChunkLimit`: outbound chunk size (chars). - `channels.telegram.textChunkLimit`: outbound chunk size (chars).
- `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking. - `channels.telegram.chunkMode`: `length` (default) or `newline` to split on newlines before length chunking.
- `channels.telegram.linkPreview`: toggle link previews for outbound messages (default: true).
- `channels.telegram.streamMode`: `off | partial | block` (draft streaming). - `channels.telegram.streamMode`: `off | partial | block` (draft streaming).
- `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB). - `channels.telegram.mediaMaxMb`: inbound/outbound media cap (MB).
- `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter). - `channels.telegram.retry`: retry policy for outbound Telegram API calls (attempts, minDelayMs, maxDelayMs, jitter).
-2
View File
@@ -89,8 +89,6 @@ Clawdbot ships with the pi‑ai catalog. These providers require **no**
- Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default). - Gemini CLI OAuth is shipped as a bundled plugin (`google-gemini-cli-auth`, disabled by default).
- Enable: `clawdbot plugins enable google-gemini-cli-auth` - Enable: `clawdbot plugins enable google-gemini-cli-auth`
- Login: `clawdbot models auth login --provider google-gemini-cli --set-default` - Login: `clawdbot models auth login --provider google-gemini-cli --set-default`
- Note: you do **not** paste a client id or secret into `clawdbot.json`. The CLI login flow stores
tokens in auth profiles on the gateway host.
### Z.AI (GLM) ### Z.AI (GLM)
-89
View File
@@ -1,89 +0,0 @@
---
summary: "Diagnostics flags for targeted debug logs"
read_when:
- You need targeted debug logs without raising global logging levels
- You need to capture subsystem-specific logs for support
---
# Diagnostics Flags
Diagnostics flags let you enable targeted debug logs without turning on verbose logging everywhere. Flags are opt-in and have no effect unless a subsystem checks them.
## How it works
- Flags are strings (case-insensitive).
- You can enable flags in config or via an env override.
- Wildcards are supported:
- `telegram.*` matches `telegram.http`
- `*` enables all flags
## Enable via config
```json
{
"diagnostics": {
"flags": ["telegram.http"]
}
}
```
Multiple flags:
```json
{
"diagnostics": {
"flags": ["telegram.http", "gateway.*"]
}
}
```
Restart the gateway after changing flags.
## Env override (one-off)
```bash
CLAWDBOT_DIAGNOSTICS=telegram.http,telegram.payload
```
Disable all flags:
```bash
CLAWDBOT_DIAGNOSTICS=0
```
## Where logs go
Flags emit logs into the standard diagnostics log file. By default:
```
/tmp/clawdbot/clawdbot-YYYY-MM-DD.log
```
If you set `logging.file`, use that path instead. Logs are JSONL (one JSON object per line). Redaction still applies based on `logging.redactSensitive`.
## Extract logs
Pick the latest log file:
```bash
ls -t /tmp/clawdbot/clawdbot-*.log | head -n 1
```
Filter for Telegram HTTP diagnostics:
```bash
rg "telegram http error" /tmp/clawdbot/clawdbot-*.log
```
Or tail while reproducing:
```bash
tail -f /tmp/clawdbot/clawdbot-$(date +%F).log | rg "telegram http error"
```
For remote gateways, you can also use `clawdbot logs --follow` (see [/cli/logs](/cli/logs)).
## Notes
- If `logging.level` is set higher than `warn`, these logs may be suppressed. Default `info` is fine.
- Flags are safe to leave enabled; they only affect log volume for the specific subsystem.
- Use [/logging](/logging) to change log destinations, levels, and redaction.
-1
View File
@@ -1048,7 +1048,6 @@
"pages": [ "pages": [
"platforms", "platforms",
"platforms/macos", "platforms/macos",
"platforms/macos-vm",
"platforms/ios", "platforms/ios",
"platforms/android", "platforms/android",
"platforms/windows", "platforms/windows",
-1
View File
@@ -1021,7 +1021,6 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
], ],
historyLimit: 50, // include last N group messages as context (0 disables) historyLimit: 50, // include last N group messages as context (0 disables)
replyToMode: "first", // off | first | all replyToMode: "first", // off | first | all
linkPreview: true, // toggle outbound link previews
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming) streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
draftChunk: { // optional; only for streamMode=block draftChunk: { // optional; only for streamMode=block
minChars: 200, minChars: 200,
+176 -382
View File
File diff suppressed because it is too large. Load diff
-6
View File
@@ -114,9 +114,3 @@ Git requirement:
If you choose `-InstallMethod git` and Git is missing, the installer will print the If you choose `-InstallMethod git` and Git is missing, the installer will print the
Git for Windows link (`https://git-scm.com/download/win`) and exit. Git for Windows link (`https://git-scm.com/download/win`) and exit.
Common Windows issues:
- **npm error spawn git / ENOENT**: install Git for Windows and reopen PowerShell, then rerun the installer.
- **"clawdbot" is not recognized**: your npm global bin folder is not on PATH. Most systems use
`%AppData%\\npm`. You can also run `npm config get prefix` and add `\\bin` to PATH, then reopen PowerShell.
-24
View File
@@ -192,30 +192,6 @@ Use this if you want diagnostics events available to plugins or custom sinks:
} }
``` ```
### Diagnostics flags (targeted logs)
Use flags to turn on extra, targeted debug logs without raising `logging.level`.
Flags are case-insensitive and support wildcards (e.g. `telegram.*` or `*`).
```json
{
"diagnostics": {
"flags": ["telegram.http"]
}
}
```
Env override (one-off):
```
CLAWDBOT_DIAGNOSTICS=telegram.http,telegram.payload
```
Notes:
- Flag logs go to the standard log file (same as `logging.file`).
- Output is still redacted according to `logging.redactSensitive`.
- Full guide: [/diagnostics/flags](/diagnostics/flags).
### Export to OpenTelemetry ### Export to OpenTelemetry
Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This Diagnostics can be exported via the `diagnostics-otel` plugin (OTLP/HTTP). This
+5 -19
View File
@@ -80,7 +80,6 @@ primary_region = "iad"
|---------|-----| |---------|-----|
| `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway | | `--bind lan` | Binds to `0.0.0.0` so Fly's proxy can reach the gateway |
| `--allow-unconfigured` | Starts without a config file (you'll create one after) | | `--allow-unconfigured` | Starts without a config file (you'll create one after) |
| `internal_port = 3000` | Must match `--port 3000` (or `CLAWDBOT_GATEWAY_PORT`) for Fly health checks |
| `memory = "2048mb"` | 512MB is too small; 2GB recommended | | `memory = "2048mb"` | 512MB is too small; 2GB recommended |
| `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume | | `CLAWDBOT_STATE_DIR = "/data"` | Persists state on the volume |
@@ -236,12 +235,6 @@ The gateway is binding to `127.0.0.1` instead of `0.0.0.0`.
**Fix:** Add `--bind lan` to your process command in `fly.toml`. **Fix:** Add `--bind lan` to your process command in `fly.toml`.
### Health checks failing / connection refused
Fly can't reach the gateway on the configured port.
**Fix:** Ensure `internal_port` matches the gateway port (set `--port 3000` or `CLAWDBOT_GATEWAY_PORT=3000`).
### OOM / Memory Issues ### OOM / Memory Issues
Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts. Container keeps restarting or getting killed. Signs: `SIGABRT`, `v8::internal::Runtime_AllocateInYoungGeneration`, or silent restarts.
@@ -275,11 +268,11 @@ The lock file is at `/data/gateway.*.lock` (not in a subdirectory).
### Config Not Being Read ### Config Not Being Read
If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/clawdbot.json` should be read on restart. If using `--allow-unconfigured`, the gateway creates a minimal config. Your custom config at `/data/.clawdbot/clawdbot.json` should be read on restart.
Verify the config exists: Verify the config exists:
```bash ```bash
fly ssh console --command "cat /data/clawdbot.json" fly ssh console --command "cat /data/.clawdbot/clawdbot.json"
``` ```
### Writing Config via SSH ### Writing Config via SSH
@@ -288,24 +281,18 @@ The `fly ssh console -C` command doesn't support shell redirection. To write a c
```bash ```bash
# Use echo + tee (pipe from local to remote) # Use echo + tee (pipe from local to remote)
echo '{"your":"config"}' | fly ssh console -C "tee /data/clawdbot.json" echo '{"your":"config"}' | fly ssh console -C "tee /data/.clawdbot/clawdbot.json"
# Or use sftp # Or use sftp
fly sftp shell fly sftp shell
> put /local/path/config.json /data/clawdbot.json > put /local/path/config.json /data/.clawdbot/clawdbot.json
``` ```
**Note:** `fly sftp` may fail if the file already exists. Delete first: **Note:** `fly sftp` may fail if the file already exists. Delete first:
```bash ```bash
fly ssh console --command "rm /data/clawdbot.json" fly ssh console --command "rm /data/.clawdbot/clawdbot.json"
``` ```
### State Not Persisting
If you lose credentials or sessions after a restart, the state dir is writing to the container filesystem.
**Fix:** Ensure `CLAWDBOT_STATE_DIR=/data` is set in `fly.toml` and redeploy.
## Updates ## Updates
```bash ```bash
@@ -343,7 +330,6 @@ fly machine update <machine-id> --vm-memory 2048 --command "node dist/index.js g
- The Dockerfile is compatible with both architectures - The Dockerfile is compatible with both architectures
- For WhatsApp/Telegram onboarding, use `fly ssh console` - For WhatsApp/Telegram onboarding, use `fly ssh console`
- Persistent data lives on the volume at `/data` - Persistent data lives on the volume at `/data`
- Signal requires Java + signal-cli; use a custom image and keep memory at 2GB+.
## Cost ## Cost
-275
View File
@@ -1,275 +0,0 @@
---
summary: "Run Clawdbot in a sandboxed macOS VM (local or hosted) when you need isolation or iMessage"
read_when:
- You want Clawdbot isolated from your main macOS environment
- You want iMessage integration (BlueBubbles) in a sandbox
- You want a resettable macOS environment you can clone
- You want to compare local vs hosted macOS VM options
---
# Clawdbot on macOS VMs (Sandboxing)
## Recommended default (most users)
- **Small Linux VPS** for an always-on Gateway and low cost. See [VPS hosting](/vps).
- **Dedicated hardware** (Mac mini or Linux box) if you want full control and a **residential IP** for browser automation. Many sites block data center IPs, so local browsing often works better.
- **Hybrid:** keep the Gateway on a cheap VPS, and connect your Mac as a **node** when you need browser/UI automation. See [Nodes](/nodes) and [Gateway remote](/gateway/remote).
Use a macOS VM when you specifically need macOS-only capabilities (iMessage/BlueBubbles) or want strict isolation from your daily Mac.
## macOS VM options
### Local VM on your Apple Silicon Mac (Lume)
Run Clawdbot in a sandboxed macOS VM on your existing Apple Silicon Mac using [Lume](https://cua.ai/docs/lume).
This gives you:
- Full macOS environment in isolation (your host stays clean)
- iMessage support via BlueBubbles (impossible on Linux/Windows)
- Instant reset by cloning VMs
- No extra hardware or cloud costs
### Hosted Mac providers (cloud)
If you want macOS in the cloud, hosted Mac providers work too:
- [MacStadium](https://www.macstadium.com/) (hosted Macs)
- Other hosted Mac vendors also work; follow their VM + SSH docs
Once you have SSH access to a macOS VM, continue at step 6 below.
---
## Quick path (Lume, experienced users)
1. Install Lume
2. `lume create clawdbot --os macos --ipsw latest`
3. Complete Setup Assistant, enable Remote Login (SSH)
4. `lume run clawdbot --no-display`
5. SSH in, install Clawdbot, configure channels
6. Done
---
## What you need (Lume)
- Apple Silicon Mac (M1/M2/M3/M4)
- macOS Sequoia or later on the host
- ~60 GB free disk space per VM
- ~20 minutes
---
## 1) Install Lume
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/trycua/cua/main/libs/lume/scripts/install.sh)"
```
If `~/.local/bin` isn't in your PATH:
```bash
echo 'export PATH="$PATH:$HOME/.local/bin"' >> ~/.zshrc && source ~/.zshrc
```
Verify:
```bash
lume --version
```
Docs: [Lume Installation](https://cua.ai/docs/lume/guide/getting-started/installation)
---
## 2) Create the macOS VM
```bash
lume create clawdbot --os macos --ipsw latest
```
This downloads macOS and creates the VM. A VNC window opens automatically.
Note: The download can take a while depending on your connection.
---
## 3) Complete Setup Assistant
In the VNC window:
1. Select language and region
2. Skip Apple ID (or sign in if you want iMessage later)
3. Create a user account (remember the username and password)
4. Skip all optional features
After setup completes, enable SSH:
1. Open System Settings → General → Sharing
2. Enable "Remote Login"
---
## 4) Get the VM's IP address
```bash
lume get clawdbot
```
Look for the IP address (usually `192.168.64.x`).
---
## 5) SSH into the VM
```bash
ssh youruser@192.168.64.X
```
Replace `youruser` with the account you created, and the IP with your VM's IP.
---
## 6) Install Clawdbot
Inside the VM:
```bash
npm install -g clawdbot@latest
clawdbot onboard --install-daemon
```
Follow the onboarding prompts to set up your model provider (Anthropic, OpenAI, etc.).
---
## 7) Configure channels
Edit the config file:
```bash
nano ~/.clawdbot/clawdbot.json
```
Add your channels:
```json
{
"channels": {
"whatsapp": {
"dmPolicy": "allowlist",
"allowFrom": ["+15551234567"]
},
"telegram": {
"botToken": "YOUR_BOT_TOKEN"
}
}
}
```
Then login to WhatsApp (scan QR):
```bash
clawdbot channels login
```
---
## 8) Run the VM headlessly
Stop the VM and restart without display:
```bash
lume stop clawdbot
lume run clawdbot --no-display
```
The VM runs in the background. Clawdbot's daemon keeps the gateway running.
To check status:
```bash
ssh youruser@192.168.64.X "clawdbot status"
```
---
## Bonus: iMessage integration
This is the killer feature of running on macOS. Use [BlueBubbles](https://bluebubbles.app) to add iMessage to Clawdbot.
Inside the VM:
1. Download BlueBubbles from bluebubbles.app
2. Sign in with your Apple ID
3. Enable the Web API and set a password
4. Point BlueBubbles webhooks at your gateway (example: `https://your-gateway-host:3000/bluebubbles-webhook?password=<password>`)
Add to your Clawdbot config:
```json
{
"channels": {
"bluebubbles": {
"serverUrl": "http://localhost:1234",
"password": "your-api-password",
"webhookPath": "/bluebubbles-webhook"
}
}
}
```
Restart the gateway. Now your agent can send and receive iMessages.
Full setup details: [BlueBubbles channel](/channels/bluebubbles)
---
## Save a golden image
Before customizing further, snapshot your clean state:
```bash
lume stop clawdbot
lume clone clawdbot clawdbot-golden
```
Reset anytime:
```bash
lume stop clawdbot && lume delete clawdbot
lume clone clawdbot-golden clawdbot
lume run clawdbot --no-display
```
---
## Running 24/7
Keep the VM running by:
- Keeping your Mac plugged in
- Disabling sleep in System Settings → Energy Saver
- Using `caffeinate` if needed
For true always-on, consider a dedicated Mac mini or a small VPS. See [VPS hosting](/vps).
---
## Troubleshooting
| Problem | Solution |
|---------|----------|
| Can't SSH into VM | Check "Remote Login" is enabled in VM's System Settings |
| VM IP not showing | Wait for VM to fully boot, run `lume get clawdbot` again |
| Lume command not found | Add `~/.local/bin` to your PATH |
| WhatsApp QR not scanning | Ensure you're logged into the VM (not host) when running `clawdbot channels login` |
---
## Related docs
- [VPS hosting](/vps)
- [Nodes](/nodes)
- [Gateway remote](/gateway/remote)
- [BlueBubbles channel](/channels/bluebubbles)
- [Lume Quickstart](https://cua.ai/docs/lume/guide/getting-started/quickstart)
- [Lume CLI Reference](https://cua.ai/docs/lume/reference/cli-reference)
- [Unattended VM Setup](https://cua.ai/docs/lume/guide/fundamentals/unattended-setup) (advanced)
- [Docker Sandboxing](/install/docker) (alternative isolation approach)
-16
View File
@@ -67,22 +67,6 @@ Plugins can register:
Plugins run **in‑process** with the Gateway, so treat them as trusted code. Plugins run **in‑process** with the Gateway, so treat them as trusted code.
Tool authoring guide: [Plugin agent tools](/plugins/agent-tools). Tool authoring guide: [Plugin agent tools](/plugins/agent-tools).
## Runtime helpers
Plugins can access selected core helpers via `api.runtime`. For telephony TTS:
```ts
const result = await api.runtime.tts.textToSpeechTelephony({
text: "Hello from Clawdbot",
cfg: api.config,
});
```
Notes:
- Uses core `messages.tts` configuration (OpenAI or ElevenLabs).
- Returns PCM audio buffer + sample rate. Plugins must resample/encode for providers.
- Edge TTS is not supported for telephony.
## Discovery & precedence ## Discovery & precedence
Clawdbot scans, in order: Clawdbot scans, in order:
-81
View File
@@ -104,87 +104,6 @@ Notes:
- `mock` is a local dev provider (no network calls). - `mock` is a local dev provider (no network calls).
- `skipSignatureVerification` is for local testing only. - `skipSignatureVerification` is for local testing only.
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
**same shape** — it deep‑merges with `messages.tts`.
```json5
{
tts: {
provider: "elevenlabs",
elevenlabs: {
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2"
}
}
}
```
Notes:
- **Edge TTS is ignored for voice calls** (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
### More examples
Use core TTS only (no override):
```json5
{
messages: {
tts: {
provider: "openai",
openai: { voice: "alloy" }
}
}
}
```
Override to ElevenLabs just for calls (keep core default elsewhere):
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
tts: {
provider: "elevenlabs",
elevenlabs: {
apiKey: "elevenlabs_key",
voiceId: "pMsXgVXv3BLzUgSXRplE",
modelId: "eleven_multilingual_v2"
}
}
}
}
}
}
}
```
Override only the OpenAI model for calls (deep‑merge example):
```json5
{
plugins: {
entries: {
"voice-call": {
config: {
tts: {
openai: {
model: "gpt-4o-mini-tts",
voice: "marin"
}
}
}
}
}
}
}
```
## Inbound calls ## Inbound calls
Inbound policy defaults to `disabled`. To enable inbound calls, set: Inbound policy defaults to `disabled`. To enable inbound calls, set:
+3 -5
View File
@@ -16,7 +16,7 @@ and you configure everything via the `/setup` web wizard.
## One-click deploy ## One-click deploy
<a href="https://railway.app/new/template?template=https://github.com/vignesh07/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a> <a href="https://railway.com/deploy/clawdbot-railway-template" target="_blank" rel="noreferrer">Deploy on Railway</a>
After deploy, find your public URL in **Railway → your service → Settings → Domains**. After deploy, find your public URL in **Railway → your service → Settings → Domains**.
@@ -55,7 +55,6 @@ Attach a volume mounted at:
Set these variables on the service: Set these variables on the service:
- `SETUP_PASSWORD` (required) - `SETUP_PASSWORD` (required)
- `PORT=8080` (required — must match the port in Public Networking)
- `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended) - `CLAWDBOT_STATE_DIR=/data/.clawdbot` (recommended)
- `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended) - `CLAWDBOT_WORKSPACE_DIR=/data/workspace` (recommended)
- `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret) - `CLAWDBOT_GATEWAY_TOKEN` (recommended; treat as an admin secret)
@@ -83,9 +82,8 @@ If Telegram DMs are set to pairing, the setup wizard can approve the pairing cod
1) Go to https://discord.com/developers/applications 1) Go to https://discord.com/developers/applications
2) **New Application** → choose a name 2) **New Application** → choose a name
3) **Bot** → **Add Bot** 3) **Bot** → **Add Bot**
4) **Enable MESSAGE CONTENT INTENT** under Bot → Privileged Gateway Intents (required or the bot will crash on startup) 4) Copy the **Bot Token** and paste into `/setup`
5) Copy the **Bot Token** and paste into `/setup` 5) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
6) Invite the bot to your server (OAuth2 URL Generator; scopes: `bot`, `applications.commands`)
## Backups & migration ## Backups & migration
-54
View File
@@ -25,11 +25,9 @@ import { resolveBlueBubblesMessageId } from "./monitor.js";
import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js"; import { probeBlueBubbles, type BlueBubblesProbe } from "./probe.js";
import { sendMessageBlueBubbles } from "./send.js"; import { sendMessageBlueBubbles } from "./send.js";
import { import {
extractHandleFromChatGuid,
looksLikeBlueBubblesTargetId, looksLikeBlueBubblesTargetId,
normalizeBlueBubblesHandle, normalizeBlueBubblesHandle,
normalizeBlueBubblesMessagingTarget, normalizeBlueBubblesMessagingTarget,
parseBlueBubblesTarget,
} from "./targets.js"; } from "./targets.js";
import { bluebubblesMessageActions } from "./actions.js"; import { bluebubblesMessageActions } from "./actions.js";
import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js"; import { monitorBlueBubblesProvider, resolveWebhookPathFromConfig } from "./monitor.js";
@@ -150,58 +148,6 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount> = {
looksLikeId: looksLikeBlueBubblesTargetId, looksLikeId: looksLikeBlueBubblesTargetId,
hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>", hint: "<handle|chat_guid:GUID|chat_id:ID|chat_identifier:ID>",
}, },
formatTargetDisplay: ({ target, display }) => {
const shouldParseDisplay = (value: string): boolean => {
if (looksLikeBlueBubblesTargetId(value)) return true;
return /^(bluebubbles:|chat_guid:|chat_id:|chat_identifier:)/i.test(value);
};
// Helper to extract a clean handle from any BlueBubbles target format
const extractCleanDisplay = (value: string | undefined): string | null => {
const trimmed = value?.trim();
if (!trimmed) return null;
try {
const parsed = parseBlueBubblesTarget(trimmed);
if (parsed.kind === "chat_guid") {
const handle = extractHandleFromChatGuid(parsed.chatGuid);
if (handle) return handle;
}
if (parsed.kind === "handle") {
return normalizeBlueBubblesHandle(parsed.to);
}
} catch {
// Fall through
}
// Strip common prefixes and try raw extraction
const stripped = trimmed
.replace(/^bluebubbles:/i, "")
.replace(/^chat_guid:/i, "")
.replace(/^chat_id:/i, "")
.replace(/^chat_identifier:/i, "");
const handle = extractHandleFromChatGuid(stripped);
if (handle) return handle;
// Don't return raw chat_guid formats - they contain internal routing info
if (stripped.includes(";-;") || stripped.includes(";+;")) return null;
return stripped;
};
// Try to get a clean display from the display parameter first
const trimmedDisplay = display?.trim();
if (trimmedDisplay) {
if (!shouldParseDisplay(trimmedDisplay)) {
return trimmedDisplay;
}
const cleanDisplay = extractCleanDisplay(trimmedDisplay);
if (cleanDisplay) return cleanDisplay;
}
// Fall back to extracting from target
const cleanTarget = extractCleanDisplay(target);
if (cleanTarget) return cleanTarget;
// Last resort: return display or target as-is
return display?.trim() || target?.trim() || "";
},
}, },
setup: { setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
+2 -94
View File
@@ -187,47 +187,6 @@ describe("send", () => {
expect(result).toBe("iMessage;-;+15551234567"); expect(result).toBe("iMessage;-;+15551234567");
}); });
it("returns null when handle only exists in group chat (not DM)", async () => {
// This is the critical fix: if a phone number only exists as a participant in a group chat
// (no direct DM chat), we should NOT send to that group. Return null instead.
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () =>
Promise.resolve({
data: [
{
guid: "iMessage;+;group-the-council",
participants: [
{ address: "+12622102921" },
{ address: "+15550001111" },
{ address: "+15550002222" },
],
},
],
}),
})
// Empty second page to stop pagination
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
});
const target: BlueBubblesSendTarget = {
kind: "handle",
address: "+12622102921",
service: "imessage",
};
const result = await resolveChatGuidForTarget({
baseUrl: "http://localhost:1234",
password: "test",
target,
});
// Should return null, NOT the group chat GUID
expect(result).toBeNull();
});
it("returns null when chat not found", async () => { it("returns null when chat not found", async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: true, ok: true,
@@ -385,14 +344,14 @@ describe("send", () => {
).rejects.toThrow("password is required"); ).rejects.toThrow("password is required");
}); });
it("throws when chatGuid cannot be resolved for non-handle targets", async () => { it("throws when chatGuid cannot be resolved", async () => {
mockFetch.mockResolvedValue({ mockFetch.mockResolvedValue({
ok: true, ok: true,
json: () => Promise.resolve({ data: [] }), json: () => Promise.resolve({ data: [] }),
}); });
await expect( await expect(
sendMessageBlueBubbles("chat_id:999", "Hello", { sendMessageBlueBubbles("+15559999999", "Hello", {
serverUrl: "http://localhost:1234", serverUrl: "http://localhost:1234",
password: "test", password: "test",
}), }),
@@ -439,57 +398,6 @@ describe("send", () => {
expect(body.method).toBeUndefined(); expect(body.method).toBeUndefined();
}); });
it("creates a new chat when handle target is missing", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
text: () =>
Promise.resolve(
JSON.stringify({
data: { guid: "new-msg-guid" },
}),
),
});
const result = await sendMessageBlueBubbles("+15550009999", "Hello new chat", {
serverUrl: "http://localhost:1234",
password: "test",
});
expect(result.messageId).toBe("new-msg-guid");
expect(mockFetch).toHaveBeenCalledTimes(2);
const createCall = mockFetch.mock.calls[1];
expect(createCall[0]).toContain("/api/v1/chat/new");
const body = JSON.parse(createCall[1].body);
expect(body.addresses).toEqual(["+15550009999"]);
expect(body.message).toBe("Hello new chat");
});
it("throws when creating a new chat requires Private API", async () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: false,
status: 403,
text: () => Promise.resolve("Private API not enabled"),
});
await expect(
sendMessageBlueBubbles("+15550008888", "Hello", {
serverUrl: "http://localhost:1234",
password: "test",
}),
).rejects.toThrow("Private API must be enabled");
});
it("uses private-api when reply metadata is present", async () => { it("uses private-api when reply metadata is present", async () => {
mockFetch mockFetch
.mockResolvedValueOnce({ .mockResolvedValueOnce({
+5 -71
View File
@@ -257,17 +257,11 @@ export async function resolveChatGuidForTarget(params: {
return guid; return guid;
} }
if (!participantMatch && guid) { if (!participantMatch && guid) {
// Only consider DM chats (`;-;` separator) as participant matches. const participants = extractParticipantAddresses(chat).map((entry) =>
// Group chats (`;+;` separator) should never match when searching by handle/phone. normalizeBlueBubblesHandle(entry),
// This prevents routing "send to +1234567890" to a group chat that contains that number. );
const isDmChat = guid.includes(";-;"); if (participants.includes(normalizedHandle)) {
if (isDmChat) { participantMatch = guid;
const participants = extractParticipantAddresses(chat).map((entry) =>
normalizeBlueBubblesHandle(entry),
);
if (participants.includes(normalizedHandle)) {
participantMatch = guid;
}
} }
} }
} }
@@ -276,55 +270,6 @@ export async function resolveChatGuidForTarget(params: {
return participantMatch; return participantMatch;
} }
/**
* Creates a new chat (DM) and optionally sends an initial message.
* Requires Private API to be enabled in BlueBubbles.
*/
async function createNewChatWithMessage(params: {
baseUrl: string;
password: string;
address: string;
message: string;
timeoutMs?: number;
}): Promise<BlueBubblesSendResult> {
const url = buildBlueBubblesApiUrl({
baseUrl: params.baseUrl,
path: "/api/v1/chat/new",
password: params.password,
});
const payload = {
addresses: [params.address],
message: params.message,
};
const res = await blueBubblesFetchWithTimeout(
url,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
params.timeoutMs,
);
if (!res.ok) {
const errorText = await res.text();
// Check for Private API not enabled error
if (res.status === 400 || res.status === 403 || errorText.toLowerCase().includes("private api")) {
throw new Error(
`BlueBubbles send failed: Cannot create new chat - Private API must be enabled. Original error: ${errorText || res.status}`,
);
}
throw new Error(`BlueBubbles create chat failed (${res.status}): ${errorText || "unknown"}`);
}
const body = await res.text();
if (!body) return { messageId: "ok" };
try {
const parsed = JSON.parse(body) as unknown;
return { messageId: extractMessageId(parsed) };
} catch {
return { messageId: "ok" };
}
}
export async function sendMessageBlueBubbles( export async function sendMessageBlueBubbles(
to: string, to: string,
text: string, text: string,
@@ -352,17 +297,6 @@ export async function sendMessageBlueBubbles(
target, target,
}); });
if (!chatGuid) { if (!chatGuid) {
// If target is a phone number/handle and no existing chat found,
// auto-create a new DM chat using the /api/v1/chat/new endpoint
if (target.kind === "handle") {
return createNewChatWithMessage({
baseUrl,
password,
address: target.address,
message: trimmedText,
timeoutMs: opts.timeoutMs,
});
}
throw new Error( throw new Error(
"BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.", "BlueBubbles send failed: chatGuid not found for target. Use a chat_guid target or ensure the chat exists.",
); );
-7
View File
@@ -1,12 +1,5 @@
# Changelog # Changelog
## 2026.1.24
### Changes
- Breaking: voice-call TTS now uses core `messages.tts` (plugin TTS config deep‑merges with core).
- Telephony TTS supports OpenAI + ElevenLabs; Edge TTS is ignored for calls.
- Removed legacy `tts.model`/`tts.voice`/`tts.instructions` plugin fields.
## 2026.1.23 ## 2026.1.23
### Changes ### Changes
-21
View File
@@ -75,27 +75,6 @@ Notes:
- Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL. - Twilio/Telnyx/Plivo require a **publicly reachable** webhook URL.
- `mock` is a local dev provider (no network calls). - `mock` is a local dev provider (no network calls).
## TTS for calls
Voice Call uses the core `messages.tts` configuration (OpenAI or ElevenLabs) for
streaming speech on calls. You can override it under the plugin config with the
same shape — overrides deep-merge with `messages.tts`.
```json5
{
tts: {
provider: "openai",
openai: {
voice: "alloy"
}
}
}
```
Notes:
- Edge TTS is ignored for voice calls (telephony audio needs PCM; Edge output is unreliable).
- Core TTS is used when Twilio media streaming is enabled; otherwise calls fall back to provider native voices.
## CLI ## CLI
```bash ```bash
+11 -207
View File
@@ -99,39 +99,16 @@
"label": "Media Stream Path", "label": "Media Stream Path",
"advanced": true "advanced": true
}, },
"tts.provider": { "tts.model": {
"label": "TTS Provider Override", "label": "TTS Model",
"help": "Deep-merges with messages.tts (Edge is ignored for calls).",
"advanced": true "advanced": true
}, },
"tts.openai.model": { "tts.voice": {
"label": "OpenAI TTS Model", "label": "TTS Voice",
"advanced": true "advanced": true
}, },
"tts.openai.voice": { "tts.instructions": {
"label": "OpenAI TTS Voice", "label": "TTS Instructions",
"advanced": true
},
"tts.openai.apiKey": {
"label": "OpenAI API Key",
"sensitive": true,
"advanced": true
},
"tts.elevenlabs.modelId": {
"label": "ElevenLabs Model ID",
"advanced": true
},
"tts.elevenlabs.voiceId": {
"label": "ElevenLabs Voice ID",
"advanced": true
},
"tts.elevenlabs.apiKey": {
"label": "ElevenLabs API Key",
"sensitive": true,
"advanced": true
},
"tts.elevenlabs.baseUrl": {
"label": "ElevenLabs Base URL",
"advanced": true "advanced": true
}, },
"publicUrl": { "publicUrl": {
@@ -393,193 +370,20 @@
"type": "object", "type": "object",
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"auto": {
"type": "string",
"enum": [
"off",
"always",
"inbound",
"tagged"
]
},
"enabled": {
"type": "boolean"
},
"mode": {
"type": "string",
"enum": [
"final",
"all"
]
},
"provider": { "provider": {
"type": "string", "type": "string",
"enum": [ "enum": [
"openai", "openai"
"elevenlabs",
"edge"
] ]
}, },
"summaryModel": { "model": {
"type": "string" "type": "string"
}, },
"modelOverrides": { "voice": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"allowText": {
"type": "boolean"
},
"allowProvider": {
"type": "boolean"
},
"allowVoice": {
"type": "boolean"
},
"allowModelId": {
"type": "boolean"
},
"allowVoiceSettings": {
"type": "boolean"
},
"allowNormalization": {
"type": "boolean"
},
"allowSeed": {
"type": "boolean"
}
}
},
"elevenlabs": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"baseUrl": {
"type": "string"
},
"voiceId": {
"type": "string"
},
"modelId": {
"type": "string"
},
"seed": {
"type": "integer",
"minimum": 0,
"maximum": 4294967295
},
"applyTextNormalization": {
"type": "string",
"enum": [
"auto",
"on",
"off"
]
},
"languageCode": {
"type": "string"
},
"voiceSettings": {
"type": "object",
"additionalProperties": false,
"properties": {
"stability": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"similarityBoost": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"style": {
"type": "number",
"minimum": 0,
"maximum": 1
},
"useSpeakerBoost": {
"type": "boolean"
},
"speed": {
"type": "number",
"minimum": 0.5,
"maximum": 2
}
}
}
}
},
"openai": {
"type": "object",
"additionalProperties": false,
"properties": {
"apiKey": {
"type": "string"
},
"model": {
"type": "string"
},
"voice": {
"type": "string"
}
}
},
"edge": {
"type": "object",
"additionalProperties": false,
"properties": {
"enabled": {
"type": "boolean"
},
"voice": {
"type": "string"
},
"lang": {
"type": "string"
},
"outputFormat": {
"type": "string"
},
"pitch": {
"type": "string"
},
"rate": {
"type": "string"
},
"volume": {
"type": "string"
},
"saveSubtitles": {
"type": "boolean"
},
"proxy": {
"type": "string"
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
}
}
},
"prefsPath": {
"type": "string" "type": "string"
}, },
"maxTextLength": { "instructions": {
"type": "integer", "type": "string"
"minimum": 1
},
"timeoutMs": {
"type": "integer",
"minimum": 1000,
"maximum": 120000
} }
} }
}, },
+3 -21
View File
@@ -74,26 +74,9 @@ const voiceCallConfigSchema = {
}, },
"streaming.sttModel": { label: "Realtime STT Model", advanced: true }, "streaming.sttModel": { label: "Realtime STT Model", advanced: true },
"streaming.streamPath": { label: "Media Stream Path", advanced: true }, "streaming.streamPath": { label: "Media Stream Path", advanced: true },
"tts.provider": { "tts.model": { label: "TTS Model", advanced: true },
label: "TTS Provider Override", "tts.voice": { label: "TTS Voice", advanced: true },
help: "Deep-merges with messages.tts (Edge is ignored for calls).", "tts.instructions": { label: "TTS Instructions", advanced: true },
advanced: true,
},
"tts.openai.model": { label: "OpenAI TTS Model", advanced: true },
"tts.openai.voice": { label: "OpenAI TTS Voice", advanced: true },
"tts.openai.apiKey": {
label: "OpenAI API Key",
sensitive: true,
advanced: true,
},
"tts.elevenlabs.modelId": { label: "ElevenLabs Model ID", advanced: true },
"tts.elevenlabs.voiceId": { label: "ElevenLabs Voice ID", advanced: true },
"tts.elevenlabs.apiKey": {
label: "ElevenLabs API Key",
sensitive: true,
advanced: true,
},
"tts.elevenlabs.baseUrl": { label: "ElevenLabs Base URL", advanced: true },
publicUrl: { label: "Public Webhook URL", advanced: true }, publicUrl: { label: "Public Webhook URL", advanced: true },
skipSignatureVerification: { skipSignatureVerification: {
label: "Skip Signature Verification", label: "Skip Signature Verification",
@@ -178,7 +161,6 @@ const voiceCallPlugin = {
runtimePromise = createVoiceCallRuntime({ runtimePromise = createVoiceCallRuntime({
config: cfg, config: cfg,
coreConfig: api.config as CoreConfig, coreConfig: api.config as CoreConfig,
ttsRuntime: api.runtime.tts,
logger: api.logger, logger: api.logger,
}); });
} }
+22 -73
View File
@@ -82,82 +82,31 @@ export const SttConfigSchema = z
.default({ provider: "openai", model: "whisper-1" }); .default({ provider: "openai", model: "whisper-1" });
export type SttConfig = z.infer<typeof SttConfigSchema>; export type SttConfig = z.infer<typeof SttConfigSchema>;
export const TtsProviderSchema = z.enum(["openai", "elevenlabs", "edge"]);
export const TtsModeSchema = z.enum(["final", "all"]);
export const TtsAutoSchema = z.enum(["off", "always", "inbound", "tagged"]);
export const TtsConfigSchema = z export const TtsConfigSchema = z
.object({ .object({
auto: TtsAutoSchema.optional(), /** TTS provider (currently only OpenAI supported) */
enabled: z.boolean().optional(), provider: z.literal("openai").default("openai"),
mode: TtsModeSchema.optional(), /**
provider: TtsProviderSchema.optional(), * TTS model to use:
summaryModel: z.string().optional(), * - gpt-4o-mini-tts: newest, supports instructions for tone/style control (recommended)
modelOverrides: z * - tts-1: lower latency
.object({ * - tts-1-hd: higher quality
enabled: z.boolean().optional(), */
allowText: z.boolean().optional(), model: z.string().min(1).default("gpt-4o-mini-tts"),
allowProvider: z.boolean().optional(), /**
allowVoice: z.boolean().optional(), * Voice ID. For best quality, use marin or cedar.
allowModelId: z.boolean().optional(), * All voices: alloy, ash, ballad, coral, echo, fable, nova, onyx, sage, shimmer, verse, marin, cedar
allowVoiceSettings: z.boolean().optional(), */
allowNormalization: z.boolean().optional(), voice: z.string().min(1).default("coral"),
allowSeed: z.boolean().optional(), /**
}) * Instructions for speech style (only works with gpt-4o-mini-tts).
.strict() * Examples: "Speak in a cheerful tone", "Talk like a sympathetic customer service agent"
.optional(), */
elevenlabs: z instructions: z.string().optional(),
.object({
apiKey: z.string().optional(),
baseUrl: z.string().optional(),
voiceId: z.string().optional(),
modelId: z.string().optional(),
seed: z.number().int().min(0).max(4294967295).optional(),
applyTextNormalization: z.enum(["auto", "on", "off"]).optional(),
languageCode: z.string().optional(),
voiceSettings: z
.object({
stability: z.number().min(0).max(1).optional(),
similarityBoost: z.number().min(0).max(1).optional(),
style: z.number().min(0).max(1).optional(),
useSpeakerBoost: z.boolean().optional(),
speed: z.number().min(0.5).max(2).optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
openai: z
.object({
apiKey: z.string().optional(),
model: z.string().optional(),
voice: z.string().optional(),
})
.strict()
.optional(),
edge: z
.object({
enabled: z.boolean().optional(),
voice: z.string().optional(),
lang: z.string().optional(),
outputFormat: z.string().optional(),
pitch: z.string().optional(),
rate: z.string().optional(),
volume: z.string().optional(),
saveSubtitles: z.boolean().optional(),
proxy: z.string().optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
})
.strict()
.optional(),
prefsPath: z.string().optional(),
maxTextLength: z.number().int().min(1).optional(),
timeoutMs: z.number().int().min(1000).max(120000).optional(),
}) })
.strict() .strict()
.optional(); .default({ provider: "openai", model: "gpt-4o-mini-tts", voice: "coral" });
export type VoiceCallTtsConfig = z.infer<typeof TtsConfigSchema>; export type TtsConfig = z.infer<typeof TtsConfigSchema>;
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Webhook Server Configuration // Webhook Server Configuration
@@ -358,7 +307,7 @@ export const VoiceCallConfigSchema = z
/** STT configuration */ /** STT configuration */
stt: SttConfigSchema, stt: SttConfigSchema,
/** TTS override (deep-merges with core messages.tts) */ /** TTS configuration */
tts: TtsConfigSchema, tts: TtsConfigSchema,
/** Store path for call logs */ /** Store path for call logs */
-6
View File
@@ -2,16 +2,10 @@ import fs from "node:fs";
import path from "node:path"; import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import type { VoiceCallTtsConfig } from "./config.js";
export type CoreConfig = { export type CoreConfig = {
session?: { session?: {
store?: string; store?: string;
}; };
messages?: {
tts?: VoiceCallTtsConfig;
};
[key: string]: unknown;
}; };
type CoreAgentDeps = { type CoreAgentDeps = {
+2 -4
View File
@@ -143,7 +143,7 @@ export class CallManager {
// For notify mode with a message, use inline TwiML with <Say> // For notify mode with a message, use inline TwiML with <Say>
let inlineTwiml: string | undefined; let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) { if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(this.config.tts?.openai?.voice); const pollyVoice = mapVoiceToPolly(this.config.tts.voice);
inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice); inlineTwiml = this.generateNotifyTwiml(initialMessage, pollyVoice);
console.log( console.log(
`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`, `[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`,
@@ -210,13 +210,11 @@ export class CallManager {
this.addTranscriptEntry(call, "bot", text); this.addTranscriptEntry(call, "bot", text);
// Play TTS // Play TTS
const voice =
this.provider?.name === "twilio" ? this.config.tts?.openai?.voice : undefined;
await this.provider.playTts({ await this.provider.playTts({
callId, callId,
providerCallId: call.providerCallId, providerCallId: call.providerCallId,
text, text,
voice, voice: this.config.tts.voice,
}); });
return { success: true }; return { success: true };
@@ -19,3 +19,4 @@ export type CallManagerContext = {
transcriptWaiters: Map<CallId, TranscriptWaiter>; transcriptWaiters: Map<CallId, TranscriptWaiter>;
maxDurationTimers: Map<CallId, NodeJS.Timeout>; maxDurationTimers: Map<CallId, NodeJS.Timeout>;
}; };
@@ -175,3 +175,4 @@ export function processEvent(ctx: CallManagerContext, event: NormalizedEvent): v
persistCallRecord(ctx.storePath, call); persistCallRecord(ctx.storePath, call);
} }
@@ -31,3 +31,4 @@ export function findCall(params: {
providerCallId: params.callIdOrProviderCallId, providerCallId: params.callIdOrProviderCallId,
}); });
} }
@@ -68,7 +68,7 @@ export async function initiateCall(
// For notify mode with a message, use inline TwiML with <Say>. // For notify mode with a message, use inline TwiML with <Say>.
let inlineTwiml: string | undefined; let inlineTwiml: string | undefined;
if (mode === "notify" && initialMessage) { if (mode === "notify" && initialMessage) {
const pollyVoice = mapVoiceToPolly(ctx.config.tts?.openai?.voice); const pollyVoice = mapVoiceToPolly(ctx.config.tts.voice);
inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice); inlineTwiml = generateNotifyTwiml(initialMessage, pollyVoice);
console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`); console.log(`[voice-call] Using inline TwiML for notify mode (voice: ${pollyVoice})`);
} }
@@ -120,13 +120,11 @@ export async function speak(
addTranscriptEntry(call, "bot", text); addTranscriptEntry(call, "bot", text);
const voice =
ctx.provider?.name === "twilio" ? ctx.config.tts?.openai?.voice : undefined;
await ctx.provider.playTts({ await ctx.provider.playTts({
callId, callId,
providerCallId: call.providerCallId, providerCallId: call.providerCallId,
text, text,
voice, voice: ctx.config.tts.voice,
}); });
return { success: true }; return { success: true };
@@ -246,3 +244,4 @@ export async function endCall(
return { success: false, error: err instanceof Error ? err.message : String(err) }; return { success: false, error: err instanceof Error ? err.message : String(err) };
} }
} }
@@ -48,3 +48,4 @@ export function addTranscriptEntry(
}; };
call.transcript.push(entry); call.transcript.push(entry);
} }
@@ -86,3 +86,4 @@ export async function getCallHistoryFromStore(
return calls; return calls;
} }
@@ -84,3 +84,4 @@ export function waitForFinalTranscript(
ctx.transcriptWaiters.set(callId, { resolve, reject, timeout }); ctx.transcriptWaiters.set(callId, { resolve, reject, timeout });
}); });
} }
@@ -7,3 +7,4 @@ export function generateNotifyTwiml(message: string, voice: string): string {
<Hangup/> <Hangup/>
</Response>`; </Response>`;
} }
@@ -26,3 +26,4 @@ describe("PlivoProvider", () => {
expect(result.providerResponseBody).toContain('length="300"'); expect(result.providerResponseBody).toContain('length="300"');
}); });
}); });
+14 -14
View File
@@ -15,9 +15,9 @@ import type {
WebhookVerificationResult, WebhookVerificationResult,
} from "../types.js"; } from "../types.js";
import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js"; import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js";
import { chunkAudio } from "../telephony-audio.js";
import type { TelephonyTtsProvider } from "../telephony-tts.js";
import type { VoiceCallProvider } from "./base.js"; import type { VoiceCallProvider } from "./base.js";
import type { OpenAITTSProvider } from "./tts-openai.js";
import { chunkAudio } from "./tts-openai.js";
import { twilioApiRequest } from "./twilio/api.js"; import { twilioApiRequest } from "./twilio/api.js";
import { verifyTwilioProviderWebhook } from "./twilio/webhook.js"; import { verifyTwilioProviderWebhook } from "./twilio/webhook.js";
@@ -53,8 +53,8 @@ export class TwilioProvider implements VoiceCallProvider {
/** Current public webhook URL (set when tunnel starts or from config) */ /** Current public webhook URL (set when tunnel starts or from config) */
private currentPublicUrl: string | null = null; private currentPublicUrl: string | null = null;
/** Optional telephony TTS provider for streaming TTS */ /** Optional OpenAI TTS provider for streaming TTS */
private ttsProvider: TelephonyTtsProvider | null = null; private ttsProvider: OpenAITTSProvider | null = null;
/** Optional media stream handler for sending audio */ /** Optional media stream handler for sending audio */
private mediaStreamHandler: MediaStreamHandler | null = null; private mediaStreamHandler: MediaStreamHandler | null = null;
@@ -119,7 +119,7 @@ export class TwilioProvider implements VoiceCallProvider {
return this.currentPublicUrl; return this.currentPublicUrl;
} }
setTTSProvider(provider: TelephonyTtsProvider): void { setTTSProvider(provider: OpenAITTSProvider): void {
this.ttsProvider = provider; this.ttsProvider = provider;
} }
@@ -454,13 +454,13 @@ export class TwilioProvider implements VoiceCallProvider {
* Play TTS audio via Twilio. * Play TTS audio via Twilio.
* *
* Two modes: * Two modes:
* 1. Core TTS + Media Streams: If TTS provider and media stream are available, * 1. OpenAI TTS + Media Streams: If TTS provider and media stream are available,
* generates audio via core TTS and streams it through WebSocket (preferred). * generates audio via OpenAI and streams it through WebSocket (preferred).
* 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices. * 2. TwiML <Say>: Falls back to Twilio's native TTS with Polly voices.
* Note: This may not work on all Twilio accounts. * Note: This may not work on all Twilio accounts.
*/ */
async playTts(input: PlayTtsInput): Promise<void> { async playTts(input: PlayTtsInput): Promise<void> {
// Try telephony TTS via media stream first (if configured) // Try OpenAI TTS via media stream first (if configured)
const streamSid = this.callStreamMap.get(input.providerCallId); const streamSid = this.callStreamMap.get(input.providerCallId);
if (this.ttsProvider && this.mediaStreamHandler && streamSid) { if (this.ttsProvider && this.mediaStreamHandler && streamSid) {
try { try {
@@ -468,7 +468,7 @@ export class TwilioProvider implements VoiceCallProvider {
return; return;
} catch (err) { } catch (err) {
console.warn( console.warn(
`[voice-call] Telephony TTS failed, falling back to Twilio <Say>:`, `[voice-call] OpenAI TTS failed, falling back to Twilio <Say>:`,
err instanceof Error ? err.message : err, err instanceof Error ? err.message : err,
); );
// Fall through to TwiML <Say> fallback // Fall through to TwiML <Say> fallback
@@ -484,7 +484,7 @@ export class TwilioProvider implements VoiceCallProvider {
} }
console.warn( console.warn(
"[voice-call] Using TwiML <Say> fallback - telephony TTS not configured or media stream not active", "[voice-call] Using TwiML <Say> fallback - OpenAI TTS not configured or media stream not active",
); );
const pollyVoice = mapVoiceToPolly(input.voice); const pollyVoice = mapVoiceToPolly(input.voice);
@@ -502,8 +502,8 @@ export class TwilioProvider implements VoiceCallProvider {
} }
/** /**
* Play TTS via core TTS and Twilio Media Streams. * Play TTS via OpenAI and Twilio Media Streams.
* Generates audio with core TTS, converts to mu-law, and streams via WebSocket. * Generates audio with OpenAI TTS, converts to mu-law, and streams via WebSocket.
* Uses a jitter buffer to smooth out timing variations. * Uses a jitter buffer to smooth out timing variations.
*/ */
private async playTtsViaStream( private async playTtsViaStream(
@@ -514,8 +514,8 @@ export class TwilioProvider implements VoiceCallProvider {
throw new Error("TTS provider and media stream handler required"); throw new Error("TTS provider and media stream handler required");
} }
// Generate audio with core TTS (returns mu-law at 8kHz) // Generate audio with OpenAI TTS (returns mu-law at 8kHz)
const muLawAudio = await this.ttsProvider.synthesizeForTelephony(text); const muLawAudio = await this.ttsProvider.synthesizeForTwilio(text);
// Stream audio in 20ms chunks (160 bytes at 8kHz mu-law) // Stream audio in 20ms chunks (160 bytes at 8kHz mu-law)
const CHUNK_SIZE = 160; const CHUNK_SIZE = 160;
@@ -27,3 +27,4 @@ export function verifyTwilioProviderWebhook(params: {
reason: result.reason, reason: result.reason,
}; };
} }
+13 -12
View File
@@ -6,9 +6,8 @@ import type { VoiceCallProvider } from "./providers/base.js";
import { MockProvider } from "./providers/mock.js"; import { MockProvider } from "./providers/mock.js";
import { PlivoProvider } from "./providers/plivo.js"; import { PlivoProvider } from "./providers/plivo.js";
import { TelnyxProvider } from "./providers/telnyx.js"; import { TelnyxProvider } from "./providers/telnyx.js";
import { OpenAITTSProvider } from "./providers/tts-openai.js";
import { TwilioProvider } from "./providers/twilio.js"; import { TwilioProvider } from "./providers/twilio.js";
import type { TelephonyTtsRuntime } from "./telephony-tts.js";
import { createTelephonyTtsProvider } from "./telephony-tts.js";
import { startTunnel, type TunnelResult } from "./tunnel.js"; import { startTunnel, type TunnelResult } from "./tunnel.js";
import { import {
cleanupTailscaleExposure, cleanupTailscaleExposure,
@@ -82,10 +81,9 @@ function resolveProvider(config: VoiceCallConfig): VoiceCallProvider {
export async function createVoiceCallRuntime(params: { export async function createVoiceCallRuntime(params: {
config: VoiceCallConfig; config: VoiceCallConfig;
coreConfig: CoreConfig; coreConfig: CoreConfig;
ttsRuntime?: TelephonyTtsRuntime;
logger?: Logger; logger?: Logger;
}): Promise<VoiceCallRuntime> { }): Promise<VoiceCallRuntime> {
const { config, coreConfig, ttsRuntime, logger } = params; const { config, coreConfig, logger } = params;
const log = logger ?? { const log = logger ?? {
info: console.log, info: console.log,
warn: console.warn, warn: console.warn,
@@ -151,24 +149,27 @@ export async function createVoiceCallRuntime(params: {
if (provider.name === "twilio" && config.streaming?.enabled) { if (provider.name === "twilio" && config.streaming?.enabled) {
const twilioProvider = provider as TwilioProvider; const twilioProvider = provider as TwilioProvider;
if (ttsRuntime?.textToSpeechTelephony) { const openaiApiKey =
config.streaming.openaiApiKey || process.env.OPENAI_API_KEY;
if (openaiApiKey) {
try { try {
const ttsProvider = createTelephonyTtsProvider({ const ttsProvider = new OpenAITTSProvider({
coreConfig, apiKey: openaiApiKey,
ttsOverride: config.tts, voice: config.tts.voice,
runtime: ttsRuntime, model: config.tts.model,
instructions: config.tts.instructions,
}); });
twilioProvider.setTTSProvider(ttsProvider); twilioProvider.setTTSProvider(ttsProvider);
log.info("[voice-call] Telephony TTS provider configured"); log.info("[voice-call] OpenAI TTS provider configured");
} catch (err) { } catch (err) {
log.warn( log.warn(
`[voice-call] Failed to initialize telephony TTS: ${ `[voice-call] Failed to initialize OpenAI TTS: ${
err instanceof Error ? err.message : String(err) err instanceof Error ? err.message : String(err)
}`, }`,
); );
} }
} else { } else {
log.warn("[voice-call] Telephony TTS unavailable; streaming TTS disabled"); log.warn("[voice-call] OpenAI TTS key missing; streaming TTS disabled");
} }
const mediaHandler = webhookServer.getMediaStreamHandler(); const mediaHandler = webhookServer.getMediaStreamHandler();
@@ -1,88 +0,0 @@
const TELEPHONY_SAMPLE_RATE = 8000;
function clamp16(value: number): number {
return Math.max(-32768, Math.min(32767, value));
}
/**
* Resample 16-bit PCM (little-endian mono) to 8kHz using linear interpolation.
*/
export function resamplePcmTo8k(input: Buffer, inputSampleRate: number): Buffer {
if (inputSampleRate === TELEPHONY_SAMPLE_RATE) return input;
const inputSamples = Math.floor(input.length / 2);
if (inputSamples === 0) return Buffer.alloc(0);
const ratio = inputSampleRate / TELEPHONY_SAMPLE_RATE;
const outputSamples = Math.floor(inputSamples / ratio);
const output = Buffer.alloc(outputSamples * 2);
for (let i = 0; i < outputSamples; i++) {
const srcPos = i * ratio;
const srcIndex = Math.floor(srcPos);
const frac = srcPos - srcIndex;
const s0 = input.readInt16LE(srcIndex * 2);
const s1Index = Math.min(srcIndex + 1, inputSamples - 1);
const s1 = input.readInt16LE(s1Index * 2);
const sample = Math.round(s0 + frac * (s1 - s0));
output.writeInt16LE(clamp16(sample), i * 2);
}
return output;
}
/**
* Convert 16-bit PCM to 8-bit mu-law (G.711).
*/
export function pcmToMulaw(pcm: Buffer): Buffer {
const samples = Math.floor(pcm.length / 2);
const mulaw = Buffer.alloc(samples);
for (let i = 0; i < samples; i++) {
const sample = pcm.readInt16LE(i * 2);
mulaw[i] = linearToMulaw(sample);
}
return mulaw;
}
export function convertPcmToMulaw8k(
pcm: Buffer,
inputSampleRate: number,
): Buffer {
const pcm8k = resamplePcmTo8k(pcm, inputSampleRate);
return pcmToMulaw(pcm8k);
}
/**
* Chunk audio buffer into 20ms frames for streaming (8kHz mono mu-law).
*/
export function chunkAudio(
audio: Buffer,
chunkSize = 160,
): Generator<Buffer, void, unknown> {
return (function* () {
for (let i = 0; i < audio.length; i += chunkSize) {
yield audio.subarray(i, Math.min(i + chunkSize, audio.length));
}
})();
}
function linearToMulaw(sample: number): number {
const BIAS = 132;
const CLIP = 32635;
const sign = sample < 0 ? 0x80 : 0;
if (sample < 0) sample = -sample;
if (sample > CLIP) sample = CLIP;
sample += BIAS;
let exponent = 7;
for (let expMask = 0x4000; (sample & expMask) === 0 && exponent > 0; exponent--) {
expMask >>= 1;
}
const mantissa = (sample >> (exponent + 3)) & 0x0f;
return ~(sign | (exponent << 4) | mantissa) & 0xff;
}
@@ -1,95 +0,0 @@
import type { CoreConfig } from "./core-bridge.js";
import type { VoiceCallTtsConfig } from "./config.js";
import { convertPcmToMulaw8k } from "./telephony-audio.js";
export type TelephonyTtsRuntime = {
textToSpeechTelephony: (params: {
text: string;
cfg: CoreConfig;
prefsPath?: string;
}) => Promise<{
success: boolean;
audioBuffer?: Buffer;
sampleRate?: number;
provider?: string;
error?: string;
}>;
};
export type TelephonyTtsProvider = {
synthesizeForTelephony: (text: string) => Promise<Buffer>;
};
export function createTelephonyTtsProvider(params: {
coreConfig: CoreConfig;
ttsOverride?: VoiceCallTtsConfig;
runtime: TelephonyTtsRuntime;
}): TelephonyTtsProvider {
const { coreConfig, ttsOverride, runtime } = params;
const mergedConfig = applyTtsOverride(coreConfig, ttsOverride);
return {
synthesizeForTelephony: async (text: string) => {
const result = await runtime.textToSpeechTelephony({
text,
cfg: mergedConfig,
});
if (!result.success || !result.audioBuffer || !result.sampleRate) {
throw new Error(result.error ?? "TTS conversion failed");
}
return convertPcmToMulaw8k(result.audioBuffer, result.sampleRate);
},
};
}
function applyTtsOverride(
coreConfig: CoreConfig,
override?: VoiceCallTtsConfig,
): CoreConfig {
if (!override) return coreConfig;
const base = coreConfig.messages?.tts;
const merged = mergeTtsConfig(base, override);
if (!merged) return coreConfig;
return {
...coreConfig,
messages: {
...(coreConfig.messages ?? {}),
tts: merged,
},
};
}
function mergeTtsConfig(
base?: VoiceCallTtsConfig,
override?: VoiceCallTtsConfig,
): VoiceCallTtsConfig | undefined {
if (!base && !override) return undefined;
if (!override) return base;
if (!base) return override;
return deepMerge(base, override);
}
function deepMerge<T>(base: T, override: T): T {
if (!isPlainObject(base) || !isPlainObject(override)) {
return override;
}
const result: Record<string, unknown> = { ...base };
for (const [key, value] of Object.entries(override)) {
if (value === undefined) continue;
const existing = (base as Record<string, unknown>)[key];
if (isPlainObject(existing) && isPlainObject(value)) {
result[key] = deepMerge(existing, value);
} else {
result[key] = value;
}
}
return result as T;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}
+1
View File
@@ -15,3 +15,4 @@ describe("zalouser outbound chunker", () => {
expect(chunks.every((c) => c.length <= limit)).toBe(true); expect(chunks.every((c) => c.length <= limit)).toBe(true);
}); });
}); });
-31
View File
@@ -1,31 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
if [[ $# -lt 1 ]]; then
echo "usage: run-node-tool.sh <tool> [args...]" >&2
exit 2
fi
tool="$1"
shift
if [[ -f "$ROOT_DIR/pnpm-lock.yaml" ]] && command -v pnpm >/dev/null 2>&1; then
exec pnpm exec "$tool" "$@"
fi
if { [[ -f "$ROOT_DIR/bun.lockb" ]] || [[ -f "$ROOT_DIR/bun.lock" ]]; } && command -v bun >/dev/null 2>&1; then
exec bunx --bun "$tool" "$@"
fi
if command -v npm >/dev/null 2>&1; then
exec npm exec -- "$tool" "$@"
fi
if command -v npx >/dev/null 2>&1; then
exec npx "$tool" "$@"
fi
echo "Missing package manager: pnpm, bun, or npm required." >&2
exit 1
+1
View File
@@ -30,3 +30,4 @@ export type Entry = {
avatar_url: string; avatar_url: string;
lines: number; lines: number;
}; };
+25 -73
View File
@@ -1,5 +1,5 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import type { ChildProcessWithoutNullStreams } from "node:child_process"; import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import path from "node:path"; import path from "node:path";
import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox"; import { Type } from "@sinclair/typebox";
@@ -27,7 +27,6 @@ import {
} from "../infra/shell-env.js"; } from "../infra/shell-env.js";
import { enqueueSystemEvent } from "../infra/system-events.js"; import { enqueueSystemEvent } from "../infra/system-events.js";
import { logInfo, logWarn } from "../logger.js"; import { logInfo, logWarn } from "../logger.js";
import { formatSpawnError, spawnWithFallback } from "../process/spawn-utils.js";
import { import {
type ProcessSession, type ProcessSession,
type SessionStdin, type SessionStdin,
@@ -363,38 +362,23 @@ async function runExecProcess(opts: {
let stdin: SessionStdin | undefined; let stdin: SessionStdin | undefined;
if (opts.sandbox) { if (opts.sandbox) {
const { child: spawned } = await spawnWithFallback({ child = spawn(
argv: [ "docker",
"docker", buildDockerExecArgs({
...buildDockerExecArgs({ containerName: opts.sandbox.containerName,
containerName: opts.sandbox.containerName, command: opts.command,
command: opts.command, workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir,
workdir: opts.containerWorkdir ?? opts.sandbox.containerWorkdir, env: opts.env,
env: opts.env, tty: opts.usePty,
tty: opts.usePty, }),
}), {
],
options: {
cwd: opts.workdir, cwd: opts.workdir,
env: process.env, env: process.env,
detached: process.platform !== "win32", detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
windowsHide: true, windowsHide: true,
}, },
fallbacks: [ ) as ChildProcessWithoutNullStreams;
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (err, fallback) => {
const errText = formatSpawnError(err);
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`);
opts.warnings.push(warning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin; stdin = child.stdin;
} else if (opts.usePty) { } else if (opts.usePty) {
const { shell, args: shellArgs } = getShellConfig(); const { shell, args: shellArgs } = getShellConfig();
@@ -438,56 +422,24 @@ async function runExecProcess(opts: {
const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`; const warning = `Warning: PTY spawn failed (${errText}); retrying without PTY for \`${opts.command}\`.`;
logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`); logWarn(`exec: PTY spawn failed (${errText}); retrying without PTY for "${opts.command}".`);
opts.warnings.push(warning); opts.warnings.push(warning);
const { child: spawned } = await spawnWithFallback({ child = spawn(shell, [...shellArgs, opts.command], {
argv: [shell, ...shellArgs, opts.command],
options: {
cwd: opts.workdir,
env: opts.env,
detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"],
windowsHide: true,
},
fallbacks: [
{
label: "no-detach",
options: { detached: false },
},
],
onFallback: (fallbackErr, fallback) => {
const fallbackText = formatSpawnError(fallbackErr);
const fallbackWarning = `Warning: spawn failed (${fallbackText}); retrying with ${fallback.label}.`;
logWarn(`exec: spawn failed (${fallbackText}); retrying with ${fallback.label}.`);
opts.warnings.push(fallbackWarning);
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin;
}
} else {
const { shell, args: shellArgs } = getShellConfig();
const { child: spawned } = await spawnWithFallback({
argv: [shell, ...shellArgs, opts.command],
options: {
cwd: opts.workdir, cwd: opts.workdir,
env: opts.env, env: opts.env,
detached: process.platform !== "win32", detached: process.platform !== "win32",
stdio: ["pipe", "pipe", "pipe"], stdio: ["pipe", "pipe", "pipe"],
windowsHide: true, windowsHide: true,
}, }) as ChildProcessWithoutNullStreams;
fallbacks: [ stdin = child.stdin;
{ }
label: "no-detach", } else {
options: { detached: false }, const { shell, args: shellArgs } = getShellConfig();
}, child = spawn(shell, [...shellArgs, opts.command], {
], cwd: opts.workdir,
onFallback: (err, fallback) => { env: opts.env,
const errText = formatSpawnError(err); detached: process.platform !== "win32",
const warning = `Warning: spawn failed (${errText}); retrying with ${fallback.label}.`; stdio: ["pipe", "pipe", "pipe"],
logWarn(`exec: spawn failed (${errText}); retrying with ${fallback.label}.`); windowsHide: true,
opts.warnings.push(warning); }) as ChildProcessWithoutNullStreams;
},
});
child = spawned as ChildProcessWithoutNullStreams;
stdin = child.stdin; stdin = child.stdin;
} }
+1 -30
View File
@@ -8,7 +8,6 @@ export type ModelCatalogEntry = {
provider: string; provider: string;
contextWindow?: number; contextWindow?: number;
reasoning?: boolean; reasoning?: boolean;
input?: Array<"text" | "image">;
}; };
type DiscoveredModel = { type DiscoveredModel = {
@@ -17,7 +16,6 @@ type DiscoveredModel = {
provider: string; provider: string;
contextWindow?: number; contextWindow?: number;
reasoning?: boolean; reasoning?: boolean;
input?: Array<"text" | "image">;
}; };
type PiSdkModule = typeof import("@mariozechner/pi-coding-agent"); type PiSdkModule = typeof import("@mariozechner/pi-coding-agent");
@@ -82,10 +80,7 @@ export async function loadModelCatalog(params?: {
? entry.contextWindow ? entry.contextWindow
: undefined; : undefined;
const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined; const reasoning = typeof entry?.reasoning === "boolean" ? entry.reasoning : undefined;
const input = Array.isArray(entry?.input) models.push({ id, name, provider, contextWindow, reasoning });
? (entry.input as Array<"text" | "image">)
: undefined;
models.push({ id, name, provider, contextWindow, reasoning, input });
} }
if (models.length === 0) { if (models.length === 0) {
@@ -110,27 +105,3 @@ export async function loadModelCatalog(params?: {
return modelCatalogPromise; return modelCatalogPromise;
} }
/**
* Check if a model supports image input based on its catalog entry.
*/
export function modelSupportsVision(entry: ModelCatalogEntry | undefined): boolean {
return entry?.input?.includes("image") ?? false;
}
/**
* Find a model in the catalog by provider and model ID.
*/
export function findModelInCatalog(
catalog: ModelCatalogEntry[],
provider: string,
modelId: string,
): ModelCatalogEntry | undefined {
const normalizedProvider = provider.toLowerCase().trim();
const normalizedModelId = modelId.toLowerCase().trim();
return catalog.find(
(entry) =>
entry.provider.toLowerCase() === normalizedProvider &&
entry.id.toLowerCase() === normalizedModelId,
);
}
@@ -27,14 +27,4 @@ describe("sanitizeUserFacingText", () => {
const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}'; const raw = '{"type":"error","error":{"message":"Something exploded","type":"server_error"}}';
expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded"); expect(sanitizeUserFacingText(raw)).toBe("LLM error server_error: Something exploded");
}); });
it("collapses consecutive duplicate paragraphs", () => {
const text = "Hello there!\n\nHello there!";
expect(sanitizeUserFacingText(text)).toBe("Hello there!");
});
it("does not collapse distinct paragraphs", () => {
const text = "Hello there!\n\nDifferent line.";
expect(sanitizeUserFacingText(text)).toBe(text);
});
}); });
+1 -24
View File
@@ -77,29 +77,6 @@ function stripFinalTagsFromText(text: string): string {
return text.replace(FINAL_TAG_RE, ""); return text.replace(FINAL_TAG_RE, "");
} }
function collapseConsecutiveDuplicateBlocks(text: string): string {
const trimmed = text.trim();
if (!trimmed) return text;
const blocks = trimmed.split(/\n{2,}/);
if (blocks.length < 2) return text;
const normalizeBlock = (value: string) => value.trim().replace(/\s+/g, " ");
const result: string[] = [];
let lastNormalized: string | null = null;
for (const block of blocks) {
const normalized = normalizeBlock(block);
if (lastNormalized && normalized === lastNormalized) {
continue;
}
result.push(block.trim());
lastNormalized = normalized;
}
if (result.length === blocks.length) return text;
return result.join("\n\n");
}
function isLikelyHttpErrorText(raw: string): boolean { function isLikelyHttpErrorText(raw: string): boolean {
const match = raw.match(HTTP_STATUS_PREFIX_RE); const match = raw.match(HTTP_STATUS_PREFIX_RE);
if (!match) return false; if (!match) return false;
@@ -344,7 +321,7 @@ export function sanitizeUserFacingText(text: string): string {
return formatRawAssistantErrorForUi(trimmed); return formatRawAssistantErrorForUi(trimmed);
} }
return collapseConsecutiveDuplicateBlocks(stripped); return stripped;
} }
export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean { export function isRateLimitAssistantError(msg: AssistantMessage | undefined): boolean {
+1 -11
View File
@@ -333,13 +333,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
name: "message", name: "message",
description, description,
parameters: schema, parameters: schema,
execute: async (_toolCallId, args, signal) => { execute: async (_toolCallId, args) => {
// Check if already aborted before doing any work
if (signal?.aborted) {
const err = new Error("Message send aborted");
err.name = "AbortError";
throw err;
}
const params = args as Record<string, unknown>; const params = args as Record<string, unknown>;
const cfg = options?.config ?? loadConfig(); const cfg = options?.config ?? loadConfig();
const action = readStringParam(params, "action", { const action = readStringParam(params, "action", {
@@ -372,9 +366,6 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
currentThreadTs: options?.currentThreadTs, currentThreadTs: options?.currentThreadTs,
replyToMode: options?.replyToMode, replyToMode: options?.replyToMode,
hasRepliedRef: options?.hasRepliedRef, hasRepliedRef: options?.hasRepliedRef,
// Direct tool invocations should not add cross-context decoration.
// The agent is composing a message, not forwarding from another chat.
skipCrossContextDecoration: true,
} }
: undefined; : undefined;
@@ -388,7 +379,6 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
agentId: options?.agentSessionKey agentId: options?.agentSessionKey
? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg }) ? resolveSessionAgentId({ sessionKey: options.agentSessionKey, config: cfg })
: undefined, : undefined,
abortSignal: signal,
}); });
const toolResult = getToolResult(result); const toolResult = getToolResult(result);
+4 -10
View File
@@ -10,17 +10,11 @@ describe("extractModelDirective", () => {
expect(result.cleaned).toBe(""); expect(result.cleaned).toBe("");
}); });
it("does not treat /models as a /model directive", () => { it("extracts /models with argument", () => {
const result = extractModelDirective("/models gpt-5"); const result = extractModelDirective("/models gpt-5");
expect(result.hasDirective).toBe(false); expect(result.hasDirective).toBe(true);
expect(result.rawModel).toBeUndefined(); expect(result.rawModel).toBe("gpt-5");
expect(result.cleaned).toBe("/models gpt-5"); expect(result.cleaned).toBe("");
});
it("does not parse /models as a /model directive (no args)", () => {
const result = extractModelDirective("/models");
expect(result.hasDirective).toBe(false);
expect(result.cleaned).toBe("/models");
}); });
it("extracts /model with provider/model format", () => { it("extracts /model with provider/model format", () => {
+1 -1
View File
@@ -14,7 +14,7 @@ export function extractModelDirective(
if (!body) return { cleaned: "", hasDirective: false }; if (!body) return { cleaned: "", hasDirective: false };
const modelMatch = body.match( const modelMatch = body.match(
/(?:^|\s)\/model(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i, /(?:^|\s)\/models?(?=$|\s|:)\s*:?\s*([A-Za-z0-9_.:@-]+(?:\/[A-Za-z0-9_.:@-]+)*)?/i,
); );
const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean); const aliases = (options?.aliases ?? []).map((alias) => alias.trim()).filter(Boolean);
+2 -19
View File
@@ -1,8 +1,7 @@
import type { NoticeLevel, ReasoningLevel } from "../thinking.js"; import type { ReasoningLevel } from "../thinking.js";
import { import {
type ElevatedLevel, type ElevatedLevel,
normalizeElevatedLevel, normalizeElevatedLevel,
normalizeNoticeLevel,
normalizeReasoningLevel, normalizeReasoningLevel,
normalizeThinkLevel, normalizeThinkLevel,
normalizeVerboseLevel, normalizeVerboseLevel,
@@ -113,22 +112,6 @@ export function extractVerboseDirective(body?: string): {
}; };
} }
export function extractNoticeDirective(body?: string): {
cleaned: string;
noticeLevel?: NoticeLevel;
rawLevel?: string;
hasDirective: boolean;
} {
if (!body) return { cleaned: "", hasDirective: false };
const extracted = extractLevelDirective(body, ["notice", "notices"], normalizeNoticeLevel);
return {
cleaned: extracted.cleaned,
noticeLevel: extracted.level,
rawLevel: extracted.rawLevel,
hasDirective: extracted.hasDirective,
};
}
export function extractElevatedDirective(body?: string): { export function extractElevatedDirective(body?: string): {
cleaned: string; cleaned: string;
elevatedLevel?: ElevatedLevel; elevatedLevel?: ElevatedLevel;
@@ -169,5 +152,5 @@ export function extractStatusDirective(body?: string): {
return extractSimpleDirective(body, ["status"]); return extractSimpleDirective(body, ["status"]);
} }
export type { ElevatedLevel, NoticeLevel, ReasoningLevel, ThinkLevel, VerboseLevel }; export type { ElevatedLevel, ReasoningLevel, ThinkLevel, VerboseLevel };
export { extractExecDirective } from "./exec/directive.js"; export { extractExecDirective } from "./exec/directive.js";
-11
View File
@@ -1,6 +1,5 @@
export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; export type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh";
export type VerboseLevel = "off" | "on" | "full"; export type VerboseLevel = "off" | "on" | "full";
export type NoticeLevel = "off" | "on" | "full";
export type ElevatedLevel = "off" | "on" | "ask" | "full"; export type ElevatedLevel = "off" | "on" | "ask" | "full";
export type ElevatedMode = "off" | "ask" | "full"; export type ElevatedMode = "off" | "ask" | "full";
export type ReasoningLevel = "off" | "on" | "stream"; export type ReasoningLevel = "off" | "on" | "stream";
@@ -94,16 +93,6 @@ export function normalizeVerboseLevel(raw?: string | null): VerboseLevel | undef
return undefined; return undefined;
} }
// Normalize system notice flags used to toggle system notifications.
export function normalizeNoticeLevel(raw?: string | null): NoticeLevel | undefined {
if (!raw) return undefined;
const key = raw.toLowerCase();
if (["off", "false", "no", "0"].includes(key)) return "off";
if (["full", "all", "everything"].includes(key)) return "full";
if (["on", "minimal", "true", "yes", "1"].includes(key)) return "on";
return undefined;
}
// Normalize response-usage display modes used to toggle per-response usage footers. // Normalize response-usage display modes used to toggle per-response usage footers.
export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined { export function normalizeUsageDisplay(raw?: string | null): UsageDisplayLevel | undefined {
if (!raw) return undefined; if (!raw) return undefined;
-6
View File
@@ -240,12 +240,6 @@ export type ChannelThreadingToolContext = {
currentThreadTs?: string; currentThreadTs?: string;
replyToMode?: "off" | "first" | "all"; replyToMode?: "off" | "first" | "all";
hasRepliedRef?: { value: boolean }; hasRepliedRef?: { value: boolean };
/**
* When true, skip cross-context decoration (e.g., "[from X]" prefix).
* Use this for direct tool invocations where the agent is composing a new message,
* not forwarding/relaying a message from another conversation.
*/
skipCrossContextDecoration?: boolean;
}; };
export type ChannelMessagingAdapter = { export type ChannelMessagingAdapter = {
-1
View File
@@ -100,7 +100,6 @@ export async function runDaemonInstall(opts: DaemonInstallOptions) {
if (json) warnings.push(message); if (json) warnings.push(message);
else defaultRuntime.log(message); else defaultRuntime.log(message);
}, },
config: cfg,
}); });
try { try {
-3
View File
@@ -11,7 +11,6 @@ import {
} from "./daemon-runtime.js"; } from "./daemon-runtime.js";
import { guardCancel } from "./onboard-helpers.js"; import { guardCancel } from "./onboard-helpers.js";
import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js";
import { loadConfig } from "../config/config.js";
export async function maybeInstallDaemon(params: { export async function maybeInstallDaemon(params: {
runtime: RuntimeEnv; runtime: RuntimeEnv;
@@ -82,14 +81,12 @@ export async function maybeInstallDaemon(params: {
progress.setLabel("Preparing Gateway service…"); progress.setLabel("Preparing Gateway service…");
const cfg = loadConfig();
const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({ const { programArguments, workingDirectory, environment } = await buildGatewayInstallPlan({
env: process.env, env: process.env,
port: params.port, port: params.port,
token: params.gatewayToken, token: params.gatewayToken,
runtime: daemonRuntime, runtime: daemonRuntime,
warn: (message, title) => note(message, title), warn: (message, title) => note(message, title),
config: cfg,
}); });
progress.setLabel("Installing Gateway service…"); progress.setLabel("Installing Gateway service…");
-134
View File
@@ -95,140 +95,6 @@ describe("buildGatewayInstallPlan", () => {
expect(warn).toHaveBeenCalledWith("Node too old", "Gateway runtime"); expect(warn).toHaveBeenCalledWith("Node too old", "Gateway runtime");
expect(mocks.resolvePreferredNodePath).toHaveBeenCalled(); expect(mocks.resolvePreferredNodePath).toHaveBeenCalled();
}); });
it("merges config env vars into the environment", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({
CLAWDBOT_PORT: "3000",
HOME: "/Users/me",
});
const plan = await buildGatewayInstallPlan({
env: {},
port: 3000,
runtime: "node",
config: {
env: {
vars: {
GOOGLE_API_KEY: "test-key",
},
CUSTOM_VAR: "custom-value",
},
},
});
// Config env vars should be present
expect(plan.environment.GOOGLE_API_KEY).toBe("test-key");
expect(plan.environment.CUSTOM_VAR).toBe("custom-value");
// Service environment vars should take precedence
expect(plan.environment.CLAWDBOT_PORT).toBe("3000");
expect(plan.environment.HOME).toBe("/Users/me");
});
it("does not include empty config env values", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({ CLAWDBOT_PORT: "3000" });
const plan = await buildGatewayInstallPlan({
env: {},
port: 3000,
runtime: "node",
config: {
env: {
vars: {
VALID_KEY: "valid",
EMPTY_KEY: "",
},
},
},
});
expect(plan.environment.VALID_KEY).toBe("valid");
expect(plan.environment.EMPTY_KEY).toBeUndefined();
});
it("drops whitespace-only config env values", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({});
const plan = await buildGatewayInstallPlan({
env: {},
port: 3000,
runtime: "node",
config: {
env: {
vars: {
VALID_KEY: "valid",
},
TRIMMED_KEY: " ",
},
},
});
expect(plan.environment.VALID_KEY).toBe("valid");
expect(plan.environment.TRIMMED_KEY).toBeUndefined();
});
it("keeps service env values over config env vars", async () => {
mocks.resolvePreferredNodePath.mockResolvedValue("/opt/node");
mocks.resolveGatewayProgramArguments.mockResolvedValue({
programArguments: ["node", "gateway"],
workingDirectory: "/Users/me",
});
mocks.resolveSystemNodeInfo.mockResolvedValue({
path: "/opt/node",
version: "22.0.0",
supported: true,
});
mocks.buildServiceEnvironment.mockReturnValue({
HOME: "/Users/service",
CLAWDBOT_PORT: "3000",
});
const plan = await buildGatewayInstallPlan({
env: {},
port: 3000,
runtime: "node",
config: {
env: {
HOME: "/Users/config",
vars: {
CLAWDBOT_PORT: "9999",
},
},
},
});
expect(plan.environment.HOME).toBe("/Users/service");
expect(plan.environment.CLAWDBOT_PORT).toBe("3000");
});
}); });
describe("gatewayInstallErrorHint", () => { describe("gatewayInstallErrorHint", () => {
+1 -12
View File
@@ -7,8 +7,6 @@ import {
} from "../daemon/runtime-paths.js"; } from "../daemon/runtime-paths.js";
import { buildServiceEnvironment } from "../daemon/service-env.js"; import { buildServiceEnvironment } from "../daemon/service-env.js";
import { formatCliCommand } from "../cli/command-format.js"; import { formatCliCommand } from "../cli/command-format.js";
import { collectConfigEnvVars } from "../config/env-vars.js";
import type { ClawdbotConfig } from "../config/types.js";
import type { GatewayDaemonRuntime } from "./daemon-runtime.js"; import type { GatewayDaemonRuntime } from "./daemon-runtime.js";
type WarnFn = (message: string, title?: string) => void; type WarnFn = (message: string, title?: string) => void;
@@ -33,8 +31,6 @@ export async function buildGatewayInstallPlan(params: {
devMode?: boolean; devMode?: boolean;
nodePath?: string; nodePath?: string;
warn?: WarnFn; warn?: WarnFn;
/** Full config to extract env vars from (env vars + inline env keys). */
config?: ClawdbotConfig;
}): Promise<GatewayInstallPlan> { }): Promise<GatewayInstallPlan> {
const devMode = params.devMode ?? resolveGatewayDevMode(); const devMode = params.devMode ?? resolveGatewayDevMode();
const nodePath = const nodePath =
@@ -54,7 +50,7 @@ export async function buildGatewayInstallPlan(params: {
const warning = renderSystemNodeWarning(systemNode, programArguments[0]); const warning = renderSystemNodeWarning(systemNode, programArguments[0]);
if (warning) params.warn?.(warning, "Gateway runtime"); if (warning) params.warn?.(warning, "Gateway runtime");
} }
const serviceEnvironment = buildServiceEnvironment({ const environment = buildServiceEnvironment({
env: params.env, env: params.env,
port: params.port, port: params.port,
token: params.token, token: params.token,
@@ -64,13 +60,6 @@ export async function buildGatewayInstallPlan(params: {
: undefined, : undefined,
}); });
// Merge config env vars into the service environment (vars + inline env keys).
// Config env vars are added first so service-specific vars take precedence.
const environment: Record<string, string | undefined> = {
...collectConfigEnvVars(params.config),
};
Object.assign(environment, serviceEnvironment);
return { programArguments, workingDirectory, environment }; return { programArguments, workingDirectory, environment };
} }
@@ -161,7 +161,6 @@ export async function maybeRepairGatewayDaemon(params: {
token: params.cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, token: params.cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
runtime: daemonRuntime, runtime: daemonRuntime,
warn: (message, title) => note(message, title), warn: (message, title) => note(message, title),
config: params.cfg,
}); });
try { try {
await service.install({ await service.install({
-2
View File
@@ -110,7 +110,6 @@ export async function maybeMigrateLegacyGatewayService(
token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN, token: cfg.gateway?.auth?.token ?? process.env.CLAWDBOT_GATEWAY_TOKEN,
runtime: daemonRuntime, runtime: daemonRuntime,
warn: (message, title) => note(message, title), warn: (message, title) => note(message, title),
config: cfg,
}); });
try { try {
await service.install({ await service.install({
@@ -178,7 +177,6 @@ export async function maybeRepairGatewayServiceConfig(
runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice, runtime: needsNodeRuntime && systemNodePath ? "node" : runtimeChoice,
nodePath: systemNodePath ?? undefined, nodePath: systemNodePath ?? undefined,
warn: (message, title) => note(message, title), warn: (message, title) => note(message, title),
config: cfg,
}); });
const expectedEntrypoint = findGatewayEntrypoint(programArguments); const expectedEntrypoint = findGatewayEntrypoint(programArguments);
const currentEntrypoint = findGatewayEntrypoint(command.programArguments); const currentEntrypoint = findGatewayEntrypoint(command.programArguments);
@@ -38,7 +38,6 @@ export async function installGatewayDaemonNonInteractive(params: {
token: gatewayToken, token: gatewayToken,
runtime: daemonRuntimeRaw, runtime: daemonRuntimeRaw,
warn: (message) => runtime.log(message), warn: (message) => runtime.log(message),
config: params.nextConfig,
}); });
try { try {
await service.install({ await service.install({
-23
View File
@@ -1,23 +0,0 @@
import type { ClawdbotConfig } from "./types.js";
export function collectConfigEnvVars(cfg?: ClawdbotConfig): Record<string, string> {
const envConfig = cfg?.env;
if (!envConfig) return {};
const entries: Record<string, string> = {};
if (envConfig.vars) {
for (const [key, value] of Object.entries(envConfig.vars)) {
if (!value) continue;
entries[key] = value;
}
}
for (const [key, value] of Object.entries(envConfig)) {
if (key === "shellEnv" || key === "vars") continue;
if (typeof value !== "string" || !value.trim()) continue;
entries[key] = value;
}
return entries;
}
+18 -2
View File
@@ -24,7 +24,6 @@ import {
} from "./defaults.js"; } from "./defaults.js";
import { VERSION } from "../version.js"; import { VERSION } from "../version.js";
import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js"; import { MissingEnvVarError, resolveConfigEnvVars } from "./env-substitution.js";
import { collectConfigEnvVars } from "./env-vars.js";
import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js"; import { ConfigIncludeError, resolveConfigIncludes } from "./includes.js";
import { findLegacyConfigIssues } from "./legacy.js"; import { findLegacyConfigIssues } from "./legacy.js";
import { normalizeConfigPaths } from "./normalize-paths.js"; import { normalizeConfigPaths } from "./normalize-paths.js";
@@ -150,7 +149,24 @@ function warnIfConfigFromFuture(cfg: ClawdbotConfig, logger: Pick<typeof console
} }
function applyConfigEnv(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): void { function applyConfigEnv(cfg: ClawdbotConfig, env: NodeJS.ProcessEnv): void {
const entries = collectConfigEnvVars(cfg); const envConfig = cfg.env;
if (!envConfig) return;
const entries: Record<string, string> = {};
if (envConfig.vars) {
for (const [key, value] of Object.entries(envConfig.vars)) {
if (!value) continue;
entries[key] = value;
}
}
for (const [key, value] of Object.entries(envConfig)) {
if (key === "shellEnv" || key === "vars") continue;
if (typeof value !== "string" || !value.trim()) continue;
entries[key] = value;
}
for (const [key, value] of Object.entries(entries)) { for (const [key, value] of Object.entries(entries)) {
if (env[key]?.trim()) continue; if (env[key]?.trim()) continue;
env[key] = value; env[key] = value;
-11
View File
@@ -59,17 +59,6 @@ export const CONFIG_PATH_CLAWDBOT = resolveConfigPath();
export const DEFAULT_GATEWAY_PORT = 18789; export const DEFAULT_GATEWAY_PORT = 18789;
/**
* Gateway lock directory (ephemeral).
* Default: os.tmpdir()/clawdbot-<uid> (uid suffix when available).
*/
export function resolveGatewayLockDir(tmpdir: () => string = os.tmpdir): string {
const base = tmpdir();
const uid = typeof process.getuid === "function" ? process.getuid() : undefined;
const suffix = uid != null ? `clawdbot-${uid}` : "clawdbot";
return path.join(base, suffix);
}
const OAUTH_FILENAME = "oauth.json"; const OAUTH_FILENAME = "oauth.json";
/** /**
-3
View File
@@ -107,7 +107,6 @@ const FIELD_LABELS: Record<string, string> = {
"update.channel": "Update Channel", "update.channel": "Update Channel",
"update.checkOnStart": "Update Check on Start", "update.checkOnStart": "Update Check on Start",
"diagnostics.enabled": "Diagnostics Enabled", "diagnostics.enabled": "Diagnostics Enabled",
"diagnostics.flags": "Diagnostics Flags",
"diagnostics.otel.enabled": "OpenTelemetry Enabled", "diagnostics.otel.enabled": "OpenTelemetry Enabled",
"diagnostics.otel.endpoint": "OpenTelemetry Endpoint", "diagnostics.otel.endpoint": "OpenTelemetry Endpoint",
"diagnostics.otel.protocol": "OpenTelemetry Protocol", "diagnostics.otel.protocol": "OpenTelemetry Protocol",
@@ -389,8 +388,6 @@ const FIELD_HELP: Record<string, string> = {
"nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.", "nodeHost.browserProxy.enabled": "Expose the local browser control server via node proxy.",
"nodeHost.browserProxy.allowProfiles": "nodeHost.browserProxy.allowProfiles":
"Optional allowlist of browser profile names exposed via the node proxy.", "Optional allowlist of browser profile names exposed via the node proxy.",
"diagnostics.flags":
'Enable targeted diagnostics logs by flag (e.g. ["telegram.http"]). Supports wildcards like "telegram.*" or "*".',
"diagnostics.cacheTrace.enabled": "diagnostics.cacheTrace.enabled":
"Log cache trace snapshots for embedded agent runs (default: false).", "Log cache trace snapshots for embedded agent runs (default: false).",
"diagnostics.cacheTrace.filePath": "diagnostics.cacheTrace.filePath":
-2
View File
@@ -135,8 +135,6 @@ export type DiagnosticsCacheTraceConfig = {
export type DiagnosticsConfig = { export type DiagnosticsConfig = {
enabled?: boolean; enabled?: boolean;
/** Optional ad-hoc diagnostics flags (e.g. "telegram.http"). */
flags?: string[];
otel?: DiagnosticsOtelConfig; otel?: DiagnosticsOtelConfig;
cacheTrace?: DiagnosticsCacheTraceConfig; cacheTrace?: DiagnosticsCacheTraceConfig;
}; };
-2
View File
@@ -33,8 +33,6 @@ export type SignalAccountConfig = {
cliPath?: string; cliPath?: string;
/** Auto-start signal-cli daemon (default: true if httpUrl not set). */ /** Auto-start signal-cli daemon (default: true if httpUrl not set). */
autoStart?: boolean; autoStart?: boolean;
/** Max time to wait for signal-cli daemon startup (ms, cap 120000). */
startupTimeoutMs?: number;
receiveMode?: "on-start" | "manual"; receiveMode?: "on-start" | "manual";
ignoreAttachments?: boolean; ignoreAttachments?: boolean;
ignoreStories?: boolean; ignoreStories?: boolean;
-2
View File
@@ -118,8 +118,6 @@ export type TelegramAccountConfig = {
reactionLevel?: "off" | "ack" | "minimal" | "extensive"; reactionLevel?: "off" | "ack" | "minimal" | "extensive";
/** Heartbeat visibility settings for this channel. */ /** Heartbeat visibility settings for this channel. */
heartbeat?: ChannelHeartbeatVisibilityConfig; heartbeat?: ChannelHeartbeatVisibilityConfig;
/** Controls whether link previews are shown in outbound messages. Default: true. */
linkPreview?: boolean;
}; };
export type TelegramTopicConfig = { export type TelegramTopicConfig = {
-2
View File
@@ -125,7 +125,6 @@ export const TelegramAccountSchemaBase = z
reactionNotifications: z.enum(["off", "own", "all"]).optional(), reactionNotifications: z.enum(["off", "own", "all"]).optional(),
reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(), reactionLevel: z.enum(["off", "ack", "minimal", "extensive"]).optional(),
heartbeat: ChannelHeartbeatVisibilitySchema, heartbeat: ChannelHeartbeatVisibilitySchema,
linkPreview: z.boolean().optional(),
}) })
.strict(); .strict();
@@ -487,7 +486,6 @@ export const SignalAccountSchemaBase = z
httpPort: z.number().int().positive().optional(), httpPort: z.number().int().positive().optional(),
cliPath: ExecutableTokenSchema.optional(), cliPath: ExecutableTokenSchema.optional(),
autoStart: z.boolean().optional(), autoStart: z.boolean().optional(),
startupTimeoutMs: z.number().int().min(1000).max(120000).optional(),
receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(), receiveMode: z.union([z.literal("on-start"), z.literal("manual")]).optional(),
ignoreAttachments: z.boolean().optional(), ignoreAttachments: z.boolean().optional(),
ignoreStories: z.boolean().optional(), ignoreStories: z.boolean().optional(),
-1
View File
@@ -62,7 +62,6 @@ export const ClawdbotSchema = z
diagnostics: z diagnostics: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
flags: z.array(z.string()).optional(),
otel: z otel: z
.object({ .object({
enabled: z.boolean().optional(), enabled: z.boolean().optional(),
+68 -19
View File
@@ -1,28 +1,77 @@
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, test, vi } from "vitest";
import { resolveGatewayListenHosts } from "./net.js"; const testTailnetIPv4 = { value: undefined as string | undefined };
const testTailnetIPv6 = { value: undefined as string | undefined };
describe("resolveGatewayListenHosts", () => { vi.mock("../infra/tailnet.js", () => ({
it("returns the input host when not loopback", async () => { pickPrimaryTailnetIPv4: () => testTailnetIPv4.value,
const hosts = await resolveGatewayListenHosts("0.0.0.0", { pickPrimaryTailnetIPv6: () => testTailnetIPv6.value,
canBindToHost: async () => { }));
throw new Error("should not be called");
}, import { isLocalGatewayAddress, resolveGatewayClientIp } from "./net.js";
});
expect(hosts).toEqual(["0.0.0.0"]); describe("gateway net", () => {
beforeEach(() => {
testTailnetIPv4.value = undefined;
testTailnetIPv6.value = undefined;
}); });
it("adds ::1 when IPv6 loopback is available", async () => { test("treats loopback as local", () => {
const hosts = await resolveGatewayListenHosts("127.0.0.1", { expect(isLocalGatewayAddress("127.0.0.1")).toBe(true);
canBindToHost: async () => true, expect(isLocalGatewayAddress("127.0.1.1")).toBe(true);
}); expect(isLocalGatewayAddress("::1")).toBe(true);
expect(hosts).toEqual(["127.0.0.1", "::1"]); expect(isLocalGatewayAddress("::ffff:127.0.0.1")).toBe(true);
}); });
it("keeps only IPv4 loopback when IPv6 is unavailable", async () => { test("treats local tailnet IPv4 as local", () => {
const hosts = await resolveGatewayListenHosts("127.0.0.1", { testTailnetIPv4.value = "100.64.0.1";
canBindToHost: async () => false, expect(isLocalGatewayAddress("100.64.0.1")).toBe(true);
expect(isLocalGatewayAddress("::ffff:100.64.0.1")).toBe(true);
});
test("ignores non-matching tailnet IPv4", () => {
testTailnetIPv4.value = "100.64.0.1";
expect(isLocalGatewayAddress("100.64.0.2")).toBe(false);
});
test("treats local tailnet IPv6 as local", () => {
testTailnetIPv6.value = "fd7a:115c:a1e0::123";
expect(isLocalGatewayAddress("fd7a:115c:a1e0::123")).toBe(true);
});
test("uses forwarded-for when remote is a trusted proxy", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.2",
forwardedFor: "203.0.113.9, 10.0.0.2",
trustedProxies: ["10.0.0.2"],
}); });
expect(hosts).toEqual(["127.0.0.1"]); expect(clientIp).toBe("203.0.113.9");
});
test("ignores forwarded-for from untrusted proxies", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.3",
forwardedFor: "203.0.113.9",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("10.0.0.3");
});
test("normalizes trusted proxy IPs and strips forwarded ports", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "::ffff:10.0.0.2",
forwardedFor: "203.0.113.9:1234",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.9");
});
test("falls back to x-real-ip when forwarded-for is missing", () => {
const clientIp = resolveGatewayClientIp({
remoteAddr: "10.0.0.2",
realIp: "203.0.113.10",
trustedProxies: ["10.0.0.2"],
});
expect(clientIp).toBe("203.0.113.10");
}); });
}); });
+6 -16
View File
@@ -97,14 +97,14 @@ export async function resolveGatewayBindHost(
if (mode === "loopback") { if (mode === "loopback") {
// 127.0.0.1 rarely fails, but handle gracefully // 127.0.0.1 rarely fails, but handle gracefully
if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; if (await canBindTo("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0"; // extreme fallback return "0.0.0.0"; // extreme fallback
} }
if (mode === "tailnet") { if (mode === "tailnet") {
const tailnetIP = pickPrimaryTailnetIPv4(); const tailnetIP = pickPrimaryTailnetIPv4();
if (tailnetIP && (await canBindToHost(tailnetIP))) return tailnetIP; if (tailnetIP && (await canBindTo(tailnetIP))) return tailnetIP;
if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; if (await canBindTo("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0"; return "0.0.0.0";
} }
@@ -116,13 +116,13 @@ export async function resolveGatewayBindHost(
const host = customHost?.trim(); const host = customHost?.trim();
if (!host) return "0.0.0.0"; // invalid config → fall back to all if (!host) return "0.0.0.0"; // invalid config → fall back to all
if (isValidIPv4(host) && (await canBindToHost(host))) return host; if (isValidIPv4(host) && (await canBindTo(host))) return host;
// Custom IP failed → fall back to LAN // Custom IP failed → fall back to LAN
return "0.0.0.0"; return "0.0.0.0";
} }
if (mode === "auto") { if (mode === "auto") {
if (await canBindToHost("127.0.0.1")) return "127.0.0.1"; if (await canBindTo("127.0.0.1")) return "127.0.0.1";
return "0.0.0.0"; return "0.0.0.0";
} }
@@ -136,7 +136,7 @@ export async function resolveGatewayBindHost(
* @param host - The host address to test * @param host - The host address to test
* @returns True if we can successfully bind to this address * @returns True if we can successfully bind to this address
*/ */
export async function canBindToHost(host: string): Promise<boolean> { async function canBindTo(host: string): Promise<boolean> {
return new Promise((resolve) => { return new Promise((resolve) => {
const testServer = net.createServer(); const testServer = net.createServer();
testServer.once("error", () => { testServer.once("error", () => {
@@ -151,16 +151,6 @@ export async function canBindToHost(host: string): Promise<boolean> {
}); });
} }
export async function resolveGatewayListenHosts(
bindHost: string,
opts?: { canBindToHost?: (host: string) => Promise<boolean> },
): Promise<string[]> {
if (bindHost !== "127.0.0.1") return [bindHost];
const canBind = opts?.canBindToHost ?? canBindToHost;
if (await canBind("::1")) return [bindHost, "::1"];
return [bindHost];
}
/** /**
* Validate if a string is a valid IPv4 address. * Validate if a string is a valid IPv4 address.
* *
+8 -15
View File
@@ -28,7 +28,6 @@ export function createGatewayCloseHandler(params: {
browserControl: { stop: () => Promise<void> } | null; browserControl: { stop: () => Promise<void> } | null;
wss: WebSocketServer; wss: WebSocketServer;
httpServer: HttpServer; httpServer: HttpServer;
httpServers?: HttpServer[];
}) { }) {
return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => { return async (opts?: { reason?: string; restartExpectedMs?: number | null }) => {
const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : ""; const reasonRaw = typeof opts?.reason === "string" ? opts.reason.trim() : "";
@@ -109,20 +108,14 @@ export function createGatewayCloseHandler(params: {
await params.browserControl.stop().catch(() => {}); await params.browserControl.stop().catch(() => {});
} }
await new Promise<void>((resolve) => params.wss.close(() => resolve())); await new Promise<void>((resolve) => params.wss.close(() => resolve()));
const servers = const httpServer = params.httpServer as HttpServer & {
params.httpServers && params.httpServers.length > 0 closeIdleConnections?: () => void;
? params.httpServers };
: [params.httpServer]; if (typeof httpServer.closeIdleConnections === "function") {
for (const server of servers) { httpServer.closeIdleConnections();
const httpServer = server as HttpServer & {
closeIdleConnections?: () => void;
};
if (typeof httpServer.closeIdleConnections === "function") {
httpServer.closeIdleConnections();
}
await new Promise<void>((resolve, reject) =>
httpServer.close((err) => (err ? reject(err) : resolve())),
);
} }
await new Promise<void>((resolve, reject) =>
params.httpServer.close((err) => (err ? reject(err) : resolve())),
);
}; };
} }
+19 -44
View File
@@ -10,7 +10,6 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
import type { HooksConfigResolved } from "./hooks.js"; import type { HooksConfigResolved } from "./hooks.js";
import { createGatewayHooksRequestHandler } from "./server/hooks.js"; import { createGatewayHooksRequestHandler } from "./server/hooks.js";
import { listenGatewayHttpServer } from "./server/http-listen.js"; import { listenGatewayHttpServer } from "./server/http-listen.js";
import { resolveGatewayListenHosts } from "./net.js";
import { createGatewayPluginRequestHandler } from "./server/plugins-http.js"; import { createGatewayPluginRequestHandler } from "./server/plugins-http.js";
import type { GatewayWsClient } from "./server/ws-types.js"; import type { GatewayWsClient } from "./server/ws-types.js";
import { createGatewayBroadcaster } from "./server-broadcast.js"; import { createGatewayBroadcaster } from "./server-broadcast.js";
@@ -39,14 +38,11 @@ export async function createGatewayRuntimeState(params: {
canvasHostEnabled: boolean; canvasHostEnabled: boolean;
allowCanvasHostInTests?: boolean; allowCanvasHostInTests?: boolean;
logCanvas: { info: (msg: string) => void; warn: (msg: string) => void }; logCanvas: { info: (msg: string) => void; warn: (msg: string) => void };
log: { info: (msg: string) => void; warn: (msg: string) => void };
logHooks: ReturnType<typeof createSubsystemLogger>; logHooks: ReturnType<typeof createSubsystemLogger>;
logPlugins: ReturnType<typeof createSubsystemLogger>; logPlugins: ReturnType<typeof createSubsystemLogger>;
}): Promise<{ }): Promise<{
canvasHost: CanvasHostHandler | null; canvasHost: CanvasHostHandler | null;
httpServer: HttpServer; httpServer: HttpServer;
httpServers: HttpServer[];
httpBindHosts: string[];
wss: WebSocketServer; wss: WebSocketServer;
clients: Set<GatewayWsClient>; clients: Set<GatewayWsClient>;
broadcast: ( broadcast: (
@@ -104,49 +100,30 @@ export async function createGatewayRuntimeState(params: {
log: params.logPlugins, log: params.logPlugins,
}); });
const bindHosts = await resolveGatewayListenHosts(params.bindHost); const httpServer = createGatewayHttpServer({
const httpServers: HttpServer[] = []; canvasHost,
const httpBindHosts: string[] = []; controlUiEnabled: params.controlUiEnabled,
for (const host of bindHosts) { controlUiBasePath: params.controlUiBasePath,
const httpServer = createGatewayHttpServer({ openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled,
canvasHost, openResponsesEnabled: params.openResponsesEnabled,
controlUiEnabled: params.controlUiEnabled, openResponsesConfig: params.openResponsesConfig,
controlUiBasePath: params.controlUiBasePath, handleHooksRequest,
openAiChatCompletionsEnabled: params.openAiChatCompletionsEnabled, handlePluginRequest,
openResponsesEnabled: params.openResponsesEnabled, resolvedAuth: params.resolvedAuth,
openResponsesConfig: params.openResponsesConfig, tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined,
handleHooksRequest, });
handlePluginRequest,
resolvedAuth: params.resolvedAuth, await listenGatewayHttpServer({
tlsOptions: params.gatewayTls?.enabled ? params.gatewayTls.tlsOptions : undefined, httpServer,
}); bindHost: params.bindHost,
try { port: params.port,
await listenGatewayHttpServer({ });
httpServer,
bindHost: host,
port: params.port,
});
httpServers.push(httpServer);
httpBindHosts.push(host);
} catch (err) {
if (host === bindHosts[0]) throw err;
params.log.warn(
`gateway: failed to bind loopback alias ${host}:${params.port} (${String(err)})`,
);
}
}
const httpServer = httpServers[0];
if (!httpServer) {
throw new Error("Gateway HTTP server failed to start");
}
const wss = new WebSocketServer({ const wss = new WebSocketServer({
noServer: true, noServer: true,
maxPayload: MAX_PAYLOAD_BYTES, maxPayload: MAX_PAYLOAD_BYTES,
}); });
for (const server of httpServers) { attachGatewayUpgradeHandler({ httpServer, wss, canvasHost });
attachGatewayUpgradeHandler({ httpServer: server, wss, canvasHost });
}
const clients = new Set<GatewayWsClient>(); const clients = new Set<GatewayWsClient>();
const { broadcast } = createGatewayBroadcaster({ clients }); const { broadcast } = createGatewayBroadcaster({ clients });
@@ -163,8 +140,6 @@ export async function createGatewayRuntimeState(params: {
return { return {
canvasHost, canvasHost,
httpServer, httpServer,
httpServers,
httpBindHosts,
wss, wss,
clients, clients,
broadcast, broadcast,
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more