Files
clawdbot/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift
Peter Steinberger 7acd26a2fc Move provider to a plugin-architecture (#661)
* refactor: introduce provider plugin registry

* refactor: move provider CLI to plugins

* docs: add provider plugin implementation notes

* refactor: shift provider runtime logic into plugins

* refactor: add plugin defaults and summaries

* docs: update provider plugin notes

* feat(commands): add /commands slash list

* Auto-reply: tidy help message

* Auto-reply: fix status command lint

* Tests: align google shared expectations

* Auto-reply: tidy help message

* Auto-reply: fix status command lint

* refactor: move provider routing into plugins

* test: align agent routing expectations

* docs: update provider plugin notes

* refactor: route replies via provider plugins

* docs: note route-reply plugin hooks

* refactor: extend provider plugin contract

* refactor: derive provider status from plugins

* refactor: unify gateway provider control

* refactor: use plugin metadata in auto-reply

* fix: parenthesize cron target selection

* refactor: derive gateway methods from plugins

* refactor: generalize provider logout

* refactor: route provider logout through plugins

* refactor: move WhatsApp web login methods into plugin

* refactor: generalize provider log prefixes

* refactor: centralize default chat provider

* refactor: derive provider lists from registry

* refactor: move provider reload noops into plugins

* refactor: resolve web login provider via alias

* refactor: derive CLI provider options from plugins

* refactor: derive prompt provider list from plugins

* style: apply biome lint fixes

* fix: resolve provider routing edge cases

* docs: update provider plugin refactor notes

* fix(gateway): harden agent provider routing

* refactor: move provider routing into plugins

* refactor: move provider CLI to plugins

* refactor: derive provider lists from registry

* fix: restore slash command parsing

* refactor: align provider ids for schema

* refactor: unify outbound target resolution

* fix: keep outbound labels stable

* feat: add msteams to cron surfaces

* fix: clean up lint build issues

* refactor: localize chat provider alias normalization

* refactor: drive gateway provider lists from plugins

* docs: update provider plugin notes

* style: format message-provider

* fix: avoid provider registry init cycles

* style: sort message-provider imports

* fix: relax provider alias map typing

* refactor: move provider routing into plugins

* refactor: add plugin pairing/config adapters

* refactor: route pairing and provider removal via plugins

* refactor: align auto-reply provider typing

* test: stabilize telegram media mocks

* docs: update provider plugin refactor notes

* refactor: pluginize outbound targets

* refactor: pluginize provider selection

* refactor: generalize text chunk limits

* docs: update provider plugin notes

* refactor: generalize group session/config

* fix: normalize provider id for room detection

* fix: avoid provider init in system prompt

* style: formatting cleanup

* refactor: normalize agent delivery targets

* test: update outbound delivery labels

* chore: fix lint regressions

* refactor: extend provider plugin adapters

* refactor: move elevated/block streaming defaults to plugins

* refactor: defer outbound send deps to plugins

* docs: note plugin-driven streaming/elevated defaults

* refactor: centralize webchat provider constant

* refactor: add provider setup adapters

* refactor: delegate provider add config to plugins

* docs: document plugin-driven provider add

* refactor: add plugin state/binding metadata

* refactor: build agent provider status from plugins

* docs: note plugin-driven agent bindings

* refactor: centralize internal provider constant usage

* fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing)

* refactor: centralize default chat provider

* refactor: centralize WhatsApp target normalization

* refactor: move provider routing into plugins

* refactor: normalize agent delivery targets

* chore: fix lint regressions

* fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing)

* feat: expand provider plugin adapters

* refactor: route auto-reply via provider plugins

* fix: align WhatsApp target normalization

* fix: normalize WhatsApp targets for groups and E.164 (#631) (thanks @imfing)

* refactor: centralize WhatsApp target normalization

* feat: add /config chat config updates

* docs: add /config get alias

* feat(commands): add /commands slash list

* refactor: centralize default chat provider

* style: apply biome lint fixes

* chore: fix lint regressions

* fix: clean up whatsapp allowlist typing

* style: format config command helpers

* refactor: pluginize tool threading context

* refactor: normalize session announce targets

* docs: note new plugin threading and announce hooks

* refactor: pluginize message actions

* docs: update provider plugin actions notes

* fix: align provider action adapters

* refactor: centralize webchat checks

* style: format message provider helpers

* refactor: move provider onboarding into adapters

* docs: note onboarding provider adapters

* feat: add msteams onboarding adapter

* style: organize onboarding imports

* fix: normalize msteams allowFrom types

* feat: add plugin text chunk limits

* refactor: use plugin chunk limit fallbacks

* feat: add provider mention stripping hooks

* style: organize provider plugin type imports

* refactor: generalize health snapshots

* refactor: update macOS health snapshot handling

* docs: refresh health snapshot notes

* style: format health snapshot updates

* refactor: drive security warnings via plugins

* docs: note provider security adapter

* style: format provider security adapters

* refactor: centralize provider account defaults

* refactor: type gateway client identity constants

* chore: regen gateway protocol swift

* fix: degrade health on failed provider probe

* refactor: centralize pairing approve hint

* docs: add plugin CLI command references

* refactor: route auth and tool sends through plugins

* docs: expand provider plugin hooks

* refactor: document provider docking touchpoints

* refactor: normalize internal provider defaults

* refactor: streamline outbound delivery wiring

* refactor: make provider onboarding plugin-owned

* refactor: support provider-owned agent tools

* refactor: move telegram draft chunking into telegram module

* refactor: infer provider tool sends via extractToolSend

* fix: repair plugin onboarding imports

* refactor: de-dup outbound target normalization

* style: tidy plugin and agent imports

* refactor: data-drive provider selection line

* fix: satisfy lint after provider plugin rebase

* test: deflake gateway-cli coverage

* style: format gateway-cli coverage test

* refactor(provider-plugins): simplify provider ids

* test(pairing-cli): avoid provider-specific ternary

* style(macos): swiftformat HealthStore

* refactor(sandbox): derive provider tool denylist

* fix(sandbox): avoid plugin init in defaults

* refactor(provider-plugins): centralize provider aliases

* style(test): satisfy biome

* refactor(protocol): v3 providers.status maps

* refactor(ui): adapt to protocol v3

* refactor(macos): adapt to protocol v3

* test: update providers.status v3 fixtures

* refactor(gateway): map provider runtime snapshot

* test(gateway): update reload runtime snapshot

* refactor(whatsapp): normalize heartbeat provider id

* docs(refactor): update provider plugin notes

* style: satisfy biome after rebase

* fix: describe sandboxed elevated in prompt

* feat(gateway): add agent image attachments + live probe

* refactor: derive CLI provider options from plugins

* fix(gateway): harden agent provider routing

* fix(gateway): harden agent provider routing

* refactor: align provider ids for schema

* fix(protocol): keep agent provider string

* fix(gateway): harden agent provider routing

* fix(protocol): keep agent provider string

* refactor: normalize agent delivery targets

* refactor: support provider-owned agent tools

* refactor(config): provider-keyed elevated allowFrom

* style: satisfy biome

* fix(gateway): appease provider narrowing

* style: satisfy biome

* refactor(reply): move group intro hints into plugin

* fix(reply): avoid plugin registry init cycle

* refactor(providers): add lightweight provider dock

* refactor(gateway): use typed client id in connect

* refactor(providers): document docks and avoid init cycles

* refactor(providers): make media limit helper generic

* fix(providers): break plugin registry import cycles

* style: satisfy biome

* refactor(status-all): build providers table from plugins

* refactor(gateway): delegate web login to provider plugin

* refactor(provider): drop web alias

* refactor(provider): lazy-load monitors

* style: satisfy lint/format

* style: format status-all providers table

* style: swiftformat gateway discovery model

* test: make reload plan plugin-driven

* fix: avoid token stringification in status-all

* refactor: make provider IDs explicit in status

* feat: warn on signal/imessage provider runtime errors

* test: cover gateway provider runtime warnings in status

* fix: add runtime kind to provider status issues

* test: cover health degradation on probe failure

* fix: keep routeReply lightweight

* style: organize routeReply imports

* refactor(web): extract auth-store helpers

* refactor(whatsapp): lazy login imports

* refactor(outbound): route replies via plugin outbound

* docs: update provider plugin notes

* style: format provider status issues

* fix: make sandbox scope warning wrap-safe

* refactor: load outbound adapters from provider plugins

* docs: update provider plugin outbound notes

* style(macos): fix swiftformat lint

* docs: changelog for provider plugins

* fix(macos): satisfy swiftformat

* fix(macos): open settings via menu action

* style: format after rebase

* fix(macos): open Settings via menu action

---------

Co-authored-by: LK <luke@kyohere.com>
Co-authored-by: Luke K (pr-0f3t) <2609441+lc0rp@users.noreply.github.com>
Co-authored-by: Xin <xin@imfing.com>
2026-01-11 11:45:25 +00:00

416 lines
16 KiB
Swift

import Foundation
import OSLog
enum GatewayEndpointState: Sendable, Equatable {
case ready(mode: AppState.ConnectionMode, url: URL, token: String?, password: String?)
case connecting(mode: AppState.ConnectionMode, detail: String)
case unavailable(mode: AppState.ConnectionMode, reason: String)
}
/// Single place to resolve (and publish) the effective gateway control endpoint.
///
/// This is intentionally separate from `GatewayConnection`:
/// - `GatewayConnection` consumes the resolved endpoint (no tunnel side-effects).
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
actor GatewayEndpointStore {
static let shared = GatewayEndpointStore()
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
private static let remoteConnectingDetail = "Connecting to remote gateway…"
struct Deps: Sendable {
let mode: @Sendable () async -> AppState.ConnectionMode
let token: @Sendable () -> String?
let password: @Sendable () -> String?
let localPort: @Sendable () -> Int
let localHost: @Sendable () async -> String
let remotePortIfRunning: @Sendable () async -> UInt16?
let ensureRemoteTunnel: @Sendable () async throws -> UInt16
static let live = Deps(
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
token: {
let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayToken(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment)
},
password: {
let root = ClawdbotConfigFile.loadDict()
return GatewayEndpointStore.resolveGatewayPassword(
isRemote: CommandResolver.connectionModeIsRemote(),
root: root,
env: ProcessInfo.processInfo.environment)
},
localPort: { GatewayEnvironment.gatewayPort() },
localHost: {
let root = ClawdbotConfigFile.loadDict()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind,
tailscaleIP: tailscaleIP)
},
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
ensureRemoteTunnel: { try await RemoteTunnelManager.shared.ensureControlTunnel() })
}
private static func resolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
{
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let password = remote["password"] as? String
{
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
if !pw.isEmpty {
return pw
}
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let password = auth["password"] as? String
{
let pw = password.trimmingCharacters(in: .whitespacesAndNewlines)
if !pw.isEmpty {
return pw
}
}
return nil
}
private static func resolveGatewayToken(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
{
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty {
return trimmed
}
if isRemote {
if let gateway = root["gateway"] as? [String: Any],
let remote = gateway["remote"] as? [String: Any],
let token = remote["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
if let gateway = root["gateway"] as? [String: Any],
let auth = gateway["auth"] as? [String: Any],
let token = auth["token"] as? String
{
let value = token.trimmingCharacters(in: .whitespacesAndNewlines)
if !value.isEmpty {
return value
}
}
return nil
}
private let deps: Deps
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
private var state: GatewayEndpointState
private var subscribers: [UUID: AsyncStream<GatewayEndpointState>.Continuation] = [:]
private var remoteEnsure: (token: UUID, task: Task<UInt16, Error>)?
init(deps: Deps = .live) {
self.deps = deps
let modeRaw = UserDefaults.standard.string(forKey: connectionModeKey)
let initialMode: AppState.ConnectionMode
if let modeRaw {
initialMode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
} else {
let seen = UserDefaults.standard.bool(forKey: "clawdbot.onboardingSeen")
initialMode = seen ? .local : .unconfigured
}
let port = deps.localPort()
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment)
let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil)
let token = deps.token()
let password = deps.password()
switch initialMode {
case .local:
self.state = .ready(
mode: .local,
url: URL(string: "ws://\(host):\(port)")!,
token: token,
password: password)
case .remote:
self.state = .connecting(mode: .remote, detail: Self.remoteConnectingDetail)
Task { await self.setMode(.remote) }
case .unconfigured:
self.state = .unavailable(mode: .unconfigured, reason: "Gateway not configured")
}
}
func subscribe(bufferingNewest: Int = 1) -> AsyncStream<GatewayEndpointState> {
let id = UUID()
let initial = self.state
let store = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
continuation.yield(initial)
self.subscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await store.removeSubscriber(id) }
}
}
}
func refresh() async {
let mode = await self.deps.mode()
await self.setMode(mode)
}
func setMode(_ mode: AppState.ConnectionMode) async {
let token = self.deps.token()
let password = self.deps.password()
switch mode {
case .local:
self.cancelRemoteEnsure()
let port = self.deps.localPort()
let host = await self.deps.localHost()
self.setState(.ready(
mode: .local,
url: URL(string: "ws://\(host):\(port)")!,
token: token,
password: password))
case .remote:
let port = await self.deps.remotePortIfRunning()
guard let port else {
self.setState(.connecting(mode: .remote, detail: Self.remoteConnectingDetail))
self.kickRemoteEnsureIfNeeded(detail: Self.remoteConnectingDetail)
return
}
self.cancelRemoteEnsure()
self.setState(.ready(
mode: .remote,
url: URL(string: "ws://127.0.0.1:\(Int(port))")!,
token: token,
password: password))
case .unconfigured:
self.cancelRemoteEnsure()
self.setState(.unavailable(mode: .unconfigured, reason: "Gateway not configured"))
}
}
/// Explicit action: ensure the remote control tunnel is established and publish the resolved endpoint.
func ensureRemoteControlTunnel() async throws -> UInt16 {
let mode = await self.deps.mode()
guard mode == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
let config = try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
guard let portInt = config.0.port, let port = UInt16(exactly: portInt) else {
throw NSError(
domain: "GatewayEndpoint",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Missing tunnel port"])
}
return port
}
func requireConfig() async throws -> GatewayConnection.Config {
await self.refresh()
switch self.state {
case let .ready(_, url, token, password):
return (url, token, password)
case let .connecting(mode, _):
guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
}
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
case let .unavailable(mode, reason):
guard mode == .remote else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: reason])
}
// Auto-recover for remote mode: if the SSH control tunnel died (or hasn't been created yet),
// recreate it on demand so callers can recover without a manual reconnect.
self.logger.info(
"endpoint unavailable; ensuring remote control tunnel reason=\(reason, privacy: .public)")
return try await self.ensureRemoteConfig(detail: Self.remoteConnectingDetail)
}
}
private func cancelRemoteEnsure() {
self.remoteEnsure?.task.cancel()
self.remoteEnsure = nil
}
private func kickRemoteEnsureIfNeeded(detail: String) {
if self.remoteEnsure != nil {
self.setState(.connecting(mode: .remote, detail: detail))
return
}
let deps = self.deps
let token = UUID()
let task = Task.detached(priority: .utility) { try await deps.ensureRemoteTunnel() }
self.remoteEnsure = (token: token, task: task)
self.setState(.connecting(mode: .remote, detail: detail))
}
private func ensureRemoteConfig(detail: String) async throws -> GatewayConnection.Config {
let mode = await self.deps.mode()
guard mode == .remote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
self.kickRemoteEnsureIfNeeded(detail: detail)
guard let ensure = self.remoteEnsure else {
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: "Connecting…"])
}
do {
let forwarded = try await ensure.task.value
let stillRemote = await self.deps.mode() == .remote
guard stillRemote else {
throw NSError(
domain: "RemoteTunnel",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Remote mode is not enabled"])
}
if self.remoteEnsure?.token == ensure.token {
self.remoteEnsure = nil
}
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
return (url, token, password)
} catch let err as CancellationError {
if self.remoteEnsure?.token == ensure.token {
self.remoteEnsure = nil
}
throw err
} catch {
if self.remoteEnsure?.token == ensure.token {
self.remoteEnsure = nil
}
let msg = "Remote control tunnel failed (\(error.localizedDescription))"
self.setState(.unavailable(mode: .remote, reason: msg))
self.logger.error("remote control tunnel ensure failed \(msg, privacy: .public)")
throw NSError(domain: "GatewayEndpoint", code: 1, userInfo: [NSLocalizedDescriptionKey: msg])
}
}
private func removeSubscriber(_ id: UUID) {
self.subscribers[id] = nil
}
private func setState(_ next: GatewayEndpointState) {
guard next != self.state else { return }
self.state = next
for (_, continuation) in self.subscribers {
continuation.yield(next)
}
switch next {
case let .ready(mode, url, _, _):
let modeDesc = String(describing: mode)
let urlDesc = url.absoluteString
self.logger
.debug(
"resolved endpoint mode=\(modeDesc, privacy: .public) url=\(urlDesc, privacy: .public)")
case let .connecting(mode, detail):
let modeDesc = String(describing: mode)
self.logger
.debug(
"endpoint connecting mode=\(modeDesc, privacy: .public) detail=\(detail, privacy: .public)")
case let .unavailable(mode, reason):
let modeDesc = String(describing: mode)
self.logger
.debug(
"endpoint unavailable mode=\(modeDesc, privacy: .public) reason=\(reason, privacy: .public)")
}
}
private static func resolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
{
if let envBind = env["CLAWDBOT_GATEWAY_BIND"] {
let trimmed = envBind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
if let gateway = root["gateway"] as? [String: Any],
let bind = gateway["bind"] as? String
{
let trimmed = bind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
if self.supportedBindModes.contains(trimmed) {
return trimmed
}
}
return nil
}
private static func resolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
{
switch bindMode {
case "tailnet", "auto":
tailscaleIP ?? "127.0.0.1"
default:
"127.0.0.1"
}
}
}
#if DEBUG
extension GatewayEndpointStore {
static func _testResolveGatewayPassword(
isRemote: Bool,
root: [String: Any],
env: [String: String]) -> String?
{
self.resolveGatewayPassword(isRemote: isRemote, root: root, env: env)
}
static func _testResolveGatewayBindMode(
root: [String: Any],
env: [String: String]) -> String?
{
self.resolveGatewayBindMode(root: root, env: env)
}
static func _testResolveLocalGatewayHost(
bindMode: String?,
tailscaleIP: String?) -> String
{
self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP)
}
}
#endif