refactor: remove bridge protocol

This commit is contained in:
Peter Steinberger
2026-01-19 04:50:07 +00:00
parent b347d5d9cc
commit 2f8206862a
118 changed files with 1560 additions and 8087 deletions

View File

@@ -1,462 +0,0 @@
import ClawdbotKit
import Foundation
import Network
import OSLog
struct BridgeNodeInfo: Sendable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var coreVersion: String?
var uiVersion: String?
var deviceFamily: String?
var modelIdentifier: String?
var remoteAddress: String?
var caps: [String]?
}
actor BridgeConnectionHandler {
private let connection: NWConnection
private let logger: Logger
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private let queue = DispatchQueue(label: "com.clawdbot.bridge.connection")
private var buffer = Data()
private var isAuthenticated = false
private var nodeId: String?
private var pendingInvokes: [String: CheckedContinuation<BridgeInvokeResponse, Error>] = [:]
private var isClosed = false
init(connection: NWConnection, logger: Logger) {
self.connection = connection
self.logger = logger
}
enum AuthResult: Sendable {
case ok
case notPaired
case unauthorized
case error(code: String, message: String)
}
enum PairResult: Sendable {
case ok(token: String)
case rejected
case error(code: String, message: String)
}
private struct FrameContext: Sendable {
var serverName: String
var resolveAuth: @Sendable (BridgeHello) async -> AuthResult
var handlePair: @Sendable (BridgePairRequest) async -> PairResult
var onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)?
var onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?
var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?
}
func run(
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async
{
self.configureStateLogging()
self.connection.start(queue: self.queue)
let context = FrameContext(
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName,
resolveAuth: resolveAuth,
handlePair: handlePair,
onAuthenticated: onAuthenticated,
onEvent: onEvent,
onRequest: onRequest)
while true {
do {
guard let line = try await self.receiveLine() else { break }
guard let data = line.data(using: .utf8) else { continue }
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
try await self.handleFrame(
baseType: base.type,
data: data,
context: context)
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
await self.close(with: onDisconnected)
}
private func configureStateLogging() {
self.connection.stateUpdateHandler = { [logger] state in
switch state {
case .ready:
logger.debug("bridge conn ready")
case let .failed(err):
logger.error("bridge conn failed: \(err.localizedDescription, privacy: .public)")
default:
break
}
}
}
private func handleFrame(
baseType: String,
data: Data,
context: FrameContext) async throws
{
switch baseType {
case "hello":
await self.handleHelloFrame(
data: data,
context: context)
case "pair-request":
await self.handlePairRequestFrame(
data: data,
context: context)
case "event":
await self.handleEventFrame(data: data, onEvent: context.onEvent)
case "req":
try await self.handleRPCRequestFrame(data: data, onRequest: context.onRequest)
case "ping":
try await self.handlePingFrame(data: data)
case "invoke-res":
await self.handleInvokeResponseFrame(data: data)
default:
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
}
}
private func handleHelloFrame(
data: Data,
context: FrameContext) async
{
do {
let hello = try self.decoder.decode(BridgeHello.self, from: data)
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
self.nodeId = nodeId
let result = await context.resolveAuth(hello)
await self.handleAuthResult(result, serverName: context.serverName)
if case .ok = result {
await context.onAuthenticated?(
BridgeNodeInfo(
nodeId: nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
coreVersion: hello.coreVersion,
uiVersion: hello.uiVersion,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
remoteAddress: self.remoteAddressString(),
caps: hello.caps))
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handlePairRequestFrame(
data: Data,
context: FrameContext) async
{
do {
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
let nodeId = req.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
self.nodeId = nodeId
let enriched = BridgePairRequest(
type: req.type,
nodeId: nodeId,
displayName: req.displayName,
platform: req.platform,
version: req.version,
coreVersion: req.coreVersion,
uiVersion: req.uiVersion,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
caps: req.caps,
commands: req.commands,
remoteAddress: self.remoteAddressString(),
silent: req.silent)
let result = await context.handlePair(enriched)
await self.handlePairResult(result, serverName: context.serverName)
if case .ok = result {
await context.onAuthenticated?(
BridgeNodeInfo(
nodeId: nodeId,
displayName: enriched.displayName,
platform: enriched.platform,
version: enriched.version,
coreVersion: enriched.coreVersion,
uiVersion: enriched.uiVersion,
deviceFamily: enriched.deviceFamily,
modelIdentifier: enriched.modelIdentifier,
remoteAddress: enriched.remoteAddress,
caps: enriched.caps))
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handleEventFrame(
data: Data,
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?) async
{
guard self.isAuthenticated, let nodeId = self.nodeId else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
do {
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
await onEvent?(nodeId, evt)
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func handleRPCRequestFrame(
data: Data,
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?) async throws
{
let req = try self.decoder.decode(BridgeRPCRequest.self, from: data)
guard self.isAuthenticated, let nodeId = self.nodeId else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated")))
return
}
if let onRequest {
let res = await onRequest(nodeId, req)
try await self.send(res)
} else {
try await self.send(
BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported")))
}
}
private func handlePingFrame(data: Data) async throws {
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
let ping = try self.decoder.decode(BridgePing.self, from: data)
try await self.send(BridgePong(type: "pong", id: ping.id))
}
private func handleInvokeResponseFrame(data: Data) async {
guard self.isAuthenticated else {
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
return
}
do {
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
} catch {
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
}
}
private func remoteAddressString() -> String? {
switch self.connection.endpoint {
case let .hostPort(host: host, port: _):
let value = String(describing: host)
return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value
default:
return nil
}
}
func remoteAddress() -> String? {
self.remoteAddressString()
}
private func handlePairResult(_ result: PairResult, serverName: String) async {
switch result {
case let .ok(token):
do {
try await self.send(BridgePairOk(type: "pair-ok", token: token))
self.isAuthenticated = true
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch {
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
}
case .rejected:
await self.sendError(code: "UNAUTHORIZED", message: "pairing rejected")
case let .error(code, message):
await self.sendError(code: code, message: message)
}
}
private func handleAuthResult(_ result: AuthResult, serverName: String) async {
switch result {
case .ok:
self.isAuthenticated = true
do {
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
try await self.send(
BridgeHelloOk(
type: "hello-ok",
serverName: serverName,
mainSessionKey: mainSessionKey))
} catch {
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
}
case .notPaired:
await self.sendError(code: "NOT_PAIRED", message: "pairing required")
case .unauthorized:
await self.sendError(code: "UNAUTHORIZED", message: "invalid token")
case let .error(code, message):
await self.sendError(code: code, message: message)
}
}
private func sendError(code: String, message: String) async {
do {
try await self.send(BridgeErrorFrame(type: "error", code: code, message: message))
} catch {
self.logger.error("bridge send error failed: \(error.localizedDescription, privacy: .public)")
}
}
func invoke(command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
guard self.isAuthenticated else {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "UNAUTHORIZED: not authenticated",
])
}
let id = UUID().uuidString
let req = BridgeInvokeRequest(type: "invoke", id: id, command: command, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: 15 * 1_000_000_000)
await self.timeoutInvoke(id: id)
}
defer { timeoutTask.cancel() }
return try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginInvoke(id: id, request: req, continuation: cont)
}
}
}
private func beginInvoke(
id: String,
request: BridgeInvokeRequest,
continuation: CheckedContinuation<BridgeInvokeResponse, Error>) async
{
self.pendingInvokes[id] = continuation
do {
try await self.send(request)
} catch {
await self.failInvoke(id: id, error: error)
}
}
private func timeoutInvoke(id: String) async {
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
cont.resume(throwing: NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: invoke timeout",
]))
}
private func failInvoke(id: String, error: Error) async {
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
cont.resume(throwing: error)
}
private func send(_ obj: some Encodable) async throws {
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A) // \n
let _: Void = try await withCheckedThrowingContinuation { cont in
self.connection.send(content: line, completion: .contentProcessed { err in
if let err {
cont.resume(throwing: err)
} else {
cont.resume(returning: ())
}
})
}
}
func sendServerEvent(event: String, payloadJSON: String?) async {
guard self.isAuthenticated else { return }
do {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
} catch {
self.logger.error("bridge send event failed: \(error.localizedDescription, privacy: .public)")
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let lineData = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return String(data: lineData, encoding: .utf8)
}
let chunk = try await self.receiveChunk()
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
}
private func receiveChunk() async throws -> Data {
try await withCheckedThrowingContinuation { cont in
self.connection
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func close(with onDisconnected: (@Sendable (String) async -> Void)? = nil) async {
if self.isClosed { return }
self.isClosed = true
let nodeId = self.nodeId
let pending = self.pendingInvokes.values
self.pendingInvokes.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 4, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
self.connection.cancel()
if let nodeId {
await onDisconnected?(nodeId)
}
}
}

View File

@@ -1,542 +0,0 @@
import AppKit
import ClawdbotKit
import ClawdbotProtocol
import Foundation
import Network
import OSLog
actor BridgeServer {
static let shared = BridgeServer()
private let logger = Logger(subsystem: "com.clawdbot", category: "bridge")
private var listener: NWListener?
private var isRunning = false
private var store: PairedNodesStore?
private var connections: [String: BridgeConnectionHandler] = [:]
private var nodeInfoById: [String: BridgeNodeInfo] = [:]
private var presenceTasks: [String: Task<Void, Never>] = [:]
private var chatSubscriptions: [String: Set<String>] = [:]
private var gatewayPushTask: Task<Void, Never>?
func start() async {
if self.isRunning { return }
self.isRunning = true
do {
let storeURL = try Self.defaultStoreURL()
let store = PairedNodesStore(fileURL: storeURL)
await store.load()
self.store = store
let params = NWParameters.tcp
params.includePeerToPeer = true
let listener = try NWListener(using: params, on: .any)
listener.newConnectionHandler = { [weak self] connection in
guard let self else { return }
Task { await self.handle(connection: connection) }
}
listener.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleListenerState(state) }
}
listener.start(queue: DispatchQueue(label: "com.clawdbot.bridge"))
self.listener = listener
} catch {
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")
self.isRunning = false
}
}
func stop() async {
self.isRunning = false
self.listener?.cancel()
self.listener = nil
}
private func handleListenerState(_ state: NWListener.State) {
switch state {
case .ready:
self.logger.info("bridge listening")
case let .failed(err):
self.logger.error("bridge listener failed: \(err.localizedDescription, privacy: .public)")
case .cancelled:
self.logger.info("bridge listener cancelled")
case .waiting:
self.logger.info("bridge listener waiting")
case .setup:
break
@unknown default:
break
}
}
private func handle(connection: NWConnection) async {
let handler = BridgeConnectionHandler(connection: connection, logger: self.logger)
await handler.run(
resolveAuth: { [weak self] hello in
await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
},
handlePair: { [weak self] request in
await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
},
onAuthenticated: { [weak self] node in
await self?.registerConnection(handler: handler, node: node)
},
onDisconnected: { [weak self] nodeId in
await self?.unregisterConnection(nodeId: nodeId)
},
onEvent: { [weak self] nodeId, evt in
await self?.handleEvent(nodeId: nodeId, evt: evt)
},
onRequest: { [weak self] nodeId, req in
await self?.handleRequest(nodeId: nodeId, req: req)
?? BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "bridge unavailable"))
})
}
func invoke(nodeId: String, command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
guard let handler = self.connections[nodeId] else {
throw NSError(domain: "Bridge", code: 10, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: node not connected",
])
}
return try await handler.invoke(command: command, paramsJSON: paramsJSON)
}
func connectedNodeIds() -> [String] {
Array(self.connections.keys).sorted()
}
func connectedNodes() -> [BridgeNodeInfo] {
self.nodeInfoById.values.sorted { a, b in
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
}
}
func pairedNodes() async -> [PairedNode] {
guard let store = self.store else { return [] }
return await store.all()
}
private func registerConnection(handler: BridgeConnectionHandler, node: BridgeNodeInfo) async {
self.connections[node.nodeId] = handler
self.nodeInfoById[node.nodeId] = node
await self.beaconPresence(nodeId: node.nodeId, reason: "connect")
self.startPresenceTask(nodeId: node.nodeId)
self.ensureGatewayPushTask()
}
private func unregisterConnection(nodeId: String) async {
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
self.stopPresenceTask(nodeId: nodeId)
self.connections.removeValue(forKey: nodeId)
self.nodeInfoById.removeValue(forKey: nodeId)
self.chatSubscriptions[nodeId] = nil
self.stopGatewayPushTaskIfIdle()
}
private struct VoiceTranscriptPayload: Codable, Sendable {
var text: String
var sessionKey: String?
}
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
switch evt.event {
case "chat.subscribe":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
struct Subscribe: Codable { var sessionKey: String }
guard let payload = try? JSONDecoder().decode(Subscribe.self, from: data) else { return }
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
set.insert(key)
self.chatSubscriptions[nodeId] = set
case "chat.unsubscribe":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
struct Unsubscribe: Codable { var sessionKey: String }
guard let payload = try? JSONDecoder().decode(Unsubscribe.self, from: data) else { return }
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
guard !key.isEmpty else { return }
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
set.remove(key)
self.chatSubscriptions[nodeId] = set.isEmpty ? nil : set
case "voice.transcript":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
}
guard let payload = try? JSONDecoder().decode(VoiceTranscriptPayload.self, from: data) else {
return
}
let text = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "main"
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: text,
sessionKey: sessionKey,
thinking: "low",
deliver: false,
to: nil,
channel: .last))
case "agent.request":
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
return
}
guard let link = try? JSONDecoder().decode(AgentDeepLink.self, from: data) else {
return
}
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
guard !message.isEmpty else { return }
guard message.count <= 20000 else { return }
let sessionKey = link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? "node-\(nodeId)"
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let channel = GatewayAgentChannel(raw: link.channel)
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
message: message,
sessionKey: sessionKey,
thinking: thinking,
deliver: link.deliver,
to: to,
channel: channel))
default:
break
}
}
private func handleRequest(nodeId: String, req: BridgeRPCRequest) async -> BridgeRPCResponse {
let allowed: Set<String> = ["chat.history", "chat.send", "health"]
guard allowed.contains(req.method) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
}
let params: [String: ClawdbotProtocol.AnyCodable]?
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
guard let data = json.data(using: .utf8) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
}
do {
params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
} catch {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "INVALID_REQUEST", message: error.localizedDescription))
}
} else {
params = nil
}
do {
let data = try await GatewayConnection.shared.request(method: req.method, params: params, timeoutMs: 30000)
guard let json = String(data: data, encoding: .utf8) else {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: "Response not UTF-8"))
}
return BridgeRPCResponse(id: req.id, ok: true, payloadJSON: json)
} catch {
return BridgeRPCResponse(
id: req.id,
ok: false,
error: BridgeRPCError(code: "UNAVAILABLE", message: error.localizedDescription))
}
}
private func ensureGatewayPushTask() {
if self.gatewayPushTask != nil { return }
self.gatewayPushTask = Task { [weak self] in
guard let self else { return }
do {
try await GatewayConnection.shared.refresh()
} catch {
// We'll still forward events once the gateway comes up.
}
let stream = await GatewayConnection.shared.subscribe()
for await push in stream {
if Task.isCancelled { return }
await self.forwardGatewayPush(push)
}
}
}
private func stopGatewayPushTaskIfIdle() {
guard self.connections.isEmpty else { return }
self.gatewayPushTask?.cancel()
self.gatewayPushTask = nil
}
private func forwardGatewayPush(_ push: GatewayPush) async {
let subscribedNodes = self.chatSubscriptions.keys.filter { self.connections[$0] != nil }
guard !subscribedNodes.isEmpty else { return }
switch push {
case let .snapshot(hello):
let payloadJSON = (try? JSONEncoder().encode(hello.snapshot.health))
.flatMap { String(data: $0, encoding: .utf8) }
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
}
case let .event(evt):
switch evt.event {
case "health":
guard let payload = evt.payload else { return }
let payloadJSON = (try? JSONEncoder().encode(payload))
.flatMap { String(data: $0, encoding: .utf8) }
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
}
case "tick":
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "tick", payloadJSON: nil)
}
case "chat":
guard let payload = evt.payload else { return }
let payloadData = try? JSONEncoder().encode(payload)
let payloadJSON = payloadData.flatMap { String(data: $0, encoding: .utf8) }
struct MinimalChat: Codable { var sessionKey: String }
let sessionKey = payloadData.flatMap { try? JSONDecoder().decode(MinimalChat.self, from: $0) }?
.sessionKey
if let sessionKey {
for nodeId in subscribedNodes {
guard self.chatSubscriptions[nodeId]?.contains(sessionKey) == true else { continue }
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
}
} else {
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
}
}
default:
break
}
case .seqGap:
for nodeId in subscribedNodes {
await self.connections[nodeId]?.sendServerEvent(event: "seqGap", payloadJSON: nil)
}
}
}
private func beaconPresence(nodeId: String, reason: String) async {
let paired = await self.store?.find(nodeId: nodeId)
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
?? nodeId
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let ip = await self.connections[nodeId]?.remoteAddress()
var tags: [String] = ["node", "ios"]
if let platform { tags.append(platform) }
let summary = [
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
platform.map { "platform \($0)" },
version.map { "app \($0)" },
"mode node",
"reason \(reason)",
].compactMap(\.self).joined(separator: " · ")
var params: [String: ClawdbotProtocol.AnyCodable] = [
"text": ClawdbotProtocol.AnyCodable(summary),
"instanceId": ClawdbotProtocol.AnyCodable(nodeId),
"host": ClawdbotProtocol.AnyCodable(host),
"mode": ClawdbotProtocol.AnyCodable("node"),
"reason": ClawdbotProtocol.AnyCodable(reason),
"tags": ClawdbotProtocol.AnyCodable(tags),
]
if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) }
if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) }
await GatewayConnection.shared.sendSystemEvent(params)
}
private func startPresenceTask(nodeId: String) {
self.presenceTasks[nodeId]?.cancel()
self.presenceTasks[nodeId] = Task.detached { [weak self] in
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 180 * 1_000_000_000)
if Task.isCancelled { return }
await self?.beaconPresence(nodeId: nodeId, reason: "periodic")
}
}
}
private func stopPresenceTask(nodeId: String) {
self.presenceTasks[nodeId]?.cancel()
self.presenceTasks.removeValue(forKey: nodeId)
}
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
if nodeId.isEmpty {
return .error(code: "INVALID_REQUEST", message: "nodeId required")
}
guard let store = self.store else {
return .error(code: "UNAVAILABLE", message: "store unavailable")
}
guard let paired = await store.find(nodeId: nodeId) else {
return .notPaired
}
guard let token = hello.token, token == paired.token else {
return .unauthorized
}
do {
var updated = paired
let name = hello.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let platform = hello.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let version = hello.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let deviceFamily = hello.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let modelIdentifier = hello.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
if updated.displayName != name { updated.displayName = name }
if updated.platform != platform { updated.platform = platform }
if updated.version != version { updated.version = version }
if updated.deviceFamily != deviceFamily { updated.deviceFamily = deviceFamily }
if updated.modelIdentifier != modelIdentifier { updated.modelIdentifier = modelIdentifier }
if updated != paired {
try await store.upsert(updated)
} else {
try await store.touchSeen(nodeId: nodeId)
}
} catch {
// ignore
}
return .ok
}
private func pair(request: BridgePairRequest) async -> BridgeConnectionHandler.PairResult {
let nodeId = request.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
if nodeId.isEmpty {
return .error(code: "INVALID_REQUEST", message: "nodeId required")
}
guard let store = self.store else {
return .error(code: "UNAVAILABLE", message: "store unavailable")
}
let existing = await store.find(nodeId: nodeId)
let approved = await BridgePairingApprover.approve(request: request, isRepair: existing != nil)
if !approved {
return .rejected
}
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
let node = PairedNode(
nodeId: nodeId,
displayName: request.displayName,
platform: request.platform,
version: request.version,
deviceFamily: request.deviceFamily,
modelIdentifier: request.modelIdentifier,
token: token,
createdAtMs: nowMs,
lastSeenAtMs: nowMs)
do {
try await store.upsert(node)
return .ok(token: token)
} catch {
return .error(code: "UNAVAILABLE", message: "failed to persist pairing")
}
}
private static func defaultStoreURL() throws -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
guard let base else {
throw NSError(
domain: "Bridge",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Application Support unavailable"])
}
return base
.appendingPathComponent("Clawdbot", isDirectory: true)
.appendingPathComponent("bridge", isDirectory: true)
.appendingPathComponent("paired-nodes.json", isDirectory: false)
}
}
@MainActor
enum BridgePairingApprover {
static func approve(request: BridgePairRequest, isRepair: Bool) async -> Bool {
await withCheckedContinuation { cont in
let name = request.displayName ?? request.nodeId
let remote = request.remoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
let alert = NSAlert()
alert.messageText = isRepair ? "Re-pair Clawdbot Node?" : "Pair Clawdbot Node?"
alert.informativeText = """
Node: \(name)
IP: \(remote ?? "unknown")
Platform: \(request.platform ?? "unknown")
Version: \(request.version ?? "unknown")
"""
alert.addButton(withTitle: "Approve")
alert.addButton(withTitle: "Reject")
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
alert.buttons[1].hasDestructiveAction = true
}
let resp = alert.runModal()
cont.resume(returning: resp == .alertFirstButtonReturn)
}
}
}
#if DEBUG
extension BridgeServer {
func exerciseForTesting() async {
let conn = NWConnection(to: .hostPort(host: "127.0.0.1", port: 22), using: .tcp)
let handler = BridgeConnectionHandler(connection: conn, logger: self.logger)
self.connections["node-1"] = handler
self.nodeInfoById["node-1"] = BridgeNodeInfo(
nodeId: "node-1",
displayName: "Node One",
platform: "macOS",
version: "1.0.0",
deviceFamily: "Mac",
modelIdentifier: "MacBookPro18,1",
remoteAddress: "127.0.0.1",
caps: ["chat", "voice"])
_ = self.connectedNodeIds()
_ = self.connectedNodes()
self.handleListenerState(.ready)
self.handleListenerState(.failed(NWError.posix(.ECONNREFUSED)))
self.handleListenerState(.waiting(NWError.posix(.ETIMEDOUT)))
self.handleListenerState(.cancelled)
self.handleListenerState(.setup)
let subscribe = BridgeEventFrame(event: "chat.subscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
await self.handleEvent(nodeId: "node-1", evt: subscribe)
let unsubscribe = BridgeEventFrame(event: "chat.unsubscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
await self.handleEvent(nodeId: "node-1", evt: unsubscribe)
let invalid = BridgeRPCRequest(id: "req-1", method: "invalid.method", paramsJSON: nil)
_ = await self.handleRequest(nodeId: "node-1", req: invalid)
}
}
#endif

View File

@@ -1,59 +0,0 @@
import Foundation
struct PairedNode: Codable, Equatable {
var nodeId: String
var displayName: String?
var platform: String?
var version: String?
var deviceFamily: String?
var modelIdentifier: String?
var token: String
var createdAtMs: Int
var lastSeenAtMs: Int?
}
actor PairedNodesStore {
private let fileURL: URL
private var nodes: [String: PairedNode] = [:]
init(fileURL: URL) {
self.fileURL = fileURL
}
func load() {
do {
let data = try Data(contentsOf: self.fileURL)
let decoded = try JSONDecoder().decode([String: PairedNode].self, from: data)
self.nodes = decoded
} catch {
self.nodes = [:]
}
}
func all() -> [PairedNode] {
self.nodes.values.sorted { a, b in (a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId) }
}
func find(nodeId: String) -> PairedNode? {
self.nodes[nodeId]
}
func upsert(_ node: PairedNode) async throws {
self.nodes[node.nodeId] = node
try await self.persist()
}
func touchSeen(nodeId: String) async throws {
guard var node = self.nodes[nodeId] else { return }
node.lastSeenAtMs = Int(Date().timeIntervalSince1970 * 1000)
self.nodes[nodeId] = node
try await self.persist()
}
private func persist() async throws {
let dir = self.fileURL.deletingLastPathComponent()
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let data = try JSONEncoder().encode(self.nodes)
try data.write(to: self.fileURL, options: [.atomic])
}
}

View File

@@ -55,6 +55,17 @@ struct WebSocketSessionBox: @unchecked Sendable {
let session: any WebSocketSessioning
}
struct GatewayConnectOptions: Sendable {
var role: String
var scopes: [String]
var caps: [String]
var commands: [String]
var permissions: [String: Bool]
var clientId: String
var clientMode: String
var clientDisplayName: String?
}
// Avoid ambiguity with the app's own AnyCodable type.
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
@@ -81,19 +92,25 @@ actor GatewayChannelActor {
private var tickTask: Task<Void, Never>?
private let defaultRequestTimeoutMs: Double = 15000
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
private let connectOptions: GatewayConnectOptions?
private let disconnectHandler: (@Sendable (String) async -> Void)?
init(
url: URL,
token: String?,
password: String? = nil,
session: WebSocketSessionBox? = nil,
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil)
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil,
connectOptions: GatewayConnectOptions? = nil,
disconnectHandler: (@Sendable (String) async -> Void)? = nil)
{
self.url = url
self.token = token
self.password = password
self.session = session?.session ?? URLSession(configuration: .default)
self.pushHandler = pushHandler
self.connectOptions = connectOptions
self.disconnectHandler = disconnectHandler
Task { [weak self] in
await self?.startWatchdog()
}
@@ -178,6 +195,7 @@ actor GatewayChannelActor {
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
self.connected = false
self.task?.cancel(with: .goingAway, reason: nil)
await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)")
let waiters = self.connectWaiters
self.connectWaiters.removeAll()
for waiter in waiters {
@@ -202,9 +220,18 @@ actor GatewayChannelActor {
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
let clientDisplayName = InstanceIdentity.displayName
let clientId = "clawdbot-macos"
let clientMode = "ui"
let options = self.connectOptions ?? GatewayConnectOptions(
role: "operator",
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
caps: [],
commands: [],
permissions: [:],
clientId: "clawdbot-macos",
clientMode: "ui",
clientDisplayName: InstanceIdentity.displayName)
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
let clientId = options.clientId
let clientMode = options.clientMode
let reqId = UUID().uuidString
var client: [String: ProtoAnyCodable] = [
@@ -224,12 +251,18 @@ actor GatewayChannelActor {
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"client": ProtoAnyCodable(client),
"caps": ProtoAnyCodable([] as [String]),
"caps": ProtoAnyCodable(options.caps),
"locale": ProtoAnyCodable(primaryLocale),
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
"role": ProtoAnyCodable("operator"),
"scopes": ProtoAnyCodable(["operator.admin", "operator.approvals", "operator.pairing"]),
"role": ProtoAnyCodable(options.role),
"scopes": ProtoAnyCodable(options.scopes),
]
if !options.commands.isEmpty {
params["commands"] = ProtoAnyCodable(options.commands)
}
if !options.permissions.isEmpty {
params["permissions"] = ProtoAnyCodable(options.permissions)
}
if let token = self.token {
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
} else if let password = self.password {
@@ -237,13 +270,13 @@ actor GatewayChannelActor {
}
let identity = DeviceIdentityStore.loadOrCreate()
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
let scopes = "operator.admin,operator.approvals,operator.pairing"
let scopes = options.scopes.joined(separator: ",")
let payload = [
"v1",
identity.deviceId,
clientId,
clientMode,
"operator",
options.role,
scopes,
String(signedAtMs),
self.token ?? "",
@@ -344,6 +377,7 @@ actor GatewayChannelActor {
let wrapped = self.wrap(err, context: "gateway receive")
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
self.connected = false
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
await self.failPending(wrapped)
await self.scheduleReconnect()
}

View File

@@ -19,7 +19,7 @@ struct GatewayDiscoveryInlineList: View {
}
if self.discovery.gateways.isEmpty {
Text("No bridges found yet.")
Text("No gateways found yet.")
.font(.caption)
.foregroundStyle(.secondary)
} else {
@@ -40,7 +40,7 @@ struct GatewayDiscoveryInlineList: View {
.font(.callout.weight(.semibold))
.lineLimit(1)
.truncationMode(.tail)
Text(target ?? "Bridge pairing only")
Text(target ?? "Gateway pairing only")
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -83,7 +83,7 @@ struct GatewayDiscoveryInlineList: View {
.fill(Color(NSColor.controlBackgroundColor)))
}
}
.help("Click a discovered bridge to fill the SSH target.")
.help("Click a discovered gateway to fill the SSH target.")
}
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
@@ -130,6 +130,6 @@ struct GatewayDiscoveryMenu: View {
} label: {
Image(systemName: "dot.radiowaves.left.and.right")
}
.help("Discover Clawdbot bridges on your LAN")
.help("Discover Clawdbot gateways on your LAN")
}
}

View File

@@ -1,10 +1,13 @@
import Foundation
enum BridgeDiscoveryPreferences {
private static let preferredStableIDKey = "bridge.preferredStableID"
enum GatewayDiscoveryPreferences {
private static let preferredStableIDKey = "gateway.preferredStableID"
private static let legacyPreferredStableIDKey = "bridge.preferredStableID"
static func preferredStableID() -> String? {
let raw = UserDefaults.standard.string(forKey: self.preferredStableIDKey)
let defaults = UserDefaults.standard
let raw = defaults.string(forKey: self.preferredStableIDKey)
?? defaults.string(forKey: self.legacyPreferredStableIDKey)
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed?.isEmpty == false ? trimmed : nil
}
@@ -13,8 +16,10 @@ enum BridgeDiscoveryPreferences {
let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines)
if let trimmed, !trimmed.isEmpty {
UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
} else {
UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey)
UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
}
}
}

View File

@@ -15,7 +15,13 @@ enum GatewayEndpointState: Sendable, Equatable {
/// - 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 supportedBindModes: Set<String> = [
"loopback",
"tailnet",
"lan",
"auto",
"custom",
]
private static let remoteConnectingDetail = "Connecting to remote gateway…"
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
private enum EnvOverrideWarningKind: Sendable {
@@ -60,9 +66,11 @@ actor GatewayEndpointStore {
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: root,
env: ProcessInfo.processInfo.environment)
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
return GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind,
customBindHost: customBindHost,
tailscaleIP: tailscaleIP)
},
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
@@ -250,10 +258,14 @@ actor GatewayEndpointStore {
let bind = GatewayEndpointStore.resolveGatewayBindMode(
root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment)
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: ClawdbotConfigFile.loadDict())
let scheme = GatewayEndpointStore.resolveGatewayScheme(
root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment)
let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil)
let host = GatewayEndpointStore.resolveLocalGatewayHost(
bindMode: bind,
customBindHost: customBindHost,
tailscaleIP: nil)
let token = deps.token()
let password = deps.password()
switch initialMode {
@@ -417,7 +429,10 @@ actor GatewayEndpointStore {
let token = self.deps.token()
let password = self.deps.password()
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
let scheme = GatewayEndpointStore.resolveGatewayScheme(
root: ClawdbotConfigFile.loadDict(),
env: ProcessInfo.processInfo.environment)
let url = URL(string: "\(scheme)://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 {
@@ -487,6 +502,16 @@ actor GatewayEndpointStore {
return nil
}
private static func resolveGatewayCustomBindHost(root: [String: Any]) -> String? {
if let gateway = root["gateway"] as? [String: Any],
let customBindHost = gateway["customBindHost"] as? String
{
let trimmed = customBindHost.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
return nil
}
private static func resolveGatewayScheme(
root: [String: Any],
env: [String: String]) -> String
@@ -507,11 +532,14 @@ actor GatewayEndpointStore {
private static func resolveLocalGatewayHost(
bindMode: String?,
customBindHost: String?,
tailscaleIP: String?) -> String
{
switch bindMode {
case "tailnet", "auto":
tailscaleIP ?? "127.0.0.1"
case "custom":
customBindHost ?? "127.0.0.1"
default:
"127.0.0.1"
}
@@ -586,7 +614,10 @@ extension GatewayEndpointStore {
bindMode: String?,
tailscaleIP: String?) -> String
{
self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP)
self.resolveLocalGatewayHost(
bindMode: bindMode,
customBindHost: nil,
tailscaleIP: tailscaleIP)
}
}
#endif

View File

@@ -716,7 +716,7 @@ extension GeneralSettings {
}
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
let host = gateway.tailnetDns ?? gateway.lanHost
guard let host else { return }

View File

@@ -0,0 +1,105 @@
import CryptoKit
import Foundation
import Security
struct GatewayTLSParams: Sendable {
let required: Bool
let expectedFingerprint: String?
let allowTOFU: Bool
let storeKey: String?
}
enum GatewayTLSStore {
private static let suiteName = "com.clawdbot.shared"
private static let keyPrefix = "gateway.tls."
private static var defaults: UserDefaults {
UserDefaults(suiteName: suiteName) ?? .standard
}
static func loadFingerprint(stableID: String) -> String? {
let key = self.keyPrefix + stableID
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
return raw?.isEmpty == false ? raw : nil
}
static func saveFingerprint(_ value: String, stableID: String) {
let key = self.keyPrefix + stableID
self.defaults.set(value, forKey: key)
}
}
final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate {
private let params: GatewayTLSParams
private lazy var session: URLSession = {
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()
init(params: GatewayTLSParams) {
self.params = params
super.init()
}
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
let task = self.session.webSocketTask(with: url)
task.maximumMessageSize = 16 * 1024 * 1024
return WebSocketTaskBox(task: task)
}
func urlSession(
_ session: URLSession,
didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
) {
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
let trust = challenge.protectionSpace.serverTrust
else {
completionHandler(.performDefaultHandling, nil)
return
}
let expected = params.expectedFingerprint.map(normalizeFingerprint)
if let fingerprint = certificateFingerprint(trust) {
if let expected {
if fingerprint == expected {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
return
}
if params.allowTOFU {
if let storeKey = params.storeKey {
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
}
completionHandler(.useCredential, URLCredential(trust: trust))
return
}
}
let ok = SecTrustEvaluateWithError(trust, nil)
if ok || !params.required {
completionHandler(.useCredential, URLCredential(trust: trust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
}
private func certificateFingerprint(_ trust: SecTrust) -> String? {
let count = SecTrustGetCertificateCount(trust)
guard count > 0, let cert = SecTrustGetCertificateAtIndex(trust, 0) else { return nil }
let data = SecCertificateCopyData(cert) as Data
return sha256Hex(data)
}
private func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func normalizeFingerprint(_ raw: String) -> String {
raw.lowercased().filter(\.isHexDigit)
}

View File

@@ -1,238 +0,0 @@
import ClawdbotKit
import Foundation
import Network
actor MacNodeBridgePairingClient {
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var lineBuffer = Data()
func pairAndHello(
endpoint: NWEndpoint,
hello: BridgeHello,
silent: Bool,
tls: MacNodeBridgeTLSParams? = nil,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
do {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
silent: silent,
tls: tls,
onStatus: onStatus)
} catch {
if let tls, !tls.required {
return try await self.pairAndHelloOnce(
endpoint: endpoint,
hello: hello,
silent: silent,
tls: nil,
onStatus: onStatus)
}
throw error
}
}
private func pairAndHelloOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
silent: Bool,
tls: MacNodeBridgeTLSParams?,
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
{
self.lineBuffer = Data()
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
defer { connection.cancel() }
try await AsyncTimeout.withTimeout(
seconds: 8,
onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "connect timed out",
])
},
operation: {
try await self.startAndWaitForReady(connection, queue: queue)
})
onStatus?("Authenticating…")
try await self.send(hello, over: connection)
let first = try await AsyncTimeout.withTimeout(
seconds: 10,
onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "hello timed out",
])
},
operation: { () -> ReceivedFrame in
guard let frame = try await self.receiveFrame(over: connection) else {
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
])
}
return frame
})
switch first.base.type {
case "hello-ok":
return hello.token ?? ""
case "error":
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
}
onStatus?("Requesting approval…")
try await self.send(
BridgePairRequest(
nodeId: hello.nodeId,
displayName: hello.displayName,
platform: hello.platform,
version: hello.version,
coreVersion: hello.coreVersion,
uiVersion: hello.uiVersion,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier,
caps: hello.caps,
commands: hello.commands,
silent: silent),
over: connection)
onStatus?("Waiting for approval…")
let ok = try await AsyncTimeout.withTimeout(
seconds: 60,
onTimeout: {
NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "pairing approval timed out",
])
},
operation: {
while let next = try await self.receiveFrame(over: connection) {
switch next.base.type {
case "pair-ok":
return try self.decoder.decode(BridgePairOk.self, from: next.data)
case "error":
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
])
default:
continue
}
}
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
])
})
return ok.token
default:
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
}
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
private struct ReceivedFrame {
var base: BridgeBaseFrame
var data: Data
}
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
guard let lineData = try await self.receiveLineData(over: connection) else {
return nil
}
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
return ReceivedFrame(base: base, data: lineData)
}
private func receiveChunk(over connection: NWConnection) async throws -> Data {
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
while true {
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
let line = self.lineBuffer.prefix(upTo: idx)
self.lineBuffer.removeSubrange(...idx)
return Data(line)
}
let chunk = try await self.receiveChunk(over: connection)
if chunk.isEmpty { return nil }
self.lineBuffer.append(chunk)
}
}
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
let tcpOptions = NWProtocolTCP.Options()
if let tlsOptions = makeMacNodeTLSOptions(tls) {
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
return params
}
private func startAndWaitForReady(
_ connection: NWConnection,
queue: DispatchQueue) async throws
{
let states = AsyncStream<NWConnection.State> { continuation in
connection.stateUpdateHandler = { state in
continuation.yield(state)
if case .ready = state { continuation.finish() }
if case .failed = state { continuation.finish() }
if case .cancelled = state { continuation.finish() }
}
}
connection.start(queue: queue)
for await state in states {
switch state {
case .ready:
return
case let .failed(err):
throw err
case .cancelled:
throw NSError(domain: "Bridge", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Bridge connection cancelled",
])
default:
continue
}
}
}
}

View File

@@ -1,519 +0,0 @@
import ClawdbotKit
import Foundation
import Network
import OSLog
actor MacNodeBridgeSession {
private struct TimeoutError: LocalizedError {
var message: String
var errorDescription: String? { self.message }
}
enum State: Sendable, Equatable {
case idle
case connecting
case connected(serverName: String)
case failed(message: String)
}
private let logger = Logger(subsystem: "com.clawdbot", category: "node.bridge-session")
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private let clock = ContinuousClock()
private var disconnectHandler: (@Sendable (String) async -> Void)?
private var connection: NWConnection?
private var queue: DispatchQueue?
private var buffer = Data()
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
private var invokeTasks: [UUID: Task<Void, Never>] = [:]
private var pingTask: Task<Void, Never>?
private var lastPongAt: ContinuousClock.Instant?
private(set) var state: State = .idle
func connect(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: MacNodeBridgeTLSParams? = nil,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onDisconnected: (@Sendable (String) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
async throws
{
await self.disconnect()
self.disconnectHandler = onDisconnected
self.state = .connecting
do {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: tls,
onConnected: onConnected,
onInvoke: onInvoke)
} catch {
if let tls, !tls.required {
try await self.connectOnce(
endpoint: endpoint,
hello: hello,
tls: nil,
onConnected: onConnected,
onInvoke: onInvoke)
return
}
throw error
}
}
private func connectOnce(
endpoint: NWEndpoint,
hello: BridgeHello,
tls: MacNodeBridgeTLSParams?,
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
{
let params = self.makeParameters(tls: tls)
let connection = NWConnection(to: endpoint, using: params)
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
self.connection = connection
self.queue = queue
let stateStream = Self.makeStateStream(for: connection)
connection.start(queue: queue)
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
connection.stateUpdateHandler = { [weak self] state in
guard let self else { return }
Task { await self.handleConnectionState(state) }
}
try await AsyncTimeout.withTimeout(
seconds: 6,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
try await self.send(hello)
})
guard let line = try await AsyncTimeout.withTimeout(
seconds: 6,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
try await self.receiveLine()
}),
let data = line.data(using: .utf8),
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
else {
self.logger.error("node bridge hello failed (unexpected response)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
if base.type == "hello-ok" {
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
self.state = .connected(serverName: ok.serverName)
self.startPingLoop()
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil)
} else if base.type == "error" {
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
self.state = .failed(message: "\(err.code): \(err.message)")
self.logger.error("node bridge hello error: \(err.code, privacy: .public)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 2, userInfo: [
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
])
} else {
self.state = .failed(message: "Unexpected bridge response")
self.logger.error("node bridge hello failed (unexpected frame)")
await self.disconnect()
throw NSError(domain: "Bridge", code: 3, userInfo: [
NSLocalizedDescriptionKey: "Unexpected bridge response",
])
}
do {
while true {
guard let next = try await self.receiveLine() else { break }
guard let nextData = next.data(using: .utf8) else { continue }
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
switch nextBase.type {
case "res":
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
cont.resume(returning: res)
}
case "event":
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
self.broadcastServerEvent(evt)
case "ping":
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
try await self.send(BridgePong(type: "pong", id: ping.id))
case "pong":
let pong = try self.decoder.decode(BridgePong.self, from: nextData)
self.notePong(pong)
case "invoke":
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
let taskID = UUID()
let task = Task { [weak self] in
let res = await onInvoke(req)
guard let self else { return }
await self.sendInvokeResponse(res, taskID: taskID)
}
self.invokeTasks[taskID] = task
default:
continue
}
}
await self.handleDisconnect(reason: "connection closed")
} catch {
self.logger.error(
"node bridge receive failed: \(error.localizedDescription, privacy: .public)")
await self.handleDisconnect(reason: "receive failed")
throw error
}
}
func sendEvent(event: String, payloadJSON: String?) async throws {
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
}
func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
guard self.connection != nil else {
throw NSError(domain: "Bridge", code: 11, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let id = UUID().uuidString
let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON)
let timeoutTask = Task {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
await self.timeoutRPC(id: id)
}
defer { timeoutTask.cancel() }
let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in
Task { [weak self] in
guard let self else { return }
await self.beginRPC(id: id, request: req, continuation: cont)
}
}
if res.ok {
let payload = res.payloadJSON ?? ""
guard let data = payload.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 12, userInfo: [
NSLocalizedDescriptionKey: "Bridge response not UTF-8",
])
}
return data
}
let code = res.error?.code ?? "UNAVAILABLE"
let message = res.error?.message ?? "request failed"
throw NSError(domain: "Bridge", code: 13, userInfo: [
NSLocalizedDescriptionKey: "\(code): \(message)",
])
}
func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<BridgeEventFrame> {
let id = UUID()
let session = self
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
self.serverEventSubscribers[id] = continuation
continuation.onTermination = { @Sendable _ in
Task { await session.removeServerEventSubscriber(id) }
}
}
}
func disconnect() async {
self.pingTask?.cancel()
self.pingTask = nil
self.lastPongAt = nil
self.disconnectHandler = nil
self.cancelInvokeTasks()
self.connection?.cancel()
self.connection = nil
self.queue = nil
self.buffer = Data()
let pending = self.pendingRPC.values
self.pendingRPC.removeAll()
for cont in pending {
cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
]))
}
for (_, cont) in self.serverEventSubscribers {
cont.finish()
}
self.serverEventSubscribers.removeAll()
self.state = .idle
}
private func beginRPC(
id: String,
request: BridgeRPCRequest,
continuation: CheckedContinuation<BridgeRPCResponse, Error>) async
{
self.pendingRPC[id] = continuation
do {
try await self.send(request)
} catch {
await self.failRPC(id: id, error: error)
}
}
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
let tcpOptions = NWProtocolTCP.Options()
tcpOptions.enableKeepalive = true
tcpOptions.keepaliveIdle = 30
tcpOptions.keepaliveInterval = 15
tcpOptions.keepaliveCount = 3
if let tlsOptions = makeMacNodeTLSOptions(tls) {
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
params.includePeerToPeer = true
return params
}
let params = NWParameters.tcp
params.includePeerToPeer = true
params.defaultProtocolStack.transportProtocol = tcpOptions
return params
}
private func failRPC(id: String, error: Error) async {
if let cont = self.pendingRPC.removeValue(forKey: id) {
cont.resume(throwing: error)
}
}
private func timeoutRPC(id: String) async {
if let cont = self.pendingRPC.removeValue(forKey: id) {
cont.resume(throwing: TimeoutError(message: "request timed out"))
}
}
private func removeServerEventSubscriber(_ id: UUID) {
self.serverEventSubscribers[id] = nil
}
private func broadcastServerEvent(_ evt: BridgeEventFrame) {
for (_, cont) in self.serverEventSubscribers {
cont.yield(evt)
}
}
private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let data = try self.encoder.encode(obj)
var line = Data()
line.append(data)
line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in
connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
})
}
}
private func receiveLine() async throws -> String? {
while true {
if let idx = self.buffer.firstIndex(of: 0x0A) {
let line = self.buffer.prefix(upTo: idx)
self.buffer.removeSubrange(...idx)
return String(data: line, encoding: .utf8)
}
let chunk = try await self.receiveChunk()
if chunk.isEmpty { return nil }
self.buffer.append(chunk)
}
}
private func receiveChunk() async throws -> Data {
guard let connection else { return Data() }
return try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Data, Error>) in
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
if let error {
cont.resume(throwing: error)
return
}
if isComplete {
cont.resume(returning: Data())
return
}
cont.resume(returning: data ?? Data())
}
}
}
private func startPingLoop() {
self.pingTask?.cancel()
self.lastPongAt = self.clock.now
self.logger.debug("node bridge ping loop started")
self.pingTask = Task { [weak self] in
guard let self else { return }
await self.runPingLoop()
}
}
private func runPingLoop() async {
let interval: Duration = .seconds(15)
let timeout: Duration = .seconds(45)
while !Task.isCancelled {
try? await Task.sleep(for: interval)
guard self.connection != nil else { return }
if let last = self.lastPongAt {
let now = self.clock.now
if now > last.advanced(by: timeout) {
let age = last.duration(to: now)
let ageDescription = String(describing: age)
let message =
"Node bridge heartbeat timed out; disconnecting " +
"(age: \(ageDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "ping timeout")
return
}
}
let id = UUID().uuidString
do {
try await self.send(BridgePing(type: "ping", id: id))
} catch {
let errorDescription = String(describing: error)
let message =
"Node bridge ping send failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "ping send failed")
return
}
}
}
private func notePong(_ pong: BridgePong) {
_ = pong
self.lastPongAt = self.clock.now
}
private func handleConnectionState(_ state: NWConnection.State) async {
switch state {
case let .failed(error):
let errorDescription = String(describing: error)
let message =
"Node bridge connection failed; disconnecting " +
"(error: \(errorDescription, privacy: .public))."
self.logger.warning(message)
await self.handleDisconnect(reason: "connection failed")
case .cancelled:
self.logger.warning("Node bridge connection cancelled; disconnecting.")
await self.handleDisconnect(reason: "connection cancelled")
default:
break
}
}
private func handleDisconnect(reason: String) async {
self.logger.info("node bridge disconnect reason=\(reason, privacy: .public)")
if let handler = self.disconnectHandler {
await handler(reason)
}
await self.disconnect()
}
private func logInvokeSendFailure(_ error: Error) {
self.logger.error(
"node bridge invoke response send failed: \(error.localizedDescription, privacy: .public)")
}
private func sendInvokeResponse(_ response: BridgeInvokeResponse, taskID: UUID) async {
defer { self.invokeTasks[taskID] = nil }
if Task.isCancelled { return }
do {
try await self.send(response)
} catch {
self.logInvokeSendFailure(error)
}
}
private func cancelInvokeTasks() {
for task in self.invokeTasks.values {
task.cancel()
}
self.invokeTasks.removeAll()
}
private static func makeStateStream(
for connection: NWConnection) -> AsyncStream<NWConnection.State>
{
AsyncStream { continuation in
connection.stateUpdateHandler = { state in
continuation.yield(state)
switch state {
case .ready, .failed, .cancelled:
continuation.finish()
default:
break
}
}
}
}
private static func waitForReady(
_ stream: AsyncStream<NWConnection.State>,
timeoutSeconds: Double) async throws
{
try await AsyncTimeout.withTimeout(
seconds: timeoutSeconds,
onTimeout: {
TimeoutError(message: "operation timed out")
},
operation: {
for await state in stream {
switch state {
case .ready:
return
case let .failed(err):
throw err
case .cancelled:
throw NSError(domain: "Bridge", code: 20, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
])
default:
continue
}
}
throw NSError(domain: "Bridge", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Connection closed",
])
})
}
}

View File

@@ -1,74 +0,0 @@
import CryptoKit
import Foundation
import Network
import Security
struct MacNodeBridgeTLSParams: Sendable {
let required: Bool
let expectedFingerprint: String?
let allowTOFU: Bool
let storeKey: String?
}
enum MacNodeBridgeTLSStore {
private static let suiteName = "com.clawdbot.shared"
private static let keyPrefix = "mac.node.bridge.tls."
private static var defaults: UserDefaults {
UserDefaults(suiteName: suiteName) ?? .standard
}
static func loadFingerprint(stableID: String) -> String? {
let key = self.keyPrefix + stableID
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
return raw?.isEmpty == false ? raw : nil
}
static func saveFingerprint(_ value: String, stableID: String) {
let key = self.keyPrefix + stableID
self.defaults.set(value, forKey: key)
}
}
func makeMacNodeTLSOptions(_ params: MacNodeBridgeTLSParams?) -> NWProtocolTLS.Options? {
guard let params else { return nil }
let options = NWProtocolTLS.Options()
let expected = params.expectedFingerprint.map(normalizeMacNodeFingerprint)
let allowTOFU = params.allowTOFU
let storeKey = params.storeKey
sec_protocol_options_set_verify_block(
options.securityProtocolOptions,
{ _, trust, complete in
let trustRef = sec_trust_copy_ref(trust).takeRetainedValue()
if let chain = SecTrustCopyCertificateChain(trustRef) as? [SecCertificate],
let cert = chain.first
{
let data = SecCertificateCopyData(cert) as Data
let fingerprint = sha256Hex(data)
if let expected {
complete(fingerprint == expected)
return
}
if allowTOFU {
if let storeKey { MacNodeBridgeTLSStore.saveFingerprint(fingerprint, stableID: storeKey) }
complete(true)
return
}
}
let ok = SecTrustEvaluateWithError(trustRef, nil)
complete(ok)
},
DispatchQueue(label: "com.clawdbot.macos.bridge.tls.verify"))
return options
}
private func sha256Hex(_ data: Data) -> String {
let digest = SHA256.hash(data: data)
return digest.map { String(format: "%02x", $0) }.joined()
}
private func normalizeMacNodeFingerprint(_ raw: String) -> String {
raw.lowercased().filter(\.isHexDigit)
}

View File

@@ -0,0 +1,150 @@
import ClawdbotKit
import ClawdbotProtocol
import Foundation
import OSLog
private struct NodeInvokeRequestPayload: Codable, Sendable {
var id: String
var nodeId: String
var command: String
var paramsJSON: String?
var timeoutMs: Int?
var idempotencyKey: String?
}
actor MacNodeGatewaySession {
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()
private var channel: GatewayChannelActor?
private var activeURL: URL?
private var activeToken: String?
private var activePassword: String?
private var connectOptions: GatewayConnectOptions?
private var onConnected: (@Sendable () async -> Void)?
private var onDisconnected: (@Sendable (String) async -> Void)?
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
func connect(
url: URL,
token: String?,
password: String?,
connectOptions: GatewayConnectOptions,
sessionBox: WebSocketSessionBox?,
onConnected: @escaping @Sendable () async -> Void,
onDisconnected: @escaping @Sendable (String) async -> Void,
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
) async throws {
let shouldReconnect = self.activeURL != url ||
self.activeToken != token ||
self.activePassword != password ||
self.channel == nil
self.connectOptions = connectOptions
self.onConnected = onConnected
self.onDisconnected = onDisconnected
self.onInvoke = onInvoke
if shouldReconnect {
if let existing = self.channel {
await existing.shutdown()
}
let channel = GatewayChannelActor(
url: url,
token: token,
password: password,
session: sessionBox,
pushHandler: { [weak self] push in
await self?.handlePush(push)
},
connectOptions: connectOptions,
disconnectHandler: { [weak self] reason in
await self?.onDisconnected?(reason)
})
self.channel = channel
self.activeURL = url
self.activeToken = token
self.activePassword = password
}
guard let channel = self.channel else {
throw NSError(domain: "Gateway", code: 0, userInfo: [
NSLocalizedDescriptionKey: "gateway channel unavailable",
])
}
do {
try await channel.connect()
await onConnected()
} catch {
await onDisconnected(error.localizedDescription)
throw error
}
}
func disconnect() async {
await self.channel?.shutdown()
self.channel = nil
self.activeURL = nil
self.activeToken = nil
self.activePassword = nil
}
func sendEvent(event: String, payloadJSON: String?) async {
guard let channel = self.channel else { return }
let params: [String: ClawdbotProtocol.AnyCodable] = [
"event": ClawdbotProtocol.AnyCodable(event),
"payloadJSON": ClawdbotProtocol.AnyCodable(payloadJSON ?? NSNull()),
]
do {
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
} catch {
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
}
}
private func handlePush(_ push: GatewayPush) async {
switch push {
case let .event(evt):
await self.handleEvent(evt)
default:
break
}
}
private func handleEvent(_ evt: EventFrame) async {
guard evt.event == "node.invoke.request" else { return }
guard let payload = evt.payload else { return }
do {
let data = try self.encoder.encode(payload)
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
guard let onInvoke else { return }
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
let response = await onInvoke(req)
await self.sendInvokeResult(request: request, response: response)
} catch {
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
}
}
private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async {
guard let channel = self.channel else { return }
var params: [String: ClawdbotProtocol.AnyCodable] = [
"id": ClawdbotProtocol.AnyCodable(request.id),
"nodeId": ClawdbotProtocol.AnyCodable(request.nodeId),
"ok": ClawdbotProtocol.AnyCodable(response.ok),
"payloadJSON": ClawdbotProtocol.AnyCodable(response.payloadJSON ?? NSNull()),
]
if let error = response.error {
params["error"] = ClawdbotProtocol.AnyCodable([
"code": ClawdbotProtocol.AnyCodable(error.code.rawValue),
"message": ClawdbotProtocol.AnyCodable(error.message),
])
}
do {
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
} catch {
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
}
}
}

View File

@@ -1,15 +1,7 @@
import ClawdbotDiscovery
import ClawdbotKit
import Foundation
import Network
import OSLog
private struct BridgeTarget {
let endpoint: NWEndpoint
let stableID: String
let tls: MacNodeBridgeTLSParams?
}
@MainActor
final class MacNodeModeCoordinator {
static let shared = MacNodeModeCoordinator()
@@ -17,8 +9,7 @@ final class MacNodeModeCoordinator {
private let logger = Logger(subsystem: "com.clawdbot", category: "mac-node")
private var task: Task<Void, Never>?
private let runtime = MacNodeRuntime()
private let session = MacNodeBridgeSession()
private var tunnel: RemotePortTunnel?
private let session = MacNodeGatewaySession()
func start() {
guard self.task == nil else { return }
@@ -31,12 +22,10 @@ final class MacNodeModeCoordinator {
self.task?.cancel()
self.task = nil
Task { await self.session.disconnect() }
self.tunnel?.terminate()
self.tunnel = nil
}
func setPreferredBridgeStableID(_ stableID: String?) {
BridgeDiscoveryPreferences.setPreferredStableID(stableID)
func setPreferredGatewayStableID(_ stableID: String?) {
GatewayDiscoveryPreferences.setPreferredStableID(stableID)
Task { await self.session.disconnect() }
}
@@ -44,6 +33,7 @@ final class MacNodeModeCoordinator {
var retryDelay: UInt64 = 1_000_000_000
var lastCameraEnabled: Bool?
let defaults = UserDefaults.standard
while !Task.isCancelled {
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
try? await Task.sleep(nanoseconds: 1_000_000_000)
@@ -59,34 +49,42 @@ final class MacNodeModeCoordinator {
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000)
continue
}
retryDelay = 1_000_000_000
do {
let hello = await self.makeHello()
self.logger.info(
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)")
let config = try await GatewayEndpointStore.shared.requireConfig()
let caps = self.currentCaps()
let commands = self.currentCommands(caps: caps)
let permissions = await self.currentPermissions()
let connectOptions = GatewayConnectOptions(
role: "node",
scopes: [],
caps: caps,
commands: commands,
permissions: permissions,
clientId: "clawdbot-macos",
clientMode: "node",
clientDisplayName: InstanceIdentity.displayName)
let sessionBox = self.buildSessionBox(url: config.url)
try await self.session.connect(
endpoint: target.endpoint,
hello: hello,
tls: target.tls,
onConnected: { [weak self] serverName, mainSessionKey in
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
if let mainSessionKey {
await self?.runtime.updateMainSessionKey(mainSessionKey)
}
await self?.runtime.setEventSender { [weak self] event, payload in
url: config.url,
token: config.token,
password: config.password,
connectOptions: connectOptions,
sessionBox: sessionBox,
onConnected: { [weak self] in
guard let self else { return }
self.logger.info("mac node connected to gateway")
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
await self.runtime.updateMainSessionKey(mainSessionKey)
await self.runtime.setEventSender { [weak self] event, payload in
guard let self else { return }
try? await self.session.sendEvent(event: event, payloadJSON: payload)
await self.session.sendEvent(event: event, payloadJSON: payload)
}
},
onDisconnected: { [weak self] reason in
await self?.runtime.setEventSender(nil)
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
guard let self else { return }
await self.runtime.setEventSender(nil)
self.logger.error("mac node disconnected: \(reason, privacy: .public)")
},
onInvoke: { [weak self] req in
guard let self else {
@@ -97,43 +95,17 @@ final class MacNodeModeCoordinator {
}
return await self.runtime.handleInvoke(req)
})
retryDelay = 1_000_000_000
try? await Task.sleep(nanoseconds: 1_000_000_000)
} catch {
if await self.tryPair(target: target, error: error) {
continue
}
self.logger.error(
"mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
retryDelay = min(retryDelay * 2, 10_000_000_000)
}
}
}
private func makeHello() async -> BridgeHello {
let token = MacNodeTokenStore.loadToken()
let caps = self.currentCaps()
let commands = self.currentCommands(caps: caps)
let permissions = await self.currentPermissions()
let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion()
let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion
let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)?
.trimmingCharacters(in: .whitespacesAndNewlines)
return BridgeHello(
nodeId: Self.nodeId(),
displayName: InstanceIdentity.displayName,
token: token,
platform: "macos",
version: uiVersion,
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
uiVersion: uiVersion,
deviceFamily: "Mac",
modelIdentifier: InstanceIdentity.modelIdentifier,
caps: caps,
commands: commands,
permissions: permissions)
}
private func currentCaps() -> [String] {
var caps: [String] = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
@@ -182,370 +154,18 @@ final class MacNodeModeCoordinator {
return commands
}
private func tryPair(target: BridgeTarget, error: Error) async -> Bool {
let text = error.localizedDescription.uppercased()
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
do {
let shouldSilent = await MainActor.run {
AppStateStore.shared.connectionMode == .remote
}
let hello = await self.makeHello()
let token = try await MacNodeBridgePairingClient().pairAndHello(
endpoint: target.endpoint,
hello: hello,
silent: shouldSilent,
tls: target.tls,
onStatus: { [weak self] status in
self?.logger.info("mac node pairing: \(status, privacy: .public)")
})
if !token.isEmpty {
MacNodeTokenStore.saveToken(token)
}
return true
} catch {
self.logger.error("mac node pairing failed: \(error.localizedDescription, privacy: .public)")
return false
}
}
private static func nodeId() -> String {
"mac-\(InstanceIdentity.instanceId)"
}
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
guard let port = Self.loopbackBridgePort(),
let endpointPort = NWEndpoint.Port(rawValue: port)
else {
return nil
}
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
guard reachable else { return nil }
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
}
static func loopbackBridgePort() -> UInt16? {
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_BRIDGE_PORT"],
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
parsed > 0,
parsed <= Int(UInt16.max)
{
return UInt16(parsed)
}
return 18790
}
static func remoteBridgePort() -> Int {
let fallback = Int(Self.loopbackBridgePort() ?? 18790)
let settings = CommandResolver.connectionSettings()
let sshHost = CommandResolver.parseSSHTarget(settings.target)?.host ?? ""
let base =
ClawdbotConfigFile.remoteGatewayPort(matchingHost: sshHost) ??
GatewayEnvironment.gatewayPort()
guard base > 0 else { return fallback }
return Self.derivePort(base: base, offset: 1, fallback: fallback)
}
private static func derivePort(base: Int, offset: Int, fallback: Int) -> Int {
let derived = base + offset
guard derived > 0, derived <= Int(UInt16.max) else { return fallback }
return derived
}
static func probeEndpoint(_ endpoint: NWEndpoint, timeoutSeconds: Double) async -> Bool {
let connection = NWConnection(to: endpoint, using: .tcp)
let stream = Self.makeStateStream(for: connection)
connection.start(queue: DispatchQueue(label: "com.clawdbot.macos.bridge-loopback-probe"))
do {
try await Self.waitForReady(stream, timeoutSeconds: timeoutSeconds)
connection.cancel()
return true
} catch {
connection.cancel()
return false
}
}
private static func makeStateStream(
for connection: NWConnection) -> AsyncStream<NWConnection.State>
{
AsyncStream { continuation in
connection.stateUpdateHandler = { state in
continuation.yield(state)
switch state {
case .ready, .failed, .cancelled:
continuation.finish()
default:
break
}
}
}
}
private static func waitForReady(
_ stream: AsyncStream<NWConnection.State>,
timeoutSeconds: Double) async throws
{
try await AsyncTimeout.withTimeout(
seconds: timeoutSeconds,
onTimeout: {
NSError(domain: "Bridge", code: 22, userInfo: [
NSLocalizedDescriptionKey: "operation timed out",
])
},
operation: {
for await state in stream {
switch state {
case .ready:
return
case let .failed(err):
throw err
case .cancelled:
throw NSError(domain: "Bridge", code: 20, userInfo: [
NSLocalizedDescriptionKey: "Connection cancelled",
])
default:
continue
}
}
throw NSError(domain: "Bridge", code: 21, userInfo: [
NSLocalizedDescriptionKey: "Connection closed",
])
})
}
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
if mode == .remote {
do {
if let tunnel = self.tunnel,
tunnel.process.isRunning,
let localPort = tunnel.localPort
{
let healthy = await self.bridgeTunnelHealthy(localPort: localPort, timeoutSeconds: 1.0)
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
self.logger.info(
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
}
self.logger.error(
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
tunnel.terminate()
self.tunnel = nil
}
let remotePort = Self.remoteBridgePort()
let preferredLocalPort = Self.loopbackBridgePort()
if let preferredLocalPort {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)")
} else {
self.logger.info(
"mac node bridge tunnel starting " +
"preferredLocalPort=none " +
"remotePort=\(remotePort, privacy: .public)")
}
self.tunnel = try await RemotePortTunnel.create(
remotePort: remotePort,
preferredLocalPort: preferredLocalPort,
allowRemoteUrlOverride: false,
allowRandomLocalPort: true)
if let localPort = self.tunnel?.localPort,
let port = NWEndpoint.Port(rawValue: localPort)
{
self.logger.info(
"mac node bridge tunnel ready " +
"localPort=\(localPort, privacy: .public) " +
"remotePort=\(remotePort, privacy: .public)")
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
let stableID = BridgeEndpointID.stableID(endpoint)
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
}
} catch {
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
self.tunnel?.terminate()
self.tunnel = nil
}
} else if let tunnel = self.tunnel {
tunnel.terminate()
self.tunnel = nil
}
if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
return target
}
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
}
@MainActor
private static func handleBridgeDisconnect(reason: String) async {
guard reason.localizedCaseInsensitiveContains("ping") else { return }
let coordinator = MacNodeModeCoordinator.shared
coordinator.logger.error(
"mac node bridge disconnected (\(reason, privacy: .public)); resetting tunnel")
coordinator.tunnel?.terminate()
coordinator.tunnel = nil
}
private func bridgeTunnelHealthy(localPort: UInt16, timeoutSeconds: Double) async -> Bool {
guard let port = NWEndpoint.Port(rawValue: localPort) else { return false }
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
}
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
final class DiscoveryState: @unchecked Sendable {
let lock = NSLock()
var resolved = false
var browsers: [NWBrowser] = []
var continuation: CheckedContinuation<BridgeTarget?, Never>?
func finish(_ target: BridgeTarget?) {
self.lock.lock()
defer { lock.unlock() }
if self.resolved { return }
self.resolved = true
for browser in self.browsers {
browser.cancel()
}
self.continuation?.resume(returning: target)
self.continuation = nil
}
}
return await withCheckedContinuation { cont in
let state = DiscoveryState()
state.continuation = cont
let params = NWParameters.tcp
params.includePeerToPeer = true
for domain in ClawdbotBonjour.bridgeServiceDomains {
let browser = NWBrowser(
for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
using: params)
browser.browseResultsChangedHandler = { results, _ in
let preferred = BridgeDiscoveryPreferences.preferredStableID()
if let preferred,
let match = results.first(where: {
if case .service = $0.endpoint {
return BridgeEndpointID.stableID($0.endpoint) == preferred
}
return false
})
{
state.finish(Self.targetFromResult(match))
return
}
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
state.finish(Self.targetFromResult(result))
}
}
browser.stateUpdateHandler = { browserState in
if case .failed = browserState {
state.finish(nil)
}
}
state.browsers.append(browser)
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.bridge-discovery.\(domain)"))
}
Task {
try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
state.finish(nil)
}
}
}
private nonisolated static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
let endpoint = result.endpoint
guard case .service = endpoint else { return nil }
let stableID = BridgeEndpointID.stableID(endpoint)
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
let tlsEnabled = Self.txtBoolValue(txt, key: "bridgeTls")
let tlsFingerprint = Self.txtValue(txt, key: "bridgeTlsSha256")
let tlsParams = Self.resolveDiscoveredTLSParams(
stableID: stableID,
tlsEnabled: tlsEnabled,
tlsFingerprintSha256: tlsFingerprint)
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
}
private nonisolated static func resolveDiscoveredTLSParams(
stableID: String,
tlsEnabled: Bool,
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
{
let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || tlsFingerprintSha256 != nil {
return MacNodeBridgeTLSParams(
required: true,
expectedFingerprint: tlsFingerprintSha256 ?? stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
if let stored {
return MacNodeBridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return nil
}
private nonisolated static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
return MacNodeBridgeTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: false,
storeKey: stableID)
}
return MacNodeBridgeTLSParams(
required: false,
expectedFingerprint: nil,
allowTOFU: true,
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
guard url.scheme?.lowercased() == "wss" else { return nil }
let host = url.host ?? "gateway"
let port = url.port ?? 443
let stableID = "\(host):\(port)"
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
let params = GatewayTLSParams(
required: true,
expectedFingerprint: stored,
allowTOFU: stored == nil,
storeKey: stableID)
}
private nonisolated static func txtValue(_ dict: [String: String], key: String) -> String? {
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return raw.isEmpty ? nil : raw
}
private nonisolated static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
return raw == "1" || raw == "true" || raw == "yes"
}
}
enum MacNodeTokenStore {
private static let suiteName = "com.clawdbot.shared"
private static let tokenKey = "mac.node.bridge.token"
private static var defaults: UserDefaults {
UserDefaults(suiteName: suiteName) ?? .standard
}
static func loadToken() -> String? {
let raw = self.defaults.string(forKey: self.tokenKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
return raw?.isEmpty == false ? raw : nil
}
static func saveToken(_ token: String) {
self.defaults.set(token, forKey: self.tokenKey)
let session = GatewayTLSPinningSession(params: params)
return WebSocketSessionBox(session: session)
}
}

View File

@@ -486,46 +486,20 @@ actor MacNodeRuntime {
return false
}()
var approvedByAsk = false
if requiresAsk {
let decision = await ExecApprovalsPromptPresenter.prompt(
ExecApprovalPromptRequest(
command: displayCommand,
cwd: params.cwd,
let approvedByAsk = params.approved == true
if requiresAsk && !approvedByAsk {
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
security: security.rawValue,
ask: ask.rawValue,
agentId: agentId,
resolvedPath: resolution?.resolvedPath))
switch decision {
case .deny:
await self.emitExecEvent(
"exec.denied",
payload: ExecEventPayload(
sessionKey: sessionKey,
runId: runId,
host: "node",
command: displayCommand,
reason: "user-denied"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: user denied")
case .allowAlways:
approvedByAsk = true
if security == .allowlist {
let pattern = resolution?.resolvedPath ??
resolution?.rawExecutable ??
command.first?.trimmingCharacters(in: .whitespacesAndNewlines) ??
""
if !pattern.isEmpty {
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
}
}
case .allowOnce:
approvedByAsk = true
}
command: displayCommand,
reason: "approval-required"))
return Self.errorResponse(
req,
code: .unavailable,
message: "SYSTEM_RUN_DENIED: approval required")
}
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
@@ -762,7 +736,7 @@ actor MacNodeRuntime {
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
guard let json, let data = json.data(using: .utf8) else {
throw NSError(domain: "Bridge", code: 20, userInfo: [
throw NSError(domain: "Gateway", code: 20, userInfo: [
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
])
}

View File

@@ -543,7 +543,7 @@ final class NodePairingApprovalPrompter {
try? await Task.sleep(nanoseconds: 200_000_000)
}
let preferred = BridgeDiscoveryPreferences.preferredStableID()
let preferred = GatewayDiscoveryPreferences.preferredStableID()
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
guard let gateway else { return nil }
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??

View File

@@ -9,7 +9,7 @@ extension OnboardingView {
self.state.connectionMode = .local
self.preferredGatewayID = nil
self.showAdvancedConnection = false
BridgeDiscoveryPreferences.setPreferredStableID(nil)
GatewayDiscoveryPreferences.setPreferredStableID(nil)
}
func selectUnconfiguredGateway() {
@@ -17,13 +17,13 @@ extension OnboardingView {
self.state.connectionMode = .unconfigured
self.preferredGatewayID = nil
self.showAdvancedConnection = false
BridgeDiscoveryPreferences.setPreferredStableID(nil)
GatewayDiscoveryPreferences.setPreferredStableID(nil)
}
func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
Task { await self.onboardingWizard.cancelIfRunning() }
self.preferredGatewayID = gateway.stableID
BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID)
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
if let host = gateway.tailnetDns ?? gateway.lanHost {
let user = NSUserName()
@@ -36,7 +36,7 @@ extension OnboardingView {
self.state.remoteCliPath = gateway.cliPath ?? ""
self.state.connectionMode = .remote
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
}
func openSettings(tab: SettingsTab) {

View File

@@ -63,7 +63,7 @@ extension OnboardingView {
await self.ensureDefaultWorkspace()
self.refreshAnthropicOAuthStatus()
self.refreshBootstrapStatus()
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID()
}
}

View File

@@ -77,7 +77,7 @@ extension OnboardingView {
.font(.largeTitle.weight(.semibold))
Text(
"Clawdbot uses a single Gateway that stays running. Pick this Mac, " +
"connect to a discovered bridge nearby for pairing, or configure later.")
"connect to a discovered gateway nearby, or configure later.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -126,13 +126,13 @@ extension OnboardingView {
}
if self.gatewayDiscovery.gateways.isEmpty {
Text("Searching for nearby bridges…")
Text("Searching for nearby gateways…")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
} else {
VStack(alignment: .leading, spacing: 6) {
Text("Nearby bridges (pairing only)")
Text("Nearby gateways")
.font(.caption)
.foregroundStyle(.secondary)
.padding(.leading, 4)
@@ -229,12 +229,12 @@ extension OnboardingView {
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
return "\(host)\(portSuffix)"
}
return "Bridge pairing only"
return "Gateway pairing only"
}
func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
guard self.state.connectionMode == .remote else { return false }
let preferred = self.preferredGatewayID ?? BridgeDiscoveryPreferences.preferredStableID()
let preferred = self.preferredGatewayID ?? GatewayDiscoveryPreferences.preferredStableID()
return preferred == gateway.stableID
}

View File

@@ -9,14 +9,14 @@ extension OnboardingView {
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
discovery.statusText = "Searching..."
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
displayName: "Test Bridge",
lanHost: "bridge.local",
tailnetDns: "bridge.ts.net",
displayName: "Test Gateway",
lanHost: "gateway.local",
tailnetDns: "gateway.ts.net",
sshPort: 2222,
gatewayPort: 18789,
cliPath: "/usr/local/bin/clawdbot",
stableID: "bridge-1",
debugID: "bridge-1",
stableID: "gateway-1",
debugID: "gateway-1",
isLocal: false)
discovery.gateways = [gateway]

View File

@@ -81,11 +81,11 @@ public final class GatewayDiscoveryModel {
public func start() {
if !self.browsers.isEmpty { return }
for domain in ClawdbotBonjour.bridgeServiceDomains {
for domain in ClawdbotBonjour.gatewayServiceDomains {
let params = NWParameters.tcp
params.includePeerToPeer = true
let browser = NWBrowser(
for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
for: .bonjour(type: ClawdbotBonjour.gatewayServiceType, domain: domain),
using: params)
browser.stateUpdateHandler = { [weak self] state in
@@ -113,7 +113,7 @@ public final class GatewayDiscoveryModel {
}
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
Task.detached(priority: .utility) { [weak self] in
guard let self else { return }
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
@@ -174,7 +174,7 @@ public final class GatewayDiscoveryModel {
}
// Bonjour can return only "local" results for the wide-area domain (or no results at all),
// which makes onboarding look empty even though Tailscale DNS-SD can already see bridges.
// which makes onboarding look empty even though Tailscale DNS-SD can already see gateways.
guard !self.wideAreaFallbackGateways.isEmpty else {
self.gateways = primaryFiltered
return
@@ -194,7 +194,7 @@ public final class GatewayDiscoveryModel {
guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil }
let decodedName = BonjourEscapes.decode(name)
let stableID = BridgeEndpointID.stableID(result.endpoint)
let stableID = GatewayEndpointID.stableID(result.endpoint)
let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:]
let txt = Self.txtDictionary(from: result).merging(
resolvedTXT,
@@ -230,12 +230,12 @@ public final class GatewayDiscoveryModel {
gatewayPort: parsedTXT.gatewayPort,
cliPath: parsedTXT.cliPath,
stableID: stableID,
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
isLocal: isLocal)
}
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
if domain == ClawdbotBonjour.wideAreaBridgeServiceDomain,
if domain == ClawdbotBonjour.wideAreaGatewayServiceDomain,
self.hasUsableWideAreaResults
{
self.wideAreaFallbackGateways = []
@@ -243,7 +243,7 @@ public final class GatewayDiscoveryModel {
}
private func scheduleWideAreaFallback() {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
if Self.isRunningTests { return }
guard self.wideAreaFallbackTask == nil else { return }
self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in
@@ -276,7 +276,7 @@ public final class GatewayDiscoveryModel {
}
private var hasUsableWideAreaResults: Bool {
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
if !self.filterLocalGateways { return true }
return gateways.contains(where: { !$0.isLocal })
@@ -462,7 +462,7 @@ public final class GatewayDiscoveryModel {
private nonisolated static func prettifyServiceName(_ decodedName: String) -> String {
let normalized = Self.prettifyInstanceName(decodedName)
var cleaned = normalized.replacingOccurrences(of: #"\s*-?bridge$"#, with: "", options: .regularExpression)
var cleaned = normalized.replacingOccurrences(of: #"\s*-?gateway$"#, with: "", options: .regularExpression)
cleaned = cleaned
.replacingOccurrences(of: "_", with: " ")
.replacingOccurrences(of: "-", with: " ")
@@ -598,11 +598,11 @@ public final class GatewayDiscoveryModel {
private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? {
guard let raw else { return nil }
let prettified = Self.prettifyInstanceName(raw)
let strippedBridge = prettified.replacingOccurrences(
of: #"\s*-?\s*bridge$"#,
let strippedGateway = prettified.replacingOccurrences(
of: #"\s*-?\s*gateway$"#,
with: "",
options: .regularExpression)
return self.normalizeHostToken(strippedBridge)
return self.normalizeHostToken(strippedGateway)
}
}

View File

@@ -2,7 +2,7 @@ import ClawdbotKit
import Foundation
import Network
public enum BridgeEndpointID {
public enum GatewayEndpointID {
public static func stableID(_ endpoint: NWEndpoint) -> String {
switch endpoint {
case let .service(name, type, domain, _):

View File

@@ -9,7 +9,6 @@ struct WideAreaGatewayBeacon: Sendable, Equatable {
var lanHost: String?
var tailnetDns: String?
var gatewayPort: Int?
var bridgePort: Int?
var sshPort: Int?
var cliPath: String?
}
@@ -51,9 +50,9 @@ enum WideAreaGatewayDiscovery {
return []
}
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
guard let ptrLines = context.dig(
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
@@ -67,7 +66,7 @@ enum WideAreaGatewayDiscovery {
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if ptr.isEmpty { continue }
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
let suffix = "._clawdbot-bridge._tcp.\(domainTrimmed)"
let suffix = "._clawdbot-gateway._tcp.\(domainTrimmed)"
let rawInstanceName = ptrName.hasSuffix(suffix)
? String(ptrName.dropLast(suffix.count))
: ptrName
@@ -94,7 +93,6 @@ enum WideAreaGatewayDiscovery {
lanHost: txt["lanHost"],
tailnetDns: txt["tailnetDns"],
gatewayPort: parseInt(txt["gatewayPort"]),
bridgePort: parseInt(txt["bridgePort"]),
sshPort: parseInt(txt["sshPort"]),
cliPath: txt["cliPath"])
beacons.append(beacon)
@@ -156,9 +154,9 @@ enum WideAreaGatewayDiscovery {
remaining: () -> TimeInterval,
dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String?
{
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
let ips = candidates
candidates.removeAll(keepingCapacity: true)

View File

@@ -1,10 +0,0 @@
import Testing
@testable import Clawdbot
@Suite(.serialized)
struct BridgeServerTests {
@Test func bridgeServerExercisesPaths() async {
let server = BridgeServer()
await server.exerciseForTesting()
}
}

View File

@@ -28,13 +28,13 @@ struct ClawdbotConfigFileTests {
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
"url": "ws://bridge.ts.net:19999",
"url": "ws://gateway.ts.net:19999",
],
],
])
#expect(ClawdbotConfigFile.remoteGatewayPort() == 19999)
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge.ts.net") == 19999)
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge") == 19999)
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "gateway.ts.net") == 19999)
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "gateway") == 19999)
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil)
}
}

View File

@@ -48,7 +48,7 @@ struct GatewayDiscoveryModelTests {
lanHost: "other.local",
tailnetDns: "other.tailnet.example",
displayName: "Other Mac",
serviceName: "other-bridge",
serviceName: "other-gateway",
local: local))
}
@@ -60,7 +60,7 @@ struct GatewayDiscoveryModelTests {
lanHost: nil,
tailnetDns: nil,
displayName: nil,
serviceName: "studio-bridge",
serviceName: "studio-gateway",
local: local))
}

View File

@@ -1,215 +0,0 @@
import Darwin
import Foundation
import Network
import Testing
@testable import Clawdbot
@Suite struct MacNodeBridgeDiscoveryTests {
@MainActor
@Test func loopbackBridgePortDefaultsAndOverrides() {
withEnv("CLAWDBOT_BRIDGE_PORT", value: nil) {
#expect(MacNodeModeCoordinator.loopbackBridgePort() == 18790)
}
withEnv("CLAWDBOT_BRIDGE_PORT", value: "19991") {
#expect(MacNodeModeCoordinator.loopbackBridgePort() == 19991)
}
withEnv("CLAWDBOT_BRIDGE_PORT", value: "not-a-port") {
#expect(MacNodeModeCoordinator.loopbackBridgePort() == 18790)
}
}
@MainActor
@Test func probeEndpointSucceedsForOpenPort() async throws {
let listener = try NWListener(using: .tcp, on: .any)
listener.newConnectionHandler = { connection in
connection.cancel()
}
listener.start(queue: DispatchQueue(label: "com.clawdbot.tests.bridge-listener"))
try await waitForListenerReady(listener, timeoutSeconds: 1.0)
guard let port = listener.port else {
listener.cancel()
throw TestError(message: "listener port missing")
}
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.6)
listener.cancel()
#expect(ok == true)
}
@MainActor
@Test func probeEndpointFailsForClosedPort() async throws {
let port = try reserveEphemeralPort()
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.4)
#expect(ok == false)
}
@MainActor
@Test func remoteBridgePortUsesMatchingRemoteUrlPort() {
let configPath = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
let defaults = UserDefaults.standard
let prevTarget = defaults.string(forKey: remoteTargetKey)
defer {
if let prevTarget {
defaults.set(prevTarget, forKey: remoteTargetKey)
} else {
defaults.removeObject(forKey: remoteTargetKey)
}
}
withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) {
withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") {
defaults.set("user@bridge.ts.net", forKey: remoteTargetKey)
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
"url": "ws://bridge.ts.net:25000",
],
],
])
#expect(MacNodeModeCoordinator.remoteBridgePort() == 25001)
}
}
}
@MainActor
@Test func remoteBridgePortFallsBackWhenRemoteUrlHostMismatch() {
let configPath = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
.appendingPathComponent("clawdbot.json")
.path
let defaults = UserDefaults.standard
let prevTarget = defaults.string(forKey: remoteTargetKey)
defer {
if let prevTarget {
defaults.set(prevTarget, forKey: remoteTargetKey)
} else {
defaults.removeObject(forKey: remoteTargetKey)
}
}
withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) {
withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") {
defaults.set("user@other.ts.net", forKey: remoteTargetKey)
ClawdbotConfigFile.saveDict([
"gateway": [
"remote": [
"url": "ws://bridge.ts.net:25000",
],
],
])
#expect(MacNodeModeCoordinator.remoteBridgePort() == 20001)
}
}
}
}
private struct TestError: Error {
let message: String
}
private struct ListenerTimeoutError: Error {}
private func waitForListenerReady(_ listener: NWListener, timeoutSeconds: Double) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
group.addTask {
try await withCheckedThrowingContinuation { cont in
final class ListenerState: @unchecked Sendable {
let lock = NSLock()
var finished = false
}
let state = ListenerState()
let finish: @Sendable (Result<Void, Error>) -> Void = { result in
state.lock.lock()
defer { state.lock.unlock() }
guard !state.finished else { return }
state.finished = true
cont.resume(with: result)
}
listener.stateUpdateHandler = { state in
switch state {
case .ready:
finish(.success(()))
case let .failed(err):
finish(.failure(err))
case .cancelled:
finish(.failure(ListenerTimeoutError()))
default:
break
}
}
}
}
group.addTask {
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
throw ListenerTimeoutError()
}
_ = try await group.next()
group.cancelAll()
}
}
private func withEnv(_ key: String, value: String?, _ body: () -> Void) {
let existing = getenv(key).map { String(cString: $0) }
if let value {
setenv(key, value, 1)
} else {
unsetenv(key)
}
defer {
if let existing {
setenv(key, existing, 1)
} else {
unsetenv(key)
}
}
body()
}
private func reserveEphemeralPort() throws -> NWEndpoint.Port {
let fd = socket(AF_INET, SOCK_STREAM, 0)
if fd < 0 {
throw TestError(message: "socket failed")
}
defer { close(fd) }
var addr = sockaddr_in()
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
addr.sin_family = sa_family_t(AF_INET)
addr.sin_port = in_port_t(0)
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
let bindResult = withUnsafePointer(to: &addr) { pointer -> Int32 in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
}
}
if bindResult != 0 {
throw TestError(message: "bind failed")
}
var resolved = sockaddr_in()
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
let nameResult = withUnsafeMutablePointer(to: &resolved) { pointer -> Int32 in
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
getsockname(fd, $0, &length)
}
}
if nameResult != 0 {
throw TestError(message: "getsockname failed")
}
let port = UInt16(bigEndian: resolved.sin_port)
guard let endpointPort = NWEndpoint.Port(rawValue: port), endpointPort.rawValue != 0 else {
throw TestError(message: "ephemeral port missing")
}
return endpointPort
}

View File

@@ -1,19 +0,0 @@
import Foundation
import Testing
@testable import Clawdbot
@Suite
struct MacNodeBridgeSessionTests {
@Test func sendEventThrowsWhenNotConnected() async {
let session = MacNodeBridgeSession()
do {
try await session.sendEvent(event: "test", payloadJSON: "{}")
Issue.record("Expected sendEvent to throw when disconnected")
} catch {
let ns = error as NSError
#expect(ns.domain == "Bridge")
#expect(ns.code == 15)
}
}
}

View File

@@ -20,15 +20,15 @@ struct WideAreaGatewayDiscoveryTests {
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
if recordType == "PTR" {
if nameserver == "@100.123.224.76" {
return "steipetacstudio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n"
return "steipetacstudio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n"
}
return ""
}
if recordType == "SRV" {
return "0 0 18790 steipetacstudio.clawdbot.internal."
return "0 0 18789 steipetacstudio.clawdbot.internal."
}
if recordType == "TXT" {
return "\"displayName=Peter\\226\\128\\153s Mac Studio (Clawdbot)\" \"transport=bridge\" \"bridgePort=18790\" \"gatewayPort=18789\" \"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net\" \"cliPath=/Users/steipete/clawdbot/src/entry.ts\""
return "\"displayName=Peter\\226\\128\\153s Mac Studio (Clawdbot)\" \"gatewayPort=18789\" \"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net\" \"cliPath=/Users/steipete/clawdbot/src/entry.ts\""
}
return ""
})
@@ -41,7 +41,7 @@ struct WideAreaGatewayDiscoveryTests {
let beacon = beacons[0]
let expectedDisplay = "Peter\u{2019}s Mac Studio (Clawdbot)"
#expect(beacon.displayName == expectedDisplay)
#expect(beacon.bridgePort == 18790)
#expect(beacon.port == 18789)
#expect(beacon.gatewayPort == 18789)
#expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net")
#expect(beacon.cliPath == "/Users/steipete/clawdbot/src/entry.ts")