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

@@ -53,11 +53,11 @@ final class BridgeDiscoveryModel {
if !self.browsers.isEmpty { return }
self.appendDebugLog("start()")
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

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")

View File

@@ -2,24 +2,24 @@ import Foundation
public enum ClawdbotBonjour {
// v0: internal-only, subject to rename.
public static let bridgeServiceType = "_clawdbot-bridge._tcp"
public static let bridgeServiceDomain = "local."
public static let wideAreaBridgeServiceDomain = "clawdbot.internal."
public static let gatewayServiceType = "_clawdbot-gateway._tcp"
public static let gatewayServiceDomain = "local."
public static let wideAreaGatewayServiceDomain = "clawdbot.internal."
public static let bridgeServiceDomains = [
bridgeServiceDomain,
wideAreaBridgeServiceDomain,
public static let gatewayServiceDomains = [
gatewayServiceDomain,
wideAreaGatewayServiceDomain,
]
public static func normalizeServiceDomain(_ raw: String?) -> String {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty {
return self.bridgeServiceDomain
return self.gatewayServiceDomain
}
let lower = trimmed.lowercased()
if lower == "local" || lower == "local." {
return self.bridgeServiceDomain
return self.gatewayServiceDomain
}
return lower.hasSuffix(".") ? lower : (lower + ".")

View File

@@ -29,6 +29,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
public var needsScreenRecording: Bool?
public var agentId: String?
public var sessionKey: String?
public var approved: Bool?
public init(
command: [String],
@@ -38,7 +39,8 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
timeoutMs: Int? = nil,
needsScreenRecording: Bool? = nil,
agentId: String? = nil,
sessionKey: String? = nil)
sessionKey: String? = nil,
approved: Bool? = nil)
{
self.command = command
self.rawCommand = rawCommand
@@ -48,6 +50,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
self.needsScreenRecording = needsScreenRecording
self.agentId = agentId
self.sessionKey = sessionKey
self.approved = approved
}
}

View File

@@ -425,7 +425,11 @@ export function createExecTool(
applyPathPrepend(env, defaultPathPrepend);
if (host === "node") {
if (security === "deny") {
const approvals = resolveExecApprovals(defaults?.agentId);
const hostSecurity = minSecurity(security, approvals.agent.security);
const hostAsk = maxAsk(ask, approvals.agent.ask);
const askFallback = approvals.agent.askFallback;
if (hostSecurity === "deny") {
throw new Error("exec denied: host=node security=deny");
}
const boundNode = defaults?.node?.trim();
@@ -465,6 +469,79 @@ export function createExecTool(
if (nodeEnv) {
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
}
const resolution = resolveCommandResolution(params.command, workdir, env);
const allowlistMatch =
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
const requiresAsk =
hostAsk === "always" ||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
let approvedByAsk = false;
if (requiresAsk) {
const decisionResult = (await callGatewayTool("exec.approval.request", {}, {
command: params.command,
cwd: workdir,
host: "node",
security: hostSecurity,
ask: hostAsk,
agentId: defaults?.agentId,
resolvedPath: resolution?.resolvedPath ?? null,
sessionKey: defaults?.sessionKey ?? null,
timeoutMs: 120_000,
})) as { decision?: string } | null;
const decision =
decisionResult && typeof decisionResult === "object"
? decisionResult.decision ?? null
: null;
if (decision === "deny") {
throw new Error("exec denied: user denied");
}
if (!decision) {
if (askFallback === "full") {
approvedByAsk = true;
} else if (askFallback === "allowlist") {
if (!allowlistMatch) {
throw new Error(
"exec denied: approval required (approval UI not available)",
);
}
approvedByAsk = true;
} else {
throw new Error("exec denied: approval required (approval UI not available)");
}
}
if (decision === "allow-once") {
approvedByAsk = true;
}
if (decision === "allow-always") {
approvedByAsk = true;
if (hostSecurity === "allowlist") {
const pattern =
resolution?.resolvedPath ??
resolution?.rawExecutable ??
params.command.split(/\s+/).shift() ??
"";
if (pattern) {
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
}
}
}
}
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
throw new Error("exec denied: allowlist miss");
}
if (allowlistMatch) {
recordAllowlistUse(
approvals.file,
defaults?.agentId,
allowlistMatch,
params.command,
resolution?.resolvedPath,
);
}
const invokeParams: Record<string, unknown> = {
nodeId,
command: "system.run",
@@ -476,6 +553,7 @@ export function createExecTool(
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
agentId: defaults?.agentId,
sessionKey: defaults?.sessionKey,
approved: approvedByAsk,
},
idempotencyKey: crypto.randomUUID(),
};

View File

@@ -4,7 +4,7 @@ import {
resolveGatewayPort,
resolveStateDir,
} from "../../config/config.js";
import type { BridgeBindMode, GatewayControlUiConfig } from "../../config/types.js";
import type { GatewayBindMode, GatewayControlUiConfig } from "../../config/types.js";
import { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
import { findExtraGatewayServices } from "../../daemon/inspect.js";
@@ -33,7 +33,7 @@ type ConfigSummary = {
};
type GatewayStatusSummary = {
bindMode: BridgeBindMode;
bindMode: GatewayBindMode;
bindHost: string;
customBindHost?: string;
port: number;

View File

@@ -122,7 +122,7 @@ export function registerDnsCli(program: Command) {
console.log(
JSON.stringify(
{
bridge: { bind: "tailnet" },
gateway: { bind: "auto" },
discovery: { wideArea: { enabled: true } },
},
null,

View File

@@ -146,7 +146,6 @@ describe("gateway-cli coverage", () => {
lanHost: "studio.local",
tailnetDns: "studio.tailnet.ts.net",
gatewayPort: 18789,
bridgePort: 18790,
sshPort: 22,
},
]);
@@ -179,7 +178,6 @@ describe("gateway-cli coverage", () => {
lanHost: "studio.local",
tailnetDns: "studio.tailnet.ts.net",
gatewayPort: 18789,
bridgePort: 18790,
sshPort: 22,
},
]);

View File

@@ -46,7 +46,6 @@ export function dedupeBeacons(beacons: GatewayBonjourBeacon[]): GatewayBonjourBe
b.displayName ?? "",
host,
String(b.port ?? ""),
String(b.bridgePort ?? ""),
String(b.gatewayPort ?? ""),
].join("|");
if (seen.has(key)) continue;

View File

@@ -110,7 +110,7 @@ describe("gateway SIGTERM", () => {
CLAWDBOT_SKIP_CHANNELS: "1",
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
CLAWDBOT_SKIP_CANVAS_HOST: "1",
// Avoid port collisions with other test processes that may also start a bridge server.
// Avoid port collisions with other test processes that may also start a gateway server.
CLAWDBOT_BRIDGE_HOST: "127.0.0.1",
CLAWDBOT_BRIDGE_PORT: "0",
},

View File

@@ -90,7 +90,7 @@ function resolveNodeDefaults(
if (opts.port !== undefined && portOverride === null) {
return { host, port: null };
}
const port = portOverride ?? config?.gateway?.port ?? 18790;
const port = portOverride ?? config?.gateway?.port ?? 18789;
return { host, port };
}
@@ -179,7 +179,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
await buildNodeInstallPlan({
env: process.env,
host,
port: port ?? 18790,
port: port ?? 18789,
tls,
tlsFingerprint: tlsFingerprint || undefined,
nodeId: opts.nodeId,

View File

@@ -30,17 +30,17 @@ export function registerNodeCli(program: Command) {
node
.command("start")
.description("Start the headless node host (foreground)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--host <host>", "Gateway host")
.option("--port <port>", "Gateway port")
.option("--tls", "Use TLS for the gateway connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--node-id <id>", "Override node id")
.option("--display-name <name>", "Override node display name")
.action(async (opts) => {
const existing = await loadNodeHostConfig();
const host =
(opts.host as string | undefined)?.trim() || existing?.gateway?.host || "127.0.0.1";
const port = parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18790);
const port = parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18789);
await runNodeHost({
gatewayHost: host,
gatewayPort: port,
@@ -63,11 +63,11 @@ export function registerNodeCli(program: Command) {
cmd
.command("install")
.description("Install the node service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--host <host>", "Gateway host")
.option("--port <port>", "Gateway port")
.option("--tls", "Use TLS for the gateway connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--node-id <id>", "Override node id")
.option("--display-name <name>", "Override node display name")
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
.option("--force", "Reinstall/overwrite if already installed", false)

View File

@@ -34,7 +34,7 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
.version(ctx.programVersion)
.option(
"--dev",
"Dev profile: isolate state under ~/.clawdbot-dev, default gateway port 19001, and shift derived ports (bridge/browser/canvas)",
"Dev profile: isolate state under ~/.clawdbot-dev, default gateway port 19001, and shift derived ports (browser/canvas)",
)
.option(
"--profile <name>",

View File

@@ -107,9 +107,9 @@ export function registerServiceCli(program: Command) {
node
.command("install")
.description("Install the node host service (launchd/systemd/schtasks)")
.option("--host <host>", "Gateway bridge host")
.option("--port <port>", "Gateway bridge port")
.option("--tls", "Use TLS for the bridge connection", false)
.option("--host <host>", "Gateway host")
.option("--port <port>", "Gateway port")
.option("--tls", "Use TLS for the Gateway connection", false)
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
.option("--node-id <id>", "Override node id (clears pairing token)")
.option("--display-name <name>", "Override node display name")

View File

@@ -45,7 +45,6 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => {
valid: true,
config: {
gateway: { mode: "local" },
bridge: { enabled: true, port: 18790 },
},
issues: [],
legacyIssues: [],
@@ -73,7 +72,7 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => {
path: "/tmp/remote.json",
exists: true,
valid: true,
config: { gateway: { mode: "remote" }, bridge: { enabled: false } },
config: { gateway: { mode: "remote" } },
issues: [],
legacyIssues: [],
},

View File

@@ -222,7 +222,6 @@ export async function gatewayStatusCommand(
host: b.host ?? null,
lanHost: b.lanHost ?? null,
tailnetDns: b.tailnetDns ?? null,
bridgePort: b.bridgePort ?? null,
gatewayPort: b.gatewayPort ?? null,
sshPort: b.sshPort ?? null,
wsUrl: (() => {
@@ -309,17 +308,12 @@ export async function gatewayStatusCommand(
}
if (p.configSummary) {
const c = p.configSummary;
const bridge =
c.bridge.enabled === false ? "disabled" : c.bridge.enabled === true ? "enabled" : "unknown";
const wideArea =
c.discovery.wideAreaEnabled === true
? "enabled"
: c.discovery.wideAreaEnabled === false
? "disabled"
: "unknown";
runtime.log(
` ${colorize(rich, theme.info, "Bridge")}: ${bridge}${c.bridge.bind ? ` · bind ${c.bridge.bind}` : ""}${c.bridge.port ? ` · port ${c.bridge.port}` : ""}`,
);
runtime.log(` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`);
}
runtime.log("");

View File

@@ -40,11 +40,6 @@ export type GatewayConfigSummary = {
remotePasswordConfigured: boolean;
tailscaleMode: string | null;
};
bridge: {
enabled: boolean | null;
bind: string | null;
port: number | null;
};
discovery: {
wideAreaEnabled: boolean | null;
};
@@ -191,7 +186,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
const gateway = (cfg.gateway ?? {}) as Record<string, unknown>;
const bridge = (cfg.bridge ?? {}) as Record<string, unknown>;
const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
@@ -211,10 +205,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
const remotePasswordConfigured =
typeof remote.password === "string" ? String(remote.password).trim().length > 0 : false;
const bridgeEnabled = typeof bridge.enabled === "boolean" ? bridge.enabled : null;
const bridgeBind = typeof bridge.bind === "string" ? bridge.bind : null;
const bridgePort = parseIntOrNull(bridge.port);
const wideAreaEnabled = typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;
return {
@@ -245,7 +235,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
remotePasswordConfigured,
tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null,
},
bridge: { enabled: bridgeEnabled, bind: bridgeBind, port: bridgePort },
discovery: { wideAreaEnabled },
};
}

View File

@@ -218,17 +218,14 @@ describe("legacy config detection", () => {
expect(res.config?.gateway?.auth?.mode).toBe("token");
expect((res.config?.gateway as { token?: string })?.token).toBeUndefined();
});
it("migrates gateway.bind and bridge.bind from 'tailnet' to 'auto'", async () => {
it("migrates gateway.bind from 'tailnet' to 'auto'", async () => {
vi.resetModules();
const { migrateLegacyConfig } = await import("./config.js");
const res = migrateLegacyConfig({
gateway: { bind: "tailnet" as const },
bridge: { bind: "tailnet" as const },
});
expect(res.changes).toContain("Migrated gateway.bind from 'tailnet' to 'auto'.");
expect(res.changes).toContain("Migrated bridge.bind from 'tailnet' to 'auto'.");
expect(res.config?.gateway?.bind).toBe("auto");
expect(res.config?.bridge?.bind).toBe("auto");
});
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
vi.resetModules();

View File

@@ -145,7 +145,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
},
{
id: "bind-tailnet->auto",
describe: "Remap gateway/bridge bind 'tailnet' to 'auto'",
describe: "Remap gateway bind 'tailnet' to 'auto'",
apply: (raw, changes) => {
const migrateBind = (obj: Record<string, unknown> | null | undefined, key: string) => {
if (!obj) return;
@@ -158,9 +158,6 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
const gateway = getRecord(raw.gateway);
migrateBind(gateway, "gateway");
const bridge = getRecord(raw.bridge);
migrateBind(bridge, "bridge");
},
},
];

View File

@@ -4,13 +4,7 @@ import type { LoggingConfig, SessionConfig, WebConfig } from "./types.base.js";
import type { BrowserConfig } from "./types.browser.js";
import type { ChannelsConfig } from "./types.channels.js";
import type { CronConfig } from "./types.cron.js";
import type {
BridgeConfig,
CanvasHostConfig,
DiscoveryConfig,
GatewayConfig,
TalkConfig,
} from "./types.gateway.js";
import type { CanvasHostConfig, DiscoveryConfig, GatewayConfig, TalkConfig } from "./types.gateway.js";
import type { HooksConfig } from "./types.hooks.js";
import type {
AudioConfig,
@@ -81,7 +75,6 @@ export type ClawdbotConfig = {
channels?: ChannelsConfig;
cron?: CronConfig;
hooks?: HooksConfig;
bridge?: BridgeConfig;
discovery?: DiscoveryConfig;
canvasHost?: CanvasHostConfig;
talk?: TalkConfig;

View File

@@ -1,27 +1,13 @@
export type BridgeBindMode = "auto" | "lan" | "loopback" | "custom";
export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom";
export type BridgeConfig = {
enabled?: boolean;
port?: number;
/**
* Bind address policy for the node bridge server.
* - auto: Tailnet IPv4 if available, else 0.0.0.0 (fallback to all interfaces)
* - lan: 0.0.0.0 (all interfaces, no fallback)
* - loopback: 127.0.0.1 (local-only)
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost on gateway)
*/
bind?: BridgeBindMode;
tls?: BridgeTlsConfig;
};
export type BridgeTlsConfig = {
/** Enable TLS for the node bridge server. */
export type GatewayTlsConfig = {
/** Enable TLS for the gateway server. */
enabled?: boolean;
/** Auto-generate a self-signed cert if cert/key are missing (default: true). */
autoGenerate?: boolean;
/** PEM certificate path for the bridge server. */
/** PEM certificate path for the gateway server. */
certPath?: string;
/** PEM private key path for the bridge server. */
/** PEM private key path for the gateway server. */
keyPath?: string;
/** Optional PEM CA bundle for TLS clients (mTLS or custom roots). */
caPath?: string;
@@ -127,7 +113,6 @@ export type GatewayHttpConfig = {
endpoints?: GatewayHttpEndpointsConfig;
};
export type GatewayTlsConfig = BridgeTlsConfig;
export type GatewayConfig = {
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
@@ -145,7 +130,7 @@ export type GatewayConfig = {
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost)
* Default: loopback (127.0.0.1).
*/
bind?: BridgeBindMode;
bind?: GatewayBindMode;
/** Custom IP address for bind="custom" mode. Fallback: 0.0.0.0. */
customBindHost?: string;
controlUi?: GatewayControlUiConfig;

View File

@@ -195,26 +195,6 @@ export const ClawdbotSchema = z
.strict()
.optional(),
channels: ChannelsSchema,
bridge: z
.object({
enabled: z.boolean().optional(),
port: z.number().int().positive().optional(),
bind: z
.union([z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback")])
.optional(),
tls: z
.object({
enabled: z.boolean().optional(),
autoGenerate: z.boolean().optional(),
certPath: z.string().optional(),
keyPath: z.string().optional(),
caPath: z.string().optional(),
})
.strict()
.optional(),
})
.strict()
.optional(),
discovery: z
.object({
wideArea: z
@@ -251,7 +231,12 @@ export const ClawdbotSchema = z
port: z.number().int().positive().optional(),
mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
bind: z
.union([z.literal("auto"), z.literal("lan"), z.literal("tailnet"), z.literal("loopback")])
.union([
z.literal("auto"),
z.literal("lan"),
z.literal("loopback"),
z.literal("custom"),
])
.optional(),
controlUi: z
.object({

View File

@@ -41,7 +41,7 @@ export type ChatAbortOps = {
) => { sessionKey: string; clientRunId: string } | undefined;
agentRunSeq: Map<string, number>;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
};
function broadcastChatAborted(
@@ -61,7 +61,7 @@ function broadcastChatAborted(
stopReason,
};
ops.broadcast("chat", payload);
ops.bridgeSendToSession(sessionKey, "chat", payload);
ops.nodeSendToSession(sessionKey, "chat", payload);
}
export function abortChatRunById(

View File

@@ -40,9 +40,13 @@ export type GatewayClientOptions = {
mode?: GatewayClientMode;
role?: string;
scopes?: string[];
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
deviceIdentity?: DeviceIdentity;
minProtocol?: number;
maxProtocol?: number;
tlsFingerprint?: string;
onEvent?: (evt: EventFrame) => void;
onHelloOk?: (hello: HelloOk) => void;
onConnectError?: (err: Error) => void;
@@ -81,7 +85,21 @@ export class GatewayClient {
if (this.closed) return;
const url = this.opts.url ?? "ws://127.0.0.1:18789";
// Allow node screen snapshots and other large responses.
this.ws = new WebSocket(url, { maxPayload: 25 * 1024 * 1024 });
const wsOptions: ConstructorParameters<typeof WebSocket>[1] = {
maxPayload: 25 * 1024 * 1024,
};
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
wsOptions.rejectUnauthorized = false;
wsOptions.checkServerIdentity = (_host, cert) => {
const fingerprint = normalizeFingerprint(
typeof cert?.fingerprint256 === "string" ? cert.fingerprint256 : "",
);
const expected = normalizeFingerprint(this.opts.tlsFingerprint ?? "");
if (fingerprint && fingerprint === expected) return undefined;
return new Error("gateway tls fingerprint mismatch");
};
}
this.ws = new WebSocket(url, wsOptions);
this.ws.on("open", () => this.sendConnect());
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
@@ -149,7 +167,12 @@ export class GatewayClient {
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
instanceId: this.opts.instanceId,
},
caps: [],
caps: Array.isArray(this.opts.caps) ? this.opts.caps : [],
commands: Array.isArray(this.opts.commands) ? this.opts.commands : undefined,
permissions:
this.opts.permissions && typeof this.opts.permissions === "object"
? this.opts.permissions
: undefined,
auth,
role,
scopes,
@@ -270,3 +293,7 @@ export class GatewayClient {
return p;
}
}
function normalizeFingerprint(input: string): string {
return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
}

View File

@@ -80,7 +80,6 @@ const BASE_RELOAD_RULES_TAIL: ReloadRule[] = [
{ prefix: "plugins", kind: "restart" },
{ prefix: "ui", kind: "none" },
{ prefix: "gateway", kind: "restart" },
{ prefix: "bridge", kind: "restart" },
{ prefix: "discovery", kind: "restart" },
{ prefix: "canvasHost", kind: "restart" },
];

View File

@@ -191,7 +191,7 @@ async function isPortFree(port: number): Promise<boolean> {
}
async function getFreeGatewayPort(): Promise<number> {
// Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by
// Gateway uses derived ports (browser/canvas). Avoid flaky collisions by
// ensuring the common derived offsets are free too.
for (let attempt = 0; attempt < 25; attempt += 1) {
const port = await getFreePort();

View File

@@ -23,7 +23,7 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
* @returns The bind address to use (never null)
*/
export async function resolveGatewayBindHost(
bind: import("../config/config.js").BridgeBindMode | undefined,
bind: import("../config/config.js").GatewayBindMode | undefined,
customHost?: string,
): Promise<string> {
const mode = bind ?? "loopback";

View File

@@ -0,0 +1,193 @@
import { randomUUID } from "node:crypto";
import type { GatewayWsClient } from "./server/ws-types.js";
export type NodeSession = {
nodeId: string;
connId: string;
client: GatewayWsClient;
displayName?: string;
platform?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
caps: string[];
commands: string[];
permissions?: Record<string, boolean>;
connectedAtMs: number;
};
type PendingInvoke = {
nodeId: string;
command: string;
resolve: (value: NodeInvokeResult) => void;
reject: (err: Error) => void;
timer: ReturnType<typeof setTimeout>;
};
export type NodeInvokeResult = {
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
};
export class NodeRegistry {
private nodesById = new Map<string, NodeSession>();
private nodesByConn = new Map<string, string>();
private pendingInvokes = new Map<string, PendingInvoke>();
register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) {
const connect = client.connect;
const nodeId = connect.device?.id ?? connect.client.id;
const caps = Array.isArray(connect.caps) ? connect.caps : [];
const commands = Array.isArray((connect as { commands?: string[] }).commands)
? (connect as { commands?: string[] }).commands ?? []
: [];
const permissions =
typeof (connect as { permissions?: Record<string, boolean> }).permissions === "object"
? ((connect as { permissions?: Record<string, boolean> }).permissions ?? undefined)
: undefined;
const session: NodeSession = {
nodeId,
connId: client.connId,
client,
displayName: connect.client.displayName,
platform: connect.client.platform,
version: connect.client.version,
coreVersion: (connect as { coreVersion?: string }).coreVersion,
uiVersion: (connect as { uiVersion?: string }).uiVersion,
deviceFamily: connect.client.deviceFamily,
modelIdentifier: connect.client.modelIdentifier,
remoteIp: opts.remoteIp,
caps,
commands,
permissions,
connectedAtMs: Date.now(),
};
this.nodesById.set(nodeId, session);
this.nodesByConn.set(client.connId, nodeId);
return session;
}
unregister(connId: string): string | null {
const nodeId = this.nodesByConn.get(connId);
if (!nodeId) return null;
this.nodesByConn.delete(connId);
this.nodesById.delete(nodeId);
for (const [id, pending] of this.pendingInvokes.entries()) {
if (pending.nodeId !== nodeId) continue;
clearTimeout(pending.timer);
pending.reject(new Error(`node disconnected (${pending.command})`));
this.pendingInvokes.delete(id);
}
return nodeId;
}
listConnected(): NodeSession[] {
return [...this.nodesById.values()];
}
get(nodeId: string): NodeSession | undefined {
return this.nodesById.get(nodeId);
}
async invoke(params: {
nodeId: string;
command: string;
params?: unknown;
timeoutMs?: number;
idempotencyKey?: string;
}): Promise<NodeInvokeResult> {
const node = this.nodesById.get(params.nodeId);
if (!node) {
return {
ok: false,
error: { code: "NOT_CONNECTED", message: "node not connected" },
};
}
const requestId = randomUUID();
const payload = {
id: requestId,
nodeId: params.nodeId,
command: params.command,
paramsJSON:
"params" in params && params.params !== undefined ? JSON.stringify(params.params) : null,
timeoutMs: params.timeoutMs,
idempotencyKey: params.idempotencyKey,
};
const ok = this.sendEvent(node, "node.invoke.request", payload);
if (!ok) {
return {
ok: false,
error: { code: "UNAVAILABLE", message: "failed to send invoke to node" },
};
}
const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 30_000;
return await new Promise<NodeInvokeResult>((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingInvokes.delete(requestId);
resolve({
ok: false,
error: { code: "TIMEOUT", message: "node invoke timed out" },
});
}, timeoutMs);
this.pendingInvokes.set(requestId, {
nodeId: params.nodeId,
command: params.command,
resolve,
reject,
timer,
});
});
}
handleInvokeResult(params: {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
}): boolean {
const pending = this.pendingInvokes.get(params.id);
if (!pending) return false;
clearTimeout(pending.timer);
this.pendingInvokes.delete(params.id);
pending.resolve({
ok: params.ok,
payload: params.payload,
payloadJSON: params.payloadJSON ?? null,
error: params.error ?? null,
});
return true;
}
sendEvent(nodeId: string, event: string, payload?: unknown): boolean {
const node = this.nodesById.get(nodeId);
if (!node) return false;
return this.sendEventToSession(node, event, payload);
}
private sendEvent(node: NodeSession, event: string, payload: unknown): boolean {
try {
node.client.socket.send(
JSON.stringify({
type: "event",
event,
payload,
}),
);
return true;
} catch {
return false;
}
}
private sendEventToSession(node: NodeSession, event: string, payload: unknown): boolean {
return this.sendEvent(node, event, payload);
}
}

View File

@@ -5,6 +5,7 @@ export const GATEWAY_CLIENT_IDS = {
CLI: "cli",
GATEWAY_CLIENT: "gateway-client",
MACOS_APP: "clawdbot-macos",
NODE_HOST: "node-host",
TEST: "test",
FINGERPRINT: "fingerprint",
PROBE: "clawdbot-probe",
@@ -21,6 +22,7 @@ export const GATEWAY_CLIENT_MODES = {
CLI: "cli",
UI: "ui",
BACKEND: "backend",
NODE: "node",
PROBE: "probe",
TEST: "test",
} as const;

View File

@@ -93,8 +93,12 @@ import {
ModelsListParamsSchema,
type NodeDescribeParams,
NodeDescribeParamsSchema,
type NodeEventParams,
NodeEventParamsSchema,
type NodeInvokeParams,
NodeInvokeParamsSchema,
type NodeInvokeResultParams,
NodeInvokeResultParamsSchema,
type NodeListParams,
NodeListParamsSchema,
type NodePairApproveParams,
@@ -207,6 +211,10 @@ export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(NodeRename
export const validateNodeListParams = ajv.compile<NodeListParams>(NodeListParamsSchema);
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(NodeDescribeParamsSchema);
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(NodeInvokeParamsSchema);
export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams>(
NodeInvokeResultParamsSchema,
);
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
SessionsResolveParamsSchema,
@@ -422,6 +430,8 @@ export type {
NodePairVerifyParams,
NodeListParams,
NodeInvokeParams,
NodeInvokeResultParams,
NodeEventParams,
SessionsListParams,
SessionsResolveParams,
SessionsPatchParams,

View File

@@ -35,6 +35,8 @@ export const ConnectParamsSchema = Type.Object(
{ additionalProperties: false },
),
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
commands: Type.Optional(Type.Array(NonEmptyString)),
permissions: Type.Optional(Type.Record(NonEmptyString, Type.Boolean())),
role: Type.Optional(NonEmptyString),
scopes: Type.Optional(Type.Array(NonEmptyString)),
device: Type.Optional(

View File

@@ -59,3 +59,44 @@ export const NodeInvokeParamsSchema = Type.Object(
},
{ additionalProperties: false },
);
export const NodeInvokeResultParamsSchema = Type.Object(
{
id: NonEmptyString,
nodeId: NonEmptyString,
ok: Type.Boolean(),
payload: Type.Optional(Type.Unknown()),
payloadJSON: Type.Optional(Type.String()),
error: Type.Optional(
Type.Object(
{
code: Type.Optional(NonEmptyString),
message: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
),
),
},
{ additionalProperties: false },
);
export const NodeEventParamsSchema = Type.Object(
{
event: NonEmptyString,
payload: Type.Optional(Type.Unknown()),
payloadJSON: Type.Optional(Type.String()),
},
{ additionalProperties: false },
);
export const NodeInvokeRequestEventSchema = Type.Object(
{
id: NonEmptyString,
nodeId: NonEmptyString,
command: NonEmptyString,
paramsJSON: Type.Optional(Type.String()),
timeoutMs: Type.Optional(Type.Integer({ minimum: 0 })),
idempotencyKey: Type.Optional(NonEmptyString),
},
{ additionalProperties: false },
);

View File

@@ -85,7 +85,10 @@ import {
} from "./logs-chat.js";
import {
NodeDescribeParamsSchema,
NodeEventParamsSchema,
NodeInvokeParamsSchema,
NodeInvokeResultParamsSchema,
NodeInvokeRequestEventSchema,
NodeListParamsSchema,
NodePairApproveParamsSchema,
NodePairListParamsSchema,
@@ -140,6 +143,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
NodeListParams: NodeListParamsSchema,
NodeDescribeParams: NodeDescribeParamsSchema,
NodeInvokeParams: NodeInvokeParamsSchema,
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
NodeEventParams: NodeEventParamsSchema,
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
SessionsListParams: SessionsListParamsSchema,
SessionsResolveParams: SessionsResolveParamsSchema,
SessionsPatchParams: SessionsPatchParamsSchema,

View File

@@ -79,7 +79,9 @@ import type {
} from "./logs-chat.js";
import type {
NodeDescribeParamsSchema,
NodeEventParamsSchema,
NodeInvokeParamsSchema,
NodeInvokeResultParamsSchema,
NodeListParamsSchema,
NodePairApproveParamsSchema,
NodePairListParamsSchema,
@@ -131,6 +133,8 @@ export type NodeRenameParams = Static<typeof NodeRenameParamsSchema>;
export type NodeListParams = Static<typeof NodeListParamsSchema>;
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;

View File

@@ -1,457 +0,0 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { resolveThinkingDefault } from "../agents/model-selection.js";
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
import { agentCommand } from "../commands/agent.js";
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
import { registerAgentRunContext } from "../infra/agent-events.js";
import { isAcpSessionKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import {
abortChatRunById,
abortChatRunsForSessionKey,
isChatStopCommandText,
resolveChatRunExpiresAtMs,
} from "./chat-abort.js";
import { type ChatImageContent, parseMessageWithAttachments } from "./chat-attachments.js";
import {
ErrorCodes,
errorShape,
formatValidationErrors,
validateChatAbortParams,
validateChatInjectParams,
validateChatHistoryParams,
validateChatSendParams,
} from "./protocol/index.js";
import type { BridgeMethodHandler } from "./server-bridge-types.js";
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "./server-constants.js";
import {
capArrayByJsonBytes,
loadSessionEntry,
readSessionMessages,
resolveSessionModelRef,
} from "./session-utils.js";
export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, method, params) => {
switch (method) {
case "chat.inject": {
if (!validateChatInjectParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid chat.inject params: ${formatValidationErrors(validateChatInjectParams.errors)}`,
},
};
}
const p = params as {
sessionKey: string;
message: string;
label?: string;
};
const { storePath, entry } = loadSessionEntry(p.sessionKey);
const sessionId = entry?.sessionId;
if (!sessionId || !storePath) {
return {
ok: false,
error: { code: ErrorCodes.INVALID_REQUEST, message: "session not found" },
};
}
const transcriptPath = entry?.sessionFile
? entry.sessionFile
: path.join(path.dirname(storePath), `${sessionId}.jsonl`);
if (!fs.existsSync(transcriptPath)) {
return {
ok: false,
error: { code: ErrorCodes.INVALID_REQUEST, message: "transcript file not found" },
};
}
const now = Date.now();
const messageId = randomUUID().slice(0, 8);
const labelPrefix = p.label ? `[${p.label}]\n\n` : "";
const messageBody: Record<string, unknown> = {
role: "assistant",
content: [{ type: "text", text: `${labelPrefix}${p.message}` }],
timestamp: now,
stopReason: "injected",
usage: { input: 0, output: 0, totalTokens: 0 },
};
const transcriptEntry = {
type: "message",
id: messageId,
timestamp: new Date(now).toISOString(),
message: messageBody,
};
try {
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
} catch (err) {
const errMessage = err instanceof Error ? err.message : String(err);
return {
ok: false,
error: {
code: ErrorCodes.UNAVAILABLE,
message: `failed to write transcript: ${errMessage}`,
},
};
}
const chatPayload = {
runId: `inject-${messageId}`,
sessionKey: p.sessionKey,
seq: 0,
state: "final" as const,
message: transcriptEntry.message,
};
ctx.broadcast("chat", chatPayload);
ctx.bridgeSendToSession(p.sessionKey, "chat", chatPayload);
return { ok: true, payloadJSON: JSON.stringify({ ok: true, messageId }) };
}
case "chat.history": {
if (!validateChatHistoryParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`,
},
};
}
const { sessionKey, limit } = params as {
sessionKey: string;
limit?: number;
};
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
const sessionId = entry?.sessionId;
const rawMessages =
sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : [];
const max = typeof limit === "number" ? limit : 200;
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
const capped = capArrayByJsonBytes(sliced, MAX_CHAT_HISTORY_MESSAGES_BYTES).items;
let thinkingLevel = entry?.thinkingLevel;
if (!thinkingLevel) {
const configured = cfg.agents?.defaults?.thinkingDefault;
if (configured) {
thinkingLevel = configured;
} else {
const { provider, model } = resolveSessionModelRef(cfg, entry);
const catalog = await ctx.loadGatewayModelCatalog();
thinkingLevel = resolveThinkingDefault({
cfg,
provider,
model,
catalog,
});
}
}
return {
ok: true,
payloadJSON: JSON.stringify({
sessionKey,
sessionId,
messages: capped,
thinkingLevel,
}),
};
}
case "chat.abort": {
if (!validateChatAbortParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`,
},
};
}
const { sessionKey, runId } = params as {
sessionKey: string;
runId?: string;
};
const ops = {
chatAbortControllers: ctx.chatAbortControllers,
chatRunBuffers: ctx.chatRunBuffers,
chatDeltaSentAt: ctx.chatDeltaSentAt,
chatAbortedRuns: ctx.chatAbortedRuns,
removeChatRun: ctx.removeChatRun,
agentRunSeq: ctx.agentRunSeq,
broadcast: ctx.broadcast,
bridgeSendToSession: ctx.bridgeSendToSession,
};
if (!runId) {
const res = abortChatRunsForSessionKey(ops, {
sessionKey,
stopReason: "rpc",
});
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
aborted: res.aborted,
runIds: res.runIds,
}),
};
}
const active = ctx.chatAbortControllers.get(runId);
if (!active) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
aborted: false,
runIds: [],
}),
};
}
if (active.sessionKey !== sessionKey) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "runId does not match sessionKey",
},
};
}
const res = abortChatRunById(ops, {
runId,
sessionKey,
stopReason: "rpc",
});
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
aborted: res.aborted,
runIds: res.aborted ? [runId] : [],
}),
};
}
case "chat.send": {
if (!validateChatSendParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`,
},
};
}
const p = params as {
sessionKey: string;
message: string;
thinking?: string;
deliver?: boolean;
attachments?: Array<{
type?: string;
mimeType?: string;
fileName?: string;
content?: unknown;
}>;
timeoutMs?: number;
idempotencyKey: string;
};
const stopCommand = isChatStopCommandText(p.message);
const normalizedAttachments =
p.attachments
?.map((a) => ({
type: typeof a?.type === "string" ? a.type : undefined,
mimeType: typeof a?.mimeType === "string" ? a.mimeType : undefined,
fileName: typeof a?.fileName === "string" ? a.fileName : undefined,
content:
typeof a?.content === "string"
? a.content
: ArrayBuffer.isView(a?.content)
? Buffer.from(
a.content.buffer,
a.content.byteOffset,
a.content.byteLength,
).toString("base64")
: undefined,
}))
.filter((a) => a.content) ?? [];
let parsedMessage = p.message;
let parsedImages: ChatImageContent[] = [];
if (normalizedAttachments.length > 0) {
try {
const parsed = await parseMessageWithAttachments(p.message, normalizedAttachments, {
maxBytes: 5_000_000,
log: ctx.logBridge,
});
parsedMessage = parsed.message;
parsedImages = parsed.images;
} catch (err) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: String(err),
},
};
}
}
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
const timeoutMs = resolveAgentTimeoutMs({
cfg,
overrideMs: p.timeoutMs,
});
const now = Date.now();
const sessionId = entry?.sessionId ?? randomUUID();
const sessionEntry = mergeSessionEntry(entry, {
sessionId,
updatedAt: now,
});
const clientRunId = p.idempotencyKey;
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
if (stopCommand) {
const res = abortChatRunsForSessionKey(
{
chatAbortControllers: ctx.chatAbortControllers,
chatRunBuffers: ctx.chatRunBuffers,
chatDeltaSentAt: ctx.chatDeltaSentAt,
chatAbortedRuns: ctx.chatAbortedRuns,
removeChatRun: ctx.removeChatRun,
agentRunSeq: ctx.agentRunSeq,
broadcast: ctx.broadcast,
bridgeSendToSession: ctx.bridgeSendToSession,
},
{ sessionKey: p.sessionKey, stopReason: "stop" },
);
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
aborted: res.aborted,
runIds: res.runIds,
}),
};
}
const cached = ctx.dedupe.get(`chat:${clientRunId}`);
if (cached) {
if (cached.ok) {
return { ok: true, payloadJSON: JSON.stringify(cached.payload) };
}
return {
ok: false,
error: cached.error ?? {
code: ErrorCodes.UNAVAILABLE,
message: "request failed",
},
};
}
const activeExisting = ctx.chatAbortControllers.get(clientRunId);
if (activeExisting) {
return {
ok: true,
payloadJSON: JSON.stringify({
runId: clientRunId,
status: "in_flight",
}),
};
}
try {
const abortController = new AbortController();
ctx.chatAbortControllers.set(clientRunId, {
controller: abortController,
sessionId,
sessionKey: p.sessionKey,
startedAtMs: now,
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
});
ctx.addChatRun(clientRunId, {
sessionKey: p.sessionKey,
clientRunId,
});
if (storePath) {
await updateSessionStore(storePath, (store) => {
store[canonicalKey] = sessionEntry;
});
}
const ackPayload = {
runId: clientRunId,
status: "started" as const,
};
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
void agentCommand(
{
message: parsedMessage,
images: parsedImages.length > 0 ? parsedImages : undefined,
sessionId,
sessionKey: p.sessionKey,
runId: clientRunId,
thinking: p.thinking,
deliver: p.deliver,
timeout: Math.ceil(timeoutMs / 1000).toString(),
messageChannel: `node(${nodeId})`,
abortSignal: abortController.signal,
lane,
},
defaultRuntime,
ctx.deps,
)
.then(() => {
ctx.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: true,
payload: { runId: clientRunId, status: "ok" as const },
});
})
.catch((err) => {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
ctx.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: false,
payload: {
runId: clientRunId,
status: "error" as const,
summary: String(err),
},
error,
});
})
.finally(() => {
ctx.chatAbortControllers.delete(clientRunId);
});
return { ok: true, payloadJSON: JSON.stringify(ackPayload) };
} catch (err) {
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
const payload = {
runId: clientRunId,
status: "error" as const,
summary: String(err),
};
ctx.dedupe.set(`chat:${clientRunId}`, {
ts: Date.now(),
ok: false,
payload,
error,
});
return {
ok: false,
error: error ?? {
code: ErrorCodes.UNAVAILABLE,
message: String(err),
},
};
}
}
default:
return null;
}
};

View File

@@ -1,270 +0,0 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
CONFIG_PATH_CLAWDBOT,
loadConfig,
parseConfigJson5,
readConfigFileSnapshot,
resolveConfigSnapshotHash,
validateConfigObject,
writeConfigFile,
} from "../config/config.js";
import { applyLegacyMigrations } from "../config/legacy.js";
import { applyMergePatch } from "../config/merge-patch.js";
import { buildConfigSchema } from "../config/schema.js";
import { listChannelPlugins } from "../channels/plugins/index.js";
import { loadClawdbotPlugins } from "../plugins/loader.js";
import {
ErrorCodes,
formatValidationErrors,
validateConfigGetParams,
validateConfigPatchParams,
validateConfigSchemaParams,
validateConfigSetParams,
} from "./protocol/index.js";
import type { BridgeMethodHandler } from "./server-bridge-types.js";
function resolveBaseHash(params: unknown): string | null {
const raw = (params as { baseHash?: unknown })?.baseHash;
if (typeof raw !== "string") return null;
const trimmed = raw.trim();
return trimmed ? trimmed : null;
}
function requireConfigBaseHash(
params: unknown,
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
): { ok: true } | { ok: false; error: { code: string; message: string } } {
if (!snapshot.exists) return { ok: true };
const snapshotHash = resolveConfigSnapshotHash(snapshot);
if (!snapshotHash) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "config base hash unavailable; re-run config.get and retry",
},
};
}
const baseHash = resolveBaseHash(params);
if (!baseHash) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "config base hash required; re-run config.get and retry",
},
};
}
if (baseHash !== snapshotHash) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "config changed since last load; re-run config.get and retry",
},
};
}
return { ok: true };
}
export const handleConfigBridgeMethods: BridgeMethodHandler = async (
_ctx,
_nodeId,
method,
params,
) => {
switch (method) {
case "config.get": {
if (!validateConfigGetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`,
},
};
}
const snapshot = await readConfigFileSnapshot();
return { ok: true, payloadJSON: JSON.stringify(snapshot) };
}
case "config.schema": {
if (!validateConfigSchemaParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`,
},
};
}
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const pluginRegistry = loadClawdbotPlugins({
config: cfg,
workspaceDir,
logger: {
info: () => {},
warn: () => {},
error: () => {},
debug: () => {},
},
});
const schema = buildConfigSchema({
plugins: pluginRegistry.plugins.map((plugin) => ({
id: plugin.id,
name: plugin.name,
description: plugin.description,
configUiHints: plugin.configUiHints,
configSchema: plugin.configJsonSchema,
})),
channels: listChannelPlugins().map((entry) => ({
id: entry.id,
label: entry.meta.label,
description: entry.meta.blurb,
configSchema: entry.configSchema?.schema,
configUiHints: entry.configSchema?.uiHints,
})),
});
return { ok: true, payloadJSON: JSON.stringify(schema) };
}
case "config.set": {
if (!validateConfigSetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`,
},
};
}
const snapshot = await readConfigFileSnapshot();
const guard = requireConfigBaseHash(params, snapshot);
if (!guard.ok) {
return { ok: false, error: guard.error };
}
const rawValue = (params as { raw?: unknown }).raw;
if (typeof rawValue !== "string") {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config.set params: raw (string) required",
},
};
}
const parsedRes = parseConfigJson5(rawValue);
if (!parsedRes.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: parsedRes.error,
},
};
}
const validated = validateConfigObject(parsedRes.parsed);
if (!validated.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config",
details: { issues: validated.issues },
},
};
}
await writeConfigFile(validated.config);
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
path: CONFIG_PATH_CLAWDBOT,
config: validated.config,
}),
};
}
case "config.patch": {
if (!validateConfigPatchParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid config.patch params: ${formatValidationErrors(validateConfigPatchParams.errors)}`,
},
};
}
const snapshot = await readConfigFileSnapshot();
const guard = requireConfigBaseHash(params, snapshot);
if (!guard.ok) {
return { ok: false, error: guard.error };
}
if (!snapshot.valid) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config; fix before patching",
},
};
}
const rawValue = (params as { raw?: unknown }).raw;
if (typeof rawValue !== "string") {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config.patch params: raw (string) required",
},
};
}
const parsedRes = parseConfigJson5(rawValue);
if (!parsedRes.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: parsedRes.error,
},
};
}
if (
!parsedRes.parsed ||
typeof parsedRes.parsed !== "object" ||
Array.isArray(parsedRes.parsed)
) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "config.patch raw must be an object",
},
};
}
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
const migrated = applyLegacyMigrations(merged);
const resolved = migrated.next ?? merged;
const validated = validateConfigObject(resolved);
if (!validated.ok) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "invalid config",
details: { issues: validated.issues },
},
};
}
await writeConfigFile(validated.config);
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
path: CONFIG_PATH_CLAWDBOT,
config: validated.config,
}),
};
}
default:
return null;
}
};

View File

@@ -1,437 +0,0 @@
import { randomUUID } from "node:crypto";
import fs from "node:fs";
import {
abortEmbeddedPiRun,
isEmbeddedPiRunActive,
resolveEmbeddedSessionLane,
waitForEmbeddedPiRunEnd,
} from "../agents/pi-embedded.js";
import { loadConfig } from "../config/config.js";
import {
resolveMainSessionKeyFromConfig,
snapshotSessionOrigin,
type SessionEntry,
updateSessionStore,
} from "../config/sessions.js";
import { clearCommandLane } from "../process/command-queue.js";
import {
ErrorCodes,
formatValidationErrors,
type SessionsCompactParams,
type SessionsDeleteParams,
type SessionsListParams,
type SessionsPatchParams,
type SessionsResetParams,
type SessionsResolveParams,
validateSessionsCompactParams,
validateSessionsDeleteParams,
validateSessionsListParams,
validateSessionsPatchParams,
validateSessionsResetParams,
validateSessionsResolveParams,
} from "./protocol/index.js";
import type { BridgeMethodHandler } from "./server-bridge-types.js";
import {
archiveFileOnDisk,
listSessionsFromStore,
loadCombinedSessionStoreForGateway,
loadSessionEntry,
resolveGatewaySessionStoreTarget,
resolveSessionTranscriptCandidates,
type SessionsPatchResult,
} from "./session-utils.js";
import { applySessionsPatchToStore } from "./sessions-patch.js";
import { resolveSessionKeyFromResolveParams } from "./sessions-resolve.js";
export const handleSessionsBridgeMethods: BridgeMethodHandler = async (
ctx,
_nodeId,
method,
params,
) => {
switch (method) {
case "sessions.list": {
if (!validateSessionsListParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.list params: ${formatValidationErrors(validateSessionsListParams.errors)}`,
},
};
}
const p = params as SessionsListParams;
const cfg = loadConfig();
const { storePath, store } = loadCombinedSessionStoreForGateway(cfg);
const result = listSessionsFromStore({
cfg,
storePath,
store,
opts: p,
});
return { ok: true, payloadJSON: JSON.stringify(result) };
}
case "sessions.resolve": {
if (!validateSessionsResolveParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.resolve params: ${formatValidationErrors(validateSessionsResolveParams.errors)}`,
},
};
}
const p = params as SessionsResolveParams;
const cfg = loadConfig();
const resolved = resolveSessionKeyFromResolveParams({ cfg, p });
if (!resolved.ok) {
return {
ok: false,
error: {
code: resolved.error.code,
message: resolved.error.message,
details: resolved.error.details,
},
};
}
return {
ok: true,
payloadJSON: JSON.stringify({ ok: true, key: resolved.key }),
};
}
case "sessions.patch": {
if (!validateSessionsPatchParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.patch params: ${formatValidationErrors(validateSessionsPatchParams.errors)}`,
},
};
}
const p = params as SessionsPatchParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
const applied = await updateSessionStore(storePath, async (store) => {
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
return await applySessionsPatchToStore({
cfg,
store,
storeKey: primaryKey,
patch: p,
loadGatewayModelCatalog: ctx.loadGatewayModelCatalog,
});
});
if (!applied.ok) {
return {
ok: false,
error: {
code: applied.error.code,
message: applied.error.message,
details: applied.error.details,
},
};
}
const payload: SessionsPatchResult = {
ok: true,
path: storePath,
key: target.canonicalKey,
entry: applied.entry,
};
return { ok: true, payloadJSON: JSON.stringify(payload) };
}
case "sessions.reset": {
if (!validateSessionsResetParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.reset params: ${formatValidationErrors(validateSessionsResetParams.errors)}`,
},
};
}
const p = params as SessionsResetParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
const next = await updateSessionStore(storePath, (store) => {
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
const entry = store[primaryKey];
const now = Date.now();
const nextEntry: SessionEntry = {
sessionId: randomUUID(),
updatedAt: now,
systemSent: false,
abortedLastRun: false,
thinkingLevel: entry?.thinkingLevel,
verboseLevel: entry?.verboseLevel,
reasoningLevel: entry?.reasoningLevel,
model: entry?.model,
contextTokens: entry?.contextTokens,
sendPolicy: entry?.sendPolicy,
label: entry?.label,
origin: snapshotSessionOrigin(entry),
displayName: entry?.displayName,
chatType: entry?.chatType,
channel: entry?.channel,
subject: entry?.subject,
groupChannel: entry?.groupChannel,
space: entry?.space,
lastChannel: entry?.lastChannel,
lastTo: entry?.lastTo,
skillsSnapshot: entry?.skillsSnapshot,
};
store[primaryKey] = nextEntry;
return nextEntry;
});
return {
ok: true,
payloadJSON: JSON.stringify({ ok: true, key, entry: next }),
};
}
case "sessions.delete": {
if (!validateSessionsDeleteParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.delete params: ${formatValidationErrors(validateSessionsDeleteParams.errors)}`,
},
};
}
const p = params as SessionsDeleteParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const mainKey = resolveMainSessionKeyFromConfig();
if (key === mainKey) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `Cannot delete the main session (${mainKey}).`,
},
};
}
const deleteTranscript = typeof p.deleteTranscript === "boolean" ? p.deleteTranscript : true;
const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
const { entry } = loadSessionEntry(key);
const sessionId = entry?.sessionId;
clearCommandLane(resolveEmbeddedSessionLane(key));
if (sessionId && isEmbeddedPiRunActive(sessionId)) {
abortEmbeddedPiRun(sessionId);
const ended = await waitForEmbeddedPiRunEnd(sessionId, 15_000);
if (!ended) {
return {
ok: false,
error: {
code: ErrorCodes.UNAVAILABLE,
message: `Session ${key} is still active; try again in a moment.`,
},
};
}
}
const deletion = await updateSessionStore(storePath, (store) => {
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
const entryToDelete = store[primaryKey];
const existed = Boolean(entryToDelete);
if (existed) delete store[primaryKey];
return { existed, entry: entryToDelete };
});
const existed = deletion.existed;
const archived: string[] = [];
if (deleteTranscript && sessionId) {
for (const candidate of resolveSessionTranscriptCandidates(
sessionId,
storePath,
entry?.sessionFile,
)) {
if (!fs.existsSync(candidate)) continue;
try {
archived.push(archiveFileOnDisk(candidate, "deleted"));
} catch {
// Best-effort; deleting the store entry is the main operation.
}
}
}
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
deleted: existed,
archived,
}),
};
}
case "sessions.compact": {
if (!validateSessionsCompactParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid sessions.compact params: ${formatValidationErrors(validateSessionsCompactParams.errors)}`,
},
};
}
const p = params as SessionsCompactParams;
const key = String(p.key ?? "").trim();
if (!key) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: "key required",
},
};
}
const maxLines =
typeof p.maxLines === "number" && Number.isFinite(p.maxLines)
? Math.max(1, Math.floor(p.maxLines))
: 400;
const cfg = loadConfig();
const target = resolveGatewaySessionStoreTarget({ cfg, key });
const storePath = target.storePath;
// Resolve entry inside the lock, but compact outside to avoid holding it.
const compactTarget = await updateSessionStore(storePath, (store) => {
const primaryKey = target.storeKeys[0] ?? key;
const existingKey = target.storeKeys.find((candidate) => store[candidate]);
if (existingKey && existingKey !== primaryKey && !store[primaryKey]) {
store[primaryKey] = store[existingKey];
delete store[existingKey];
}
return { entry: store[primaryKey], primaryKey };
});
const entry = compactTarget.entry;
const sessionId = entry?.sessionId;
if (!sessionId) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: false,
reason: "no sessionId",
}),
};
}
const filePath = resolveSessionTranscriptCandidates(
sessionId,
storePath,
entry?.sessionFile,
).find((candidate) => fs.existsSync(candidate));
if (!filePath) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: false,
reason: "no transcript",
}),
};
}
const raw = fs.readFileSync(filePath, "utf-8");
const lines = raw.split(/\r?\n/).filter((l) => l.trim().length > 0);
if (lines.length <= maxLines) {
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: false,
kept: lines.length,
}),
};
}
const archived = archiveFileOnDisk(filePath, "bak");
const keptLines = lines.slice(-maxLines);
fs.writeFileSync(filePath, `${keptLines.join("\n")}\n`, "utf-8");
// Token counts no longer match; clear so status + UI reflect reality after the next turn.
await updateSessionStore(storePath, (store) => {
const entryToUpdate = store[compactTarget.primaryKey];
if (!entryToUpdate) return;
delete entryToUpdate.inputTokens;
delete entryToUpdate.outputTokens;
delete entryToUpdate.totalTokens;
entryToUpdate.updatedAt = Date.now();
});
return {
ok: true,
payloadJSON: JSON.stringify({
ok: true,
key,
compacted: true,
archived,
kept: keptLines.length,
}),
};
}
default:
return null;
}
};

View File

@@ -1,94 +0,0 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
import { buildWorkspaceSkillStatus } from "../agents/skills-status.js";
import { loadConfig } from "../config/config.js";
import { getRemoteSkillEligibility } from "../infra/skills-remote.js";
import { loadVoiceWakeConfig, setVoiceWakeTriggers } from "../infra/voicewake.js";
import {
ErrorCodes,
formatValidationErrors,
validateModelsListParams,
validateTalkModeParams,
} from "./protocol/index.js";
import type { BridgeMethodHandler } from "./server-bridge-types.js";
import { HEALTH_REFRESH_INTERVAL_MS } from "./server-constants.js";
import { normalizeVoiceWakeTriggers } from "./server-utils.js";
export const handleSystemBridgeMethods: BridgeMethodHandler = async (
ctx,
_nodeId,
method,
params,
) => {
switch (method) {
case "voicewake.get": {
const cfg = await loadVoiceWakeConfig();
return {
ok: true,
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
};
}
case "voicewake.set": {
const triggers = normalizeVoiceWakeTriggers(params.triggers);
const cfg = await setVoiceWakeTriggers(triggers);
ctx.broadcastVoiceWakeChanged(cfg.triggers);
return {
ok: true,
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
};
}
case "health": {
const now = Date.now();
const cached = ctx.getHealthCache();
if (cached && now - cached.ts < HEALTH_REFRESH_INTERVAL_MS) {
return { ok: true, payloadJSON: JSON.stringify(cached) };
}
const snap = await ctx.refreshHealthSnapshot({ probe: false });
return { ok: true, payloadJSON: JSON.stringify(snap) };
}
case "talk.mode": {
if (!validateTalkModeParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid talk.mode params: ${formatValidationErrors(validateTalkModeParams.errors)}`,
},
};
}
const payload = {
enabled: (params as { enabled: boolean }).enabled,
phase: (params as { phase?: string }).phase ?? null,
ts: Date.now(),
};
ctx.broadcast("talk.mode", payload, { dropIfSlow: true });
return { ok: true, payloadJSON: JSON.stringify(payload) };
}
case "models.list": {
if (!validateModelsListParams(params)) {
return {
ok: false,
error: {
code: ErrorCodes.INVALID_REQUEST,
message: `invalid models.list params: ${formatValidationErrors(validateModelsListParams.errors)}`,
},
};
}
const models = await ctx.loadGatewayModelCatalog();
return { ok: true, payloadJSON: JSON.stringify({ models }) };
}
case "skills.bins": {
const cfg = loadConfig();
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
const report = buildWorkspaceSkillStatus(workspaceDir, {
config: cfg,
eligibility: { remote: getRemoteSkillEligibility() },
});
const bins = Array.from(
new Set(report.skills.flatMap((skill) => skill.requirements?.bins ?? []).filter(Boolean)),
);
return { ok: true, payloadJSON: JSON.stringify({ bins }) };
}
default:
return null;
}
};

View File

@@ -1,246 +0,0 @@
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import { startCanvasHost } from "../canvas-host/server.js";
import type { CliDeps } from "../cli/deps.js";
import type { HealthSummary } from "../commands/health.js";
import type { ClawdbotConfig } from "../config/config.js";
import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js";
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import { loadBridgeTlsRuntime } from "../infra/bridge/server/tls.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import type { RuntimeEnv } from "../runtime.js";
import type { ChatAbortControllerEntry } from "./chat-abort.js";
import { createBridgeHandlers } from "./server-bridge.js";
import {
type BridgeListConnectedFn,
type BridgeSendEventFn,
createBridgeSubscriptionManager,
} from "./server-bridge-subscriptions.js";
import type { ChatRunEntry } from "./server-chat.js";
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
import { startGatewayNodeBridge } from "./server-node-bridge.js";
import type { DedupeEntry } from "./server-shared.js";
export type GatewayBridgeRuntime = {
bridge: import("../infra/bridge/server.js").NodeBridgeServer | null;
bridgeHost: string | null;
bridgePort: number;
canvasHostServer: CanvasHostServer | null;
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
bonjourStop: (() => Promise<void>) | null;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
broadcastVoiceWakeChanged: (triggers: string[]) => void;
};
export async function startGatewayBridgeRuntime(params: {
cfg: ClawdbotConfig;
port: number;
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
canvasHostEnabled: boolean;
canvasHost: CanvasHostHandler | null;
canvasRuntime: RuntimeEnv;
allowCanvasHostInTests?: boolean;
machineDisplayName: string;
deps: CliDeps;
broadcast: (
event: string,
payload: unknown,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => void;
dedupe: Map<string, DedupeEntry>;
agentRunSeq: Map<string, number>;
chatRunState: { abortedRuns: Map<string, number> };
chatRunBuffers: Map<string, string>;
chatDeltaSentAt: Map<string, number>;
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
removeChatRun: (
sessionId: string,
clientRunId: string,
sessionKey?: string,
) => ChatRunEntry | undefined;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
getHealthCache: () => HealthSummary | null;
refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
logCanvas: { warn: (msg: string) => void };
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
}): Promise<GatewayBridgeRuntime> {
const wideAreaDiscoveryEnabled = params.cfg.discovery?.wideArea?.enabled === true;
let bridgeEnabled = (() => {
if (params.cfg.bridge?.enabled !== undefined) return params.cfg.bridge.enabled === true;
return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0";
})();
const bridgePort = (() => {
if (typeof params.cfg.bridge?.port === "number" && params.cfg.bridge.port > 0) {
return params.cfg.bridge.port;
}
if (process.env.CLAWDBOT_BRIDGE_PORT !== undefined) {
const parsed = Number.parseInt(process.env.CLAWDBOT_BRIDGE_PORT, 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : deriveDefaultBridgePort(params.port);
}
return deriveDefaultBridgePort(params.port);
})();
const bridgeHost = (() => {
// Back-compat: allow an env var override when no bind policy is configured.
if (params.cfg.bridge?.bind === undefined) {
const env = process.env.CLAWDBOT_BRIDGE_HOST?.trim();
if (env) return env;
}
const bind = params.cfg.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan");
if (bind === "loopback") return "127.0.0.1";
if (bind === "lan") return "0.0.0.0";
const tailnetIPv4 = pickPrimaryTailnetIPv4();
const tailnetIPv6 = pickPrimaryTailnetIPv6();
if (bind === "auto") {
return tailnetIPv4 ?? tailnetIPv6 ?? "0.0.0.0";
}
if (bind === "custom") {
// For bridge, customBindHost is not currently supported on GatewayConfig.
// This will fall back to "0.0.0.0" until we add customBindHost to BridgeConfig.
return "0.0.0.0";
}
return "0.0.0.0";
})();
const bridgeTls = bridgeEnabled
? await loadBridgeTlsRuntime(params.cfg.bridge?.tls, params.logBridge)
: { enabled: false, required: false };
if (bridgeTls.required && !bridgeTls.enabled) {
params.logBridge.warn(bridgeTls.error ?? "bridge tls: failed to enable; bridge disabled");
bridgeEnabled = false;
}
const canvasHostPort = (() => {
if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) {
const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10);
if (Number.isFinite(parsed) && parsed > 0) return parsed;
return deriveDefaultCanvasHostPort(params.port);
}
const configured = params.cfg.canvasHost?.port;
if (typeof configured === "number" && configured > 0) return configured;
return deriveDefaultCanvasHostPort(params.port);
})();
let canvasHostServer: CanvasHostServer | null = null;
if (params.canvasHostEnabled && bridgeEnabled && bridgeHost) {
try {
const started = await startCanvasHost({
runtime: params.canvasRuntime,
rootDir: params.cfg.canvasHost?.root,
port: canvasHostPort,
listenHost: bridgeHost,
allowInTests: params.allowCanvasHostInTests,
liveReload: params.cfg.canvasHost?.liveReload,
handler: params.canvasHost ?? undefined,
ownsHandler: params.canvasHost ? false : undefined,
});
if (started.port > 0) {
canvasHostServer = started;
}
} catch (err) {
params.logCanvas.warn(`failed to start on ${bridgeHost}:${canvasHostPort}: ${String(err)}`);
}
}
let bridge: NodeBridgeServer | null = null;
const bridgeSubscriptions = createBridgeSubscriptionManager();
const bridgeSubscribe = bridgeSubscriptions.subscribe;
const bridgeUnsubscribe = bridgeSubscriptions.unsubscribe;
const bridgeUnsubscribeAll = bridgeSubscriptions.unsubscribeAll;
const bridgeSendEvent: BridgeSendEventFn = (opts) => {
bridge?.sendEvent(opts);
};
const bridgeListConnected: BridgeListConnectedFn = () => bridge?.listConnected() ?? [];
const bridgeSendToSession = (sessionKey: string, event: string, payload: unknown) =>
bridgeSubscriptions.sendToSession(sessionKey, event, payload, bridgeSendEvent);
const bridgeSendToAllSubscribed = (event: string, payload: unknown) =>
bridgeSubscriptions.sendToAllSubscribed(event, payload, bridgeSendEvent);
const bridgeSendToAllConnected = (event: string, payload: unknown) =>
bridgeSubscriptions.sendToAllConnected(event, payload, bridgeListConnected, bridgeSendEvent);
const broadcastVoiceWakeChanged = (triggers: string[]) => {
const payload = { triggers };
params.broadcast("voicewake.changed", payload, { dropIfSlow: true });
bridgeSendToAllConnected("voicewake.changed", payload);
};
const { handleBridgeRequest, handleBridgeEvent } = createBridgeHandlers({
deps: params.deps,
broadcast: params.broadcast,
bridgeSendToSession,
bridgeSubscribe,
bridgeUnsubscribe,
broadcastVoiceWakeChanged,
addChatRun: params.addChatRun,
removeChatRun: params.removeChatRun,
chatAbortControllers: params.chatAbortControllers,
chatAbortedRuns: params.chatRunState.abortedRuns,
chatRunBuffers: params.chatRunBuffers,
chatDeltaSentAt: params.chatDeltaSentAt,
dedupe: params.dedupe,
agentRunSeq: params.agentRunSeq,
getHealthCache: params.getHealthCache,
refreshHealthSnapshot: params.refreshGatewayHealthSnapshot,
loadGatewayModelCatalog: params.loadGatewayModelCatalog ?? loadGatewayModelCatalog,
logBridge: params.logBridge,
});
const canvasHostPortForBridge = canvasHostServer?.port;
const canvasHostHostForBridge =
canvasHostServer && bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::"
? bridgeHost
: undefined;
const bridgeRuntime = await startGatewayNodeBridge({
cfg: params.cfg,
bridgeEnabled,
bridgePort,
bridgeHost,
bridgeTls: bridgeTls.enabled ? bridgeTls : undefined,
machineDisplayName: params.machineDisplayName,
canvasHostPort: canvasHostPortForBridge,
canvasHostHost: canvasHostHostForBridge,
broadcast: params.broadcast,
bridgeUnsubscribeAll,
handleBridgeRequest,
handleBridgeEvent,
logBridge: params.logBridge,
});
bridge = bridgeRuntime.bridge;
const discovery = await startGatewayDiscovery({
machineDisplayName: params.machineDisplayName,
port: params.port,
gatewayTls: params.gatewayTls,
bridgePort: bridge?.port,
bridgeTls: bridgeTls.enabled
? { enabled: true, fingerprintSha256: bridgeTls.fingerprintSha256 }
: undefined,
canvasPort: canvasHostPortForBridge,
wideAreaDiscoveryEnabled,
logDiscovery: params.logDiscovery,
});
return {
bridge,
bridgeHost,
bridgePort,
canvasHostServer,
nodePresenceTimers: bridgeRuntime.nodePresenceTimers,
bonjourStop: discovery.bonjourStop,
bridgeSendToSession,
bridgeSendToAllSubscribed,
broadcastVoiceWakeChanged,
};
}

View File

@@ -1,62 +0,0 @@
import { ErrorCodes } from "./protocol/index.js";
import { handleBridgeEvent as handleBridgeEventImpl } from "./server-bridge-events.js";
import { handleChatBridgeMethods } from "./server-bridge-methods-chat.js";
import { handleConfigBridgeMethods } from "./server-bridge-methods-config.js";
import { handleSessionsBridgeMethods } from "./server-bridge-methods-sessions.js";
import { handleSystemBridgeMethods } from "./server-bridge-methods-system.js";
import type {
BridgeEvent,
BridgeHandlersContext,
BridgeRequest,
BridgeResponse,
} from "./server-bridge-types.js";
export type { BridgeHandlersContext } from "./server-bridge-types.js";
export function createBridgeHandlers(ctx: BridgeHandlersContext) {
const handleBridgeRequest = async (
nodeId: string,
req: BridgeRequest,
): Promise<BridgeResponse> => {
const method = req.method.trim();
const parseParams = (): Record<string, unknown> => {
const raw = typeof req.paramsJSON === "string" ? req.paramsJSON : "";
const trimmed = raw.trim();
if (!trimmed) return {};
const parsed = JSON.parse(trimmed) as unknown;
return typeof parsed === "object" && parsed !== null
? (parsed as Record<string, unknown>)
: {};
};
try {
const params = parseParams();
const response =
(await handleSystemBridgeMethods(ctx, nodeId, method, params)) ??
(await handleConfigBridgeMethods(ctx, nodeId, method, params)) ??
(await handleSessionsBridgeMethods(ctx, nodeId, method, params)) ??
(await handleChatBridgeMethods(ctx, nodeId, method, params));
if (response) return response;
return {
ok: false,
error: {
code: "FORBIDDEN",
message: "Method not allowed",
details: { method },
},
};
} catch (err) {
return {
ok: false,
error: { code: ErrorCodes.INVALID_REQUEST, message: String(err) },
};
}
};
const handleBridgeEvent = async (nodeId: string, evt: BridgeEvent) => {
await handleBridgeEventImpl(ctx, nodeId, evt);
};
return { handleBridgeRequest, handleBridgeEvent };
}

View File

@@ -94,11 +94,11 @@ export type ChatEventBroadcast = (
opts?: { dropIfSlow?: boolean },
) => void;
export type BridgeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
export type NodeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
export type AgentEventHandlerOptions = {
broadcast: ChatEventBroadcast;
bridgeSendToSession: BridgeSendToSession;
nodeSendToSession: NodeSendToSession;
agentRunSeq: Map<string, number>;
chatRunState: ChatRunState;
resolveSessionKeyForRun: (runId: string) => string | undefined;
@@ -107,7 +107,7 @@ export type AgentEventHandlerOptions = {
export function createAgentEventHandler({
broadcast,
bridgeSendToSession,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun,
@@ -131,7 +131,7 @@ export function createAgentEventHandler({
},
};
broadcast("chat", payload, { dropIfSlow: true });
bridgeSendToSession(sessionKey, "chat", payload);
nodeSendToSession(sessionKey, "chat", payload);
};
const emitChatFinal = (
@@ -159,7 +159,7 @@ export function createAgentEventHandler({
: undefined,
};
broadcast("chat", payload);
bridgeSendToSession(sessionKey, "chat", payload);
nodeSendToSession(sessionKey, "chat", payload);
return;
}
const payload = {
@@ -170,7 +170,7 @@ export function createAgentEventHandler({
errorMessage: error ? formatForLog(error) : undefined,
};
broadcast("chat", payload);
bridgeSendToSession(sessionKey, "chat", payload);
nodeSendToSession(sessionKey, "chat", payload);
};
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
@@ -222,7 +222,7 @@ export function createAgentEventHandler({
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
if (sessionKey) {
bridgeSendToSession(sessionKey, "agent", agentPayload);
nodeSendToSession(sessionKey, "agent", agentPayload);
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {

View File

@@ -3,7 +3,6 @@ import type { WebSocketServer } from "ws";
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import type { PluginServicesHandle } from "../plugins/services.js";
export function createGatewayCloseHandler(params: {
@@ -11,7 +10,6 @@ export function createGatewayCloseHandler(params: {
tailscaleCleanup: (() => Promise<void>) | null;
canvasHost: CanvasHostHandler | null;
canvasHostServer: CanvasHostServer | null;
bridge: NodeBridgeServer | null;
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
pluginServices: PluginServicesHandle | null;
cron: { stop: () => void };
@@ -61,13 +59,6 @@ export function createGatewayCloseHandler(params: {
/* ignore */
}
}
if (params.bridge) {
try {
await params.bridge.close();
} catch {
/* ignore */
}
}
for (const plugin of listChannelPlugins()) {
await params.stopChannel(plugin.id);
}

View File

@@ -1,6 +1,6 @@
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
import { WIDE_AREA_DISCOVERY_DOMAIN, writeWideAreaBridgeZone } from "../infra/widearea-dns.js";
import { WIDE_AREA_DISCOVERY_DOMAIN, writeWideAreaGatewayZone } from "../infra/widearea-dns.js";
import {
formatBonjourInstanceName,
resolveBonjourCliPath,
@@ -11,8 +11,6 @@ export async function startGatewayDiscovery(params: {
machineDisplayName: string;
port: number;
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
bridgePort?: number;
bridgeTls?: { enabled: boolean; fingerprintSha256?: string };
canvasPort?: number;
wideAreaDiscoveryEnabled: boolean;
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
@@ -34,10 +32,7 @@ export async function startGatewayDiscovery(params: {
gatewayPort: params.port,
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
bridgePort: params.bridgePort,
canvasPort: params.canvasPort,
bridgeTlsEnabled: params.bridgeTls?.enabled ?? false,
bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256,
sshPort,
tailnetDns,
cliPath: resolveBonjourCliPath(),
@@ -47,7 +42,7 @@ export async function startGatewayDiscovery(params: {
params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`);
}
if (params.wideAreaDiscoveryEnabled && params.bridgePort) {
if (params.wideAreaDiscoveryEnabled) {
const tailnetIPv4 = pickPrimaryTailnetIPv4();
if (!tailnetIPv4) {
params.logDiscovery.warn(
@@ -56,14 +51,13 @@ export async function startGatewayDiscovery(params: {
} else {
try {
const tailnetIPv6 = pickPrimaryTailnetIPv6();
const result = await writeWideAreaBridgeZone({
bridgePort: params.bridgePort,
const result = await writeWideAreaGatewayZone({
gatewayPort: params.port,
displayName: formatBonjourInstanceName(params.machineDisplayName),
tailnetIPv4,
tailnetIPv6: tailnetIPv6 ?? undefined,
bridgeTlsEnabled: params.bridgeTls?.enabled ?? false,
bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256,
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
tailnetDns,
sshPort,
cliPath: resolveBonjourCliPath(),

View File

@@ -20,7 +20,7 @@ export function startGatewayMaintenanceTimers(params: {
stateVersion?: { presence?: number; health?: number };
},
) => void;
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
getPresenceVersion: () => number;
getHealthVersion: () => number;
refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
@@ -36,7 +36,7 @@ export function startGatewayMaintenanceTimers(params: {
sessionKey?: string,
) => ChatRunEntry | undefined;
agentRunSeq: Map<string, number>;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
}): {
tickInterval: ReturnType<typeof setInterval>;
healthInterval: ReturnType<typeof setInterval>;
@@ -49,14 +49,14 @@ export function startGatewayMaintenanceTimers(params: {
health: params.getHealthVersion(),
},
});
params.bridgeSendToAllSubscribed("health", snap);
params.nodeSendToAllSubscribed("health", snap);
});
// periodic keepalive
const tickInterval = setInterval(() => {
const payload = { ts: Date.now() };
params.broadcast("tick", payload, { dropIfSlow: true });
params.bridgeSendToAllSubscribed("tick", payload);
params.nodeSendToAllSubscribed("tick", payload);
}, TICK_INTERVAL_MS);
// periodic health refresh to keep cached snapshot warm
@@ -95,7 +95,7 @@ export function startGatewayMaintenanceTimers(params: {
removeChatRun: params.removeChatRun,
agentRunSeq: params.agentRunSeq,
broadcast: params.broadcast,
bridgeSendToSession: params.bridgeSendToSession,
nodeSendToSession: params.nodeSendToSession,
},
{ runId, sessionKey: entry.sessionKey, stopReason: "timeout" },
);

View File

@@ -52,6 +52,8 @@ const BASE_METHODS = [
"node.list",
"node.describe",
"node.invoke",
"node.invoke.result",
"node.event",
"cron.list",
"cron.status",
"cron.add",
@@ -87,6 +89,7 @@ export const GATEWAY_EVENTS = [
"cron",
"node.pair.requested",
"node.pair.resolved",
"node.invoke.request",
"device.pair.requested",
"device.pair.resolved",
"voicewake.changed",

View File

@@ -29,6 +29,7 @@ const APPROVALS_SCOPE = "operator.approvals";
const PAIRING_SCOPE = "operator.pairing";
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
const NODE_ROLE_METHODS = new Set(["node.invoke.result", "node.event"]);
const PAIRING_METHODS = new Set([
"node.pair.request",
"node.pair.list",
@@ -45,6 +46,10 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
if (!client?.connect) return null;
const role = client.connect.role ?? "operator";
const scopes = client.connect.scopes ?? [];
if (role === "node") {
if (NODE_ROLE_METHODS.has(method)) return null;
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
}
if (role !== "operator") {
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
}

View File

@@ -113,7 +113,7 @@ export const chatHandlers: GatewayRequestHandlers = {
removeChatRun: context.removeChatRun,
agentRunSeq: context.agentRunSeq,
broadcast: context.broadcast,
bridgeSendToSession: context.bridgeSendToSession,
nodeSendToSession: context.nodeSendToSession,
};
if (!runId) {
@@ -250,7 +250,7 @@ export const chatHandlers: GatewayRequestHandlers = {
removeChatRun: context.removeChatRun,
agentRunSeq: context.agentRunSeq,
broadcast: context.broadcast,
bridgeSendToSession: context.bridgeSendToSession,
nodeSendToSession: context.nodeSendToSession,
},
{ sessionKey: p.sessionKey, stopReason: "stop" },
);
@@ -451,7 +451,7 @@ export const chatHandlers: GatewayRequestHandlers = {
message: transcriptEntry.message,
};
context.broadcast("chat", chatPayload);
context.bridgeSendToSession(p.sessionKey, "chat", chatPayload);
context.nodeSendToSession(p.sessionKey, "chat", chatPayload);
respond(true, { ok: true, messageId });
},

View File

@@ -167,11 +167,6 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
);
return;
}
const bridge = context.bridge;
if (!bridge) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const { nodeId } = params as { nodeId: string };
const id = nodeId.trim();
if (!id) {
@@ -179,10 +174,10 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
return;
}
await respondUnavailableOnThrow(respond, async () => {
const res = await bridge.invoke({
const res = await context.nodeRegistry.invoke({
nodeId: id,
command: "system.execApprovals.get",
paramsJSON: "{}",
params: {},
});
if (!res.ok) {
respond(
@@ -194,7 +189,7 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
);
return;
}
const payload = safeParseJson(res.payloadJSON ?? null);
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
respond(true, payload, undefined);
});
},
@@ -210,11 +205,6 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
);
return;
}
const bridge = context.bridge;
if (!bridge) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const { nodeId, file, baseHash } = params as {
nodeId: string;
file: ExecApprovalsFile;
@@ -226,10 +216,10 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
return;
}
await respondUnavailableOnThrow(respond, async () => {
const res = await bridge.invoke({
const res = await context.nodeRegistry.invoke({
nodeId: id,
command: "system.execApprovals.set",
paramsJSON: JSON.stringify({ file, baseHash }),
params: { file, baseHash },
});
if (!res.ok) {
respond(

View File

@@ -6,11 +6,14 @@ import {
requestNodePairing,
verifyNodeToken,
} from "../../infra/node-pairing.js";
import { listDevicePairing } from "../../infra/device-pairing.js";
import {
ErrorCodes,
errorShape,
validateNodeDescribeParams,
validateNodeEventParams,
validateNodeInvokeParams,
validateNodeInvokeResultParams,
validateNodeListParams,
validateNodePairApproveParams,
validateNodePairListParams,
@@ -201,9 +204,29 @@ export const nodeHandlers: GatewayRequestHandlers = {
return;
}
await respondUnavailableOnThrow(respond, async () => {
const list = await listNodePairing();
const pairedById = new Map(list.paired.map((n) => [n.nodeId, n]));
const connected = context.bridge?.listConnected?.() ?? [];
const list = await listDevicePairing();
const pairedById = new Map(
list.paired
.filter((entry) => entry.role === "node")
.map((entry) => [
entry.deviceId,
{
nodeId: entry.deviceId,
displayName: entry.displayName,
platform: entry.platform,
version: undefined,
coreVersion: undefined,
uiVersion: undefined,
deviceFamily: undefined,
modelIdentifier: undefined,
remoteIp: entry.remoteIp,
caps: [],
commands: [],
permissions: undefined,
},
]),
);
const connected = context.nodeRegistry.listConnected();
const connectedById = new Map(connected.map((n) => [n.nodeId, n]));
const nodeIds = new Set<string>([...pairedById.keys(), ...connectedById.keys()]);
@@ -260,9 +283,9 @@ export const nodeHandlers: GatewayRequestHandlers = {
return;
}
await respondUnavailableOnThrow(respond, async () => {
const list = await listNodePairing();
const paired = list.paired.find((n) => n.nodeId === id);
const connected = context.bridge?.listConnected?.() ?? [];
const list = await listDevicePairing();
const paired = list.paired.find((n) => n.deviceId === id && n.role === "node");
const connected = context.nodeRegistry.listConnected();
const live = connected.find((n) => n.nodeId === id);
if (!paired && !live) {
@@ -270,8 +293,8 @@ export const nodeHandlers: GatewayRequestHandlers = {
return;
}
const caps = uniqueSortedStrings([...(live?.caps ?? paired?.caps ?? [])]);
const commands = uniqueSortedStrings([...(live?.commands ?? paired?.commands ?? [])]);
const caps = uniqueSortedStrings([...(live?.caps ?? [])]);
const commands = uniqueSortedStrings([...(live?.commands ?? [])]);
respond(
true,
@@ -280,15 +303,15 @@ export const nodeHandlers: GatewayRequestHandlers = {
nodeId: id,
displayName: live?.displayName ?? paired?.displayName,
platform: live?.platform ?? paired?.platform,
version: live?.version ?? paired?.version,
coreVersion: live?.coreVersion ?? paired?.coreVersion,
uiVersion: live?.uiVersion ?? paired?.uiVersion,
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
version: live?.version,
coreVersion: live?.coreVersion,
uiVersion: live?.uiVersion,
deviceFamily: live?.deviceFamily,
modelIdentifier: live?.modelIdentifier,
remoteIp: live?.remoteIp ?? paired?.remoteIp,
caps,
commands,
permissions: live?.permissions ?? paired?.permissions,
permissions: live?.permissions,
paired: Boolean(paired),
connected: Boolean(live),
},
@@ -305,11 +328,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
});
return;
}
const bridge = context.bridge;
if (!bridge) {
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
return;
}
const p = params as {
nodeId: string;
command: string;
@@ -329,12 +347,12 @@ export const nodeHandlers: GatewayRequestHandlers = {
}
await respondUnavailableOnThrow(respond, async () => {
const paramsJSON = "params" in p && p.params !== undefined ? JSON.stringify(p.params) : null;
const res = await bridge.invoke({
const res = await context.nodeRegistry.invoke({
nodeId,
command,
paramsJSON,
params: p.params,
timeoutMs: p.timeoutMs,
idempotencyKey: p.idempotencyKey,
});
if (!res.ok) {
respond(
@@ -346,7 +364,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
);
return;
}
const payload = safeParseJson(res.payloadJSON ?? null);
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
respond(
true,
{
@@ -360,4 +378,85 @@ export const nodeHandlers: GatewayRequestHandlers = {
);
});
},
"node.invoke.result": async ({ params, respond, context }) => {
if (!validateNodeInvokeResultParams(params)) {
respondInvalidParams({
respond,
method: "node.invoke.result",
validator: validateNodeInvokeResultParams,
});
return;
}
const p = params as {
id: string;
nodeId: string;
ok: boolean;
payload?: unknown;
payloadJSON?: string | null;
error?: { code?: string; message?: string } | null;
};
const ok = context.nodeRegistry.handleInvokeResult({
id: p.id,
nodeId: p.nodeId,
ok: p.ok,
payload: p.payload,
payloadJSON: p.payloadJSON ?? null,
error: p.error ?? null,
});
if (!ok) {
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown invoke id"));
return;
}
respond(true, { ok: true }, undefined);
},
"node.event": async ({ params, respond, context }) => {
if (!validateNodeEventParams(params)) {
respondInvalidParams({
respond,
method: "node.event",
validator: validateNodeEventParams,
});
return;
}
const p = params as { event: string; payload?: unknown; payloadJSON?: string | null };
const payloadJSON =
typeof p.payloadJSON === "string"
? p.payloadJSON
: p.payload !== undefined
? JSON.stringify(p.payload)
: null;
await respondUnavailableOnThrow(respond, async () => {
const { handleNodeEvent } = await import("../server-node-events.js");
const nodeContext = {
deps: context.deps,
broadcast: context.broadcast,
nodeSendToSession: context.nodeSendToSession,
nodeSubscribe: context.nodeSubscribe,
nodeUnsubscribe: context.nodeUnsubscribe,
broadcastVoiceWakeChanged: context.broadcastVoiceWakeChanged,
addChatRun: context.addChatRun,
removeChatRun: context.removeChatRun,
chatAbortControllers: context.chatAbortControllers,
chatAbortedRuns: context.chatAbortedRuns,
chatRunBuffers: context.chatRunBuffers,
chatDeltaSentAt: context.chatDeltaSentAt,
dedupe: context.dedupe,
agentRunSeq: context.agentRunSeq,
getHealthCache: context.getHealthCache,
refreshHealthSnapshot: context.refreshHealthSnapshot,
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
logGateway: { warn: context.logGateway.warn },
};
await handleNodeEvent(
nodeContext,
"node",
{
type: "event",
event: p.event,
payloadJSON,
},
);
respond(true, { ok: true }, undefined);
});
},
};

View File

@@ -2,9 +2,9 @@ import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
import type { createDefaultDeps } from "../../cli/deps.js";
import type { HealthSummary } from "../../commands/health.js";
import type { CronService } from "../../cron/service.js";
import type { startNodeBridgeServer } from "../../infra/bridge/server.js";
import type { WizardSession } from "../../wizard/session.js";
import type { ChatAbortControllerEntry } from "../chat-abort.js";
import type { NodeRegistry } from "../node-registry.js";
import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js";
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
import type { DedupeEntry } from "../server-shared.js";
@@ -39,9 +39,13 @@ export type GatewayRequestContext = {
stateVersion?: { presence?: number; health?: number };
},
) => void;
bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
nodeSubscribe: (nodeId: string, sessionKey: string) => void;
nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
nodeUnsubscribeAll: (nodeId: string) => void;
hasConnectedMobileNode: () => boolean;
nodeRegistry: NodeRegistry;
agentRunSeq: Map<string, number>;
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
chatAbortedRuns: Map<string, number>;

View File

@@ -1,6 +1,4 @@
type BridgeLike = {
listConnected?: () => Array<{ platform?: string | null }>;
};
import type { NodeRegistry } from "./node-registry.js";
const isMobilePlatform = (platform: unknown): boolean => {
const p = typeof platform === "string" ? platform.trim().toLowerCase() : "";
@@ -8,7 +6,7 @@ const isMobilePlatform = (platform: unknown): boolean => {
return p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android");
};
export function hasConnectedMobileNode(bridge: BridgeLike | null): boolean {
const connected = bridge?.listConnected?.() ?? [];
export function hasConnectedMobileNode(registry: NodeRegistry): boolean {
const connected = registry.listConnected();
return connected.some((n) => isMobilePlatform(n.platform));
}

View File

@@ -1,202 +0,0 @@
import type { NodeBridgeServer } from "../infra/bridge/server.js";
import { startNodeBridgeServer } from "../infra/bridge/server.js";
import type { BridgeTlsRuntime } from "../infra/bridge/server/tls.js";
import type { ClawdbotConfig } from "../config/config.js";
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../infra/skills-remote.js";
import { listSystemPresence, upsertPresence } from "../infra/system-presence.js";
import { loadVoiceWakeConfig } from "../infra/voicewake.js";
import { isLoopbackAddress } from "./net.js";
import {
getHealthVersion,
getPresenceVersion,
incrementPresenceVersion,
} from "./server/health-state.js";
import type { BridgeEvent, BridgeRequest, BridgeResponse } from "./server-bridge-types.js";
export type GatewayNodeBridgeRuntime = {
bridge: NodeBridgeServer | null;
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
};
export async function startGatewayNodeBridge(params: {
cfg: ClawdbotConfig;
bridgeEnabled: boolean;
bridgePort: number;
bridgeHost: string | null;
bridgeTls?: BridgeTlsRuntime;
machineDisplayName: string;
canvasHostPort?: number;
canvasHostHost?: string;
broadcast: (
event: string,
payload: unknown,
opts?: {
dropIfSlow?: boolean;
stateVersion?: { presence?: number; health?: number };
},
) => void;
bridgeUnsubscribeAll: (nodeId: string) => void;
handleBridgeRequest: (nodeId: string, req: BridgeRequest) => Promise<BridgeResponse>;
handleBridgeEvent: (nodeId: string, evt: BridgeEvent) => Promise<void> | void;
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
}): Promise<GatewayNodeBridgeRuntime> {
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
const formatVersionLabel = (raw: string): string => {
const trimmed = raw.trim();
if (!trimmed) return raw;
if (trimmed.toLowerCase().startsWith("v")) return trimmed;
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
};
const resolveNodeVersionLabel = (node: {
coreVersion?: string;
uiVersion?: string;
}): string | null => {
const core = node.coreVersion?.trim();
const ui = node.uiVersion?.trim();
const parts: string[] = [];
if (core) parts.push(`core ${formatVersionLabel(core)}`);
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
return parts.length > 0 ? parts.join(" · ") : null;
};
const stopNodePresenceTimer = (nodeId: string) => {
const timer = nodePresenceTimers.get(nodeId);
if (timer) {
clearInterval(timer);
}
nodePresenceTimers.delete(nodeId);
};
const beaconNodePresence = (
node: {
nodeId: string;
displayName?: string;
remoteIp?: string;
version?: string;
coreVersion?: string;
uiVersion?: string;
platform?: string;
deviceFamily?: string;
modelIdentifier?: string;
},
reason: string,
) => {
const host = node.displayName?.trim() || node.nodeId;
const rawIp = node.remoteIp?.trim();
const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined;
const version = resolveNodeVersionLabel(node) ?? node.version?.trim() ?? "unknown";
const platform = node.platform?.trim() || undefined;
const deviceFamily = node.deviceFamily?.trim() || undefined;
const modelIdentifier = node.modelIdentifier?.trim() || undefined;
const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason ${reason}`;
upsertPresence(node.nodeId, {
host,
ip,
version,
platform,
deviceFamily,
modelIdentifier,
mode: "remote",
reason,
lastInputSeconds: 0,
instanceId: node.nodeId,
text,
});
incrementPresenceVersion();
params.broadcast(
"presence",
{ presence: listSystemPresence() },
{
dropIfSlow: true,
stateVersion: {
presence: getPresenceVersion(),
health: getHealthVersion(),
},
},
);
};
const startNodePresenceTimer = (node: { nodeId: string }) => {
stopNodePresenceTimer(node.nodeId);
nodePresenceTimers.set(
node.nodeId,
setInterval(() => {
beaconNodePresence(node, "periodic");
}, 180_000),
);
};
if (params.bridgeEnabled && params.bridgePort > 0 && params.bridgeHost) {
try {
const started = await startNodeBridgeServer({
host: params.bridgeHost,
port: params.bridgePort,
tls: params.bridgeTls?.tlsOptions,
serverName: params.machineDisplayName,
canvasHostPort: params.canvasHostPort,
canvasHostHost: params.canvasHostHost,
onRequest: (nodeId, req) => params.handleBridgeRequest(nodeId, req),
onAuthenticated: async (node) => {
beaconNodePresence(node, "node-connected");
startNodePresenceTimer(node);
recordRemoteNodeInfo({
nodeId: node.nodeId,
displayName: node.displayName,
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
remoteIp: node.remoteIp,
});
bumpSkillsSnapshotVersion({ reason: "remote-node" });
await refreshRemoteNodeBins({
nodeId: node.nodeId,
platform: node.platform,
deviceFamily: node.deviceFamily,
commands: node.commands,
cfg: params.cfg,
});
try {
const cfg = await loadVoiceWakeConfig();
started.sendEvent({
nodeId: node.nodeId,
event: "voicewake.changed",
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
});
} catch {
// Best-effort only.
}
},
onDisconnected: (node) => {
params.bridgeUnsubscribeAll(node.nodeId);
stopNodePresenceTimer(node.nodeId);
beaconNodePresence(node, "node-disconnected");
},
onEvent: params.handleBridgeEvent,
onPairRequested: (request) => {
params.broadcast("node.pair.requested", request, {
dropIfSlow: true,
});
},
});
if (started.port > 0) {
const scheme = params.bridgeTls?.enabled ? "tls" : "tcp";
params.logBridge.info(
`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`,
);
return { bridge: started, nodePresenceTimers };
}
} catch (err) {
params.logBridge.warn(`failed to start: ${String(err)}`);
}
} else if (params.bridgeEnabled && params.bridgePort > 0 && !params.bridgeHost) {
params.logBridge.warn(
"bind policy requested tailnet IP, but no tailnet interface was found; refusing to start bridge",
);
}
return { bridge: null, nodePresenceTimers };
}

View File

@@ -5,12 +5,12 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
import type { ChatRunEntry } from "./server-chat.js";
import type { DedupeEntry } from "./server-shared.js";
export type BridgeHandlersContext = {
export type NodeEventContext = {
deps: CliDeps;
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
bridgeSubscribe: (nodeId: string, sessionKey: string) => void;
bridgeUnsubscribe: (nodeId: string, sessionKey: string) => void;
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
nodeSubscribe: (nodeId: string, sessionKey: string) => void;
nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
broadcastVoiceWakeChanged: (triggers: string[]) => void;
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
removeChatRun: (
@@ -27,32 +27,10 @@ export type BridgeHandlersContext = {
getHealthCache: () => HealthSummary | null;
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
loadGatewayModelCatalog: () => Promise<ModelCatalogEntry[]>;
logBridge: { warn: (msg: string) => void };
logGateway: { warn: (msg: string) => void };
};
export type BridgeRequest = {
id: string;
method: string;
paramsJSON?: string | null;
};
export type BridgeEvent = {
export type NodeEvent = {
event: string;
payloadJSON?: string | null;
};
export type BridgeResponse =
| { ok: true; payloadJSON?: string | null }
| {
ok: false;
error: { code: string; message: string; details?: unknown };
};
export type BridgeRequestParams = Record<string, unknown>;
export type BridgeMethodHandler = (
ctx: BridgeHandlersContext,
nodeId: string,
method: string,
params: BridgeRequestParams,
) => Promise<BridgeResponse | null>;

View File

@@ -9,21 +9,21 @@ vi.mock("../infra/heartbeat-wake.js", () => ({
import { enqueueSystemEvent } from "../infra/system-events.js";
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { handleBridgeEvent } from "./server-bridge-events.js";
import type { BridgeHandlersContext } from "./server-bridge-types.js";
import { handleNodeEvent } from "./server-node-events.js";
import type { NodeEventContext } from "./server-node-events-types.js";
import type { HealthSummary } from "../commands/health.js";
import type { CliDeps } from "../cli/deps.js";
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
function buildCtx(): BridgeHandlersContext {
function buildCtx(): NodeEventContext {
return {
deps: {} as CliDeps,
broadcast: () => {},
bridgeSendToSession: () => {},
bridgeSubscribe: () => {},
bridgeUnsubscribe: () => {},
nodeSendToSession: () => {},
nodeSubscribe: () => {},
nodeUnsubscribe: () => {},
broadcastVoiceWakeChanged: () => {},
addChatRun: () => {},
removeChatRun: () => undefined,
@@ -36,11 +36,11 @@ function buildCtx(): BridgeHandlersContext {
getHealthCache: () => null,
refreshHealthSnapshot: async () => ({}) as HealthSummary,
loadGatewayModelCatalog: async () => [],
logBridge: { warn: () => {} },
logGateway: { warn: () => {} },
};
}
describe("bridge exec events", () => {
describe("node exec events", () => {
beforeEach(() => {
enqueueSystemEventMock.mockReset();
requestHeartbeatNowMock.mockReset();
@@ -48,7 +48,7 @@ describe("bridge exec events", () => {
it("enqueues exec.started events", async () => {
const ctx = buildCtx();
await handleBridgeEvent(ctx, "node-1", {
await handleNodeEvent(ctx, "node-1", {
event: "exec.started",
payloadJSON: JSON.stringify({
sessionKey: "agent:main:main",
@@ -66,7 +66,7 @@ describe("bridge exec events", () => {
it("enqueues exec.finished events with output", async () => {
const ctx = buildCtx();
await handleBridgeEvent(ctx, "node-2", {
await handleNodeEvent(ctx, "node-2", {
event: "exec.finished",
payloadJSON: JSON.stringify({
runId: "run-2",
@@ -85,7 +85,7 @@ describe("bridge exec events", () => {
it("enqueues exec.denied events with reason", async () => {
const ctx = buildCtx();
await handleBridgeEvent(ctx, "node-3", {
await handleNodeEvent(ctx, "node-3", {
event: "exec.denied",
payloadJSON: JSON.stringify({
sessionKey: "agent:demo:main",

View File

@@ -7,14 +7,14 @@ import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import type { BridgeEvent, BridgeHandlersContext } from "./server-bridge-types.js";
import type { NodeEvent, NodeEventContext } from "./server-node-events-types.js";
import { loadSessionEntry } from "./session-utils.js";
import { formatForLog } from "./ws-log.js";
export const handleBridgeEvent = async (
ctx: BridgeHandlersContext,
export const handleNodeEvent = async (
ctx: NodeEventContext,
nodeId: string,
evt: BridgeEvent,
evt: NodeEvent,
) => {
switch (evt.event) {
case "voice.transcript": {
@@ -72,7 +72,7 @@ export const handleBridgeEvent = async (
defaultRuntime,
ctx.deps,
).catch((err) => {
ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
});
return;
}
@@ -140,7 +140,7 @@ export const handleBridgeEvent = async (
defaultRuntime,
ctx.deps,
).catch((err) => {
ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
});
return;
}
@@ -156,7 +156,7 @@ export const handleBridgeEvent = async (
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
if (!sessionKey) return;
ctx.bridgeSubscribe(nodeId, sessionKey);
ctx.nodeSubscribe(nodeId, sessionKey);
return;
}
case "chat.unsubscribe": {
@@ -171,7 +171,7 @@ export const handleBridgeEvent = async (
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
if (!sessionKey) return;
ctx.bridgeUnsubscribe(nodeId, sessionKey);
ctx.nodeUnsubscribe(nodeId, sessionKey);
return;
}
case "exec.started":

View File

@@ -1,9 +1,9 @@
import { describe, expect, test } from "vitest";
import { createBridgeSubscriptionManager } from "./server-bridge-subscriptions.js";
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
describe("bridge subscription manager", () => {
describe("node subscription manager", () => {
test("routes events to subscribed nodes", () => {
const manager = createBridgeSubscriptionManager();
const manager = createNodeSubscriptionManager();
const sent: Array<{
nodeId: string;
event: string;
@@ -22,7 +22,7 @@ describe("bridge subscription manager", () => {
});
test("unsubscribeAll clears session mappings", () => {
const manager = createBridgeSubscriptionManager();
const manager = createNodeSubscriptionManager();
const sent: string[] = [];
const sendEvent = (evt: { nodeId: string; event: string }) =>
sent.push(`${evt.nodeId}:${evt.event}`);

View File

@@ -1,12 +1,12 @@
export type BridgeSendEventFn = (opts: {
export type NodeSendEventFn = (opts: {
nodeId: string;
event: string;
payloadJSON?: string | null;
}) => void;
export type BridgeListConnectedFn = () => Array<{ nodeId: string }>;
export type NodeListConnectedFn = () => Array<{ nodeId: string }>;
export type BridgeSubscriptionManager = {
export type NodeSubscriptionManager = {
subscribe: (nodeId: string, sessionKey: string) => void;
unsubscribe: (nodeId: string, sessionKey: string) => void;
unsubscribeAll: (nodeId: string) => void;
@@ -14,25 +14,25 @@ export type BridgeSubscriptionManager = {
sessionKey: string,
event: string,
payload: unknown,
sendEvent?: BridgeSendEventFn | null,
sendEvent?: NodeSendEventFn | null,
) => void;
sendToAllSubscribed: (
event: string,
payload: unknown,
sendEvent?: BridgeSendEventFn | null,
sendEvent?: NodeSendEventFn | null,
) => void;
sendToAllConnected: (
event: string,
payload: unknown,
listConnected?: BridgeListConnectedFn | null,
sendEvent?: BridgeSendEventFn | null,
listConnected?: NodeListConnectedFn | null,
sendEvent?: NodeSendEventFn | null,
) => void;
clear: () => void;
};
export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const bridgeNodeSubscriptions = new Map<string, Set<string>>();
const bridgeSessionSubscribers = new Map<string, Set<string>>();
export function createNodeSubscriptionManager(): NodeSubscriptionManager {
const nodeSubscriptions = new Map<string, Set<string>>();
const sessionSubscribers = new Map<string, Set<string>>();
const toPayloadJSON = (payload: unknown) => (payload ? JSON.stringify(payload) : null);
@@ -41,18 +41,18 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const normalizedSessionKey = sessionKey.trim();
if (!normalizedNodeId || !normalizedSessionKey) return;
let nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
let nodeSet = nodeSubscriptions.get(normalizedNodeId);
if (!nodeSet) {
nodeSet = new Set<string>();
bridgeNodeSubscriptions.set(normalizedNodeId, nodeSet);
nodeSubscriptions.set(normalizedNodeId, nodeSet);
}
if (nodeSet.has(normalizedSessionKey)) return;
nodeSet.add(normalizedSessionKey);
let sessionSet = bridgeSessionSubscribers.get(normalizedSessionKey);
let sessionSet = sessionSubscribers.get(normalizedSessionKey);
if (!sessionSet) {
sessionSet = new Set<string>();
bridgeSessionSubscribers.set(normalizedSessionKey, sessionSet);
sessionSubscribers.set(normalizedSessionKey, sessionSet);
}
sessionSet.add(normalizedNodeId);
};
@@ -62,36 +62,36 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const normalizedSessionKey = sessionKey.trim();
if (!normalizedNodeId || !normalizedSessionKey) return;
const nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
const nodeSet = nodeSubscriptions.get(normalizedNodeId);
nodeSet?.delete(normalizedSessionKey);
if (nodeSet?.size === 0) bridgeNodeSubscriptions.delete(normalizedNodeId);
if (nodeSet?.size === 0) nodeSubscriptions.delete(normalizedNodeId);
const sessionSet = bridgeSessionSubscribers.get(normalizedSessionKey);
const sessionSet = sessionSubscribers.get(normalizedSessionKey);
sessionSet?.delete(normalizedNodeId);
if (sessionSet?.size === 0) bridgeSessionSubscribers.delete(normalizedSessionKey);
if (sessionSet?.size === 0) sessionSubscribers.delete(normalizedSessionKey);
};
const unsubscribeAll = (nodeId: string) => {
const normalizedNodeId = nodeId.trim();
const nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
const nodeSet = nodeSubscriptions.get(normalizedNodeId);
if (!nodeSet) return;
for (const sessionKey of nodeSet) {
const sessionSet = bridgeSessionSubscribers.get(sessionKey);
const sessionSet = sessionSubscribers.get(sessionKey);
sessionSet?.delete(normalizedNodeId);
if (sessionSet?.size === 0) bridgeSessionSubscribers.delete(sessionKey);
if (sessionSet?.size === 0) sessionSubscribers.delete(sessionKey);
}
bridgeNodeSubscriptions.delete(normalizedNodeId);
nodeSubscriptions.delete(normalizedNodeId);
};
const sendToSession = (
sessionKey: string,
event: string,
payload: unknown,
sendEvent?: BridgeSendEventFn | null,
sendEvent?: NodeSendEventFn | null,
) => {
const normalizedSessionKey = sessionKey.trim();
if (!normalizedSessionKey || !sendEvent) return;
const subs = bridgeSessionSubscribers.get(normalizedSessionKey);
const subs = sessionSubscribers.get(normalizedSessionKey);
if (!subs || subs.size === 0) return;
const payloadJSON = toPayloadJSON(payload);
@@ -103,11 +103,11 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const sendToAllSubscribed = (
event: string,
payload: unknown,
sendEvent?: BridgeSendEventFn | null,
sendEvent?: NodeSendEventFn | null,
) => {
if (!sendEvent) return;
const payloadJSON = toPayloadJSON(payload);
for (const nodeId of bridgeNodeSubscriptions.keys()) {
for (const nodeId of nodeSubscriptions.keys()) {
sendEvent({ nodeId, event, payloadJSON });
}
};
@@ -115,8 +115,8 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
const sendToAllConnected = (
event: string,
payload: unknown,
listConnected?: BridgeListConnectedFn | null,
sendEvent?: BridgeSendEventFn | null,
listConnected?: NodeListConnectedFn | null,
sendEvent?: NodeSendEventFn | null,
) => {
if (!sendEvent || !listConnected) return;
const payloadJSON = toPayloadJSON(payload);
@@ -126,8 +126,8 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
};
const clear = () => {
bridgeNodeSubscriptions.clear();
bridgeSessionSubscribers.clear();
nodeSubscriptions.clear();
sessionSubscribers.clear();
};
return {

View File

@@ -1,9 +1,4 @@
import type {
BridgeBindMode,
GatewayAuthConfig,
GatewayTailscaleConfig,
loadConfig,
} from "../config/config.js";
import type { GatewayAuthConfig, GatewayBindMode, GatewayTailscaleConfig, loadConfig } from "../config/config.js";
import {
assertGatewayAuthConfigured,
type ResolvedGatewayAuth,
@@ -29,7 +24,7 @@ export type GatewayRuntimeConfig = {
export async function resolveGatewayRuntimeConfig(params: {
cfg: ReturnType<typeof loadConfig>;
port: number;
bind?: BridgeBindMode;
bind?: GatewayBindMode;
host?: string;
controlUiEnabled?: boolean;
openAiChatCompletionsEnabled?: boolean;

View File

@@ -9,7 +9,7 @@ export function attachGatewayWsHandlers(params: {
wss: WebSocketServer;
clients: Set<GatewayWsClient>;
port: number;
bridgeHost?: string;
gatewayHost?: string;
canvasHostEnabled: boolean;
canvasHostServerPort?: number;
resolvedAuth: ResolvedGatewayAuth;
@@ -33,7 +33,7 @@ export function attachGatewayWsHandlers(params: {
wss: params.wss,
clients: params.clients,
port: params.port,
bridgeHost: params.bridgeHost,
gatewayHost: params.gatewayHost,
canvasHostEnabled: params.canvasHostEnabled,
canvasHostServerPort: params.canvasHostServerPort,
resolvedAuth: params.resolvedAuth,

View File

@@ -20,7 +20,7 @@ import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
import {
primeRemoteSkillsCache,
refreshRemoteBinsForConnectedNodes,
setSkillsRemoteBridge,
setSkillsRemoteRegistry,
} from "../infra/skills-remote.js";
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
@@ -36,7 +36,7 @@ import {
incrementPresenceVersion,
refreshGatewayHealthSnapshot,
} from "./server/health-state.js";
import { startGatewayBridgeRuntime } from "./server-bridge-runtime.js";
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
import { ExecApprovalManager } from "./exec-approval-manager.js";
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
@@ -48,12 +48,15 @@ import { applyGatewayLaneConcurrency } from "./server-lanes.js";
import { startGatewayMaintenanceTimers } from "./server-maintenance.js";
import { coreGatewayHandlers } from "./server-methods.js";
import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.js";
import { hasConnectedMobileNode as hasConnectedMobileNodeFromBridge } from "./server-mobile-nodes.js";
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
import { NodeRegistry } from "./node-registry.js";
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
import { safeParseJson } from "./server-methods/nodes.helpers.js";
import { loadGatewayPlugins } from "./server-plugins.js";
import { createGatewayReloadHandlers } from "./server-reload-handlers.js";
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
import { createGatewayRuntimeState } from "./server-runtime-state.js";
import { hasConnectedMobileNode } from "./server-mobile-nodes.js";
import { resolveSessionKeyForRun } from "./server-session-key.js";
import { startGatewaySidecars } from "./server-startup.js";
import { logGatewayStartup } from "./server-startup-log.js";
@@ -68,7 +71,6 @@ ensureClawdbotCliOnPath();
const log = createSubsystemLogger("gateway");
const logCanvas = log.child("canvas");
const logBridge = log.child("bridge");
const logDiscovery = log.child("discovery");
const logTailscale = log.child("tailscale");
const logChannels = log.child("channels");
@@ -93,7 +95,7 @@ export type GatewayServerOptions = {
* - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10)
* - auto: prefer tailnet, else LAN
*/
bind?: import("../config/config.js").BridgeBindMode;
bind?: import("../config/config.js").GatewayBindMode;
/**
* Advanced override for the bind host, bypassing bind resolution.
* Prefer `bind` unless you really need a specific address.
@@ -135,7 +137,7 @@ export async function startGatewayServer(
port = 18789,
opts: GatewayServerOptions = {},
): Promise<GatewayServer> {
// Ensure all default port derivations (browser/bridge/canvas) see the actual runtime port.
// Ensure all default port derivations (browser/canvas) see the actual runtime port.
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
let configSnapshot = await readConfigFileSnapshot();
@@ -261,9 +263,24 @@ export async function startGatewayServer(
logPlugins,
});
let bonjourStop: (() => Promise<void>) | null = null;
let bridge: import("../infra/bridge/server.js").NodeBridgeServer | null = null;
const hasConnectedMobileNode = () => hasConnectedMobileNodeFromBridge(bridge);
const nodeRegistry = new NodeRegistry();
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
const nodeSubscriptions = createNodeSubscriptionManager();
const nodeSendEvent = (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => {
const payload = safeParseJson(opts.payloadJSON ?? null);
nodeRegistry.sendEvent(opts.nodeId, opts.event, payload);
};
const nodeSendToSession = (sessionKey: string, event: string, payload: unknown) =>
nodeSubscriptions.sendToSession(sessionKey, event, payload, nodeSendEvent);
const nodeSendToAllSubscribed = (event: string, payload: unknown) =>
nodeSubscriptions.sendToAllSubscribed(event, payload, nodeSendEvent);
const nodeSubscribe = nodeSubscriptions.subscribe;
const nodeUnsubscribe = nodeSubscriptions.unsubscribe;
const nodeUnsubscribeAll = nodeSubscriptions.unsubscribeAll;
const broadcastVoiceWakeChanged = (triggers: string[]) => {
broadcast("voicewake.changed", { triggers }, { dropIfSlow: true });
};
const hasMobileNodeConnected = () => hasConnectedMobileNode(nodeRegistry);
applyGatewayLaneConcurrency(cfgAtStart);
let cronState = buildGatewayCronService({
@@ -282,44 +299,18 @@ export async function startGatewayServer(
channelManager;
const machineDisplayName = await getMachineDisplayName();
const bridgeRuntime = await startGatewayBridgeRuntime({
cfg: cfgAtStart,
const discovery = await startGatewayDiscovery({
machineDisplayName,
port,
gatewayTls: gatewayTls.enabled
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
: undefined,
canvasHostEnabled,
canvasHost,
canvasRuntime,
allowCanvasHostInTests: opts.allowCanvasHostInTests,
machineDisplayName,
deps,
broadcast,
dedupe,
agentRunSeq,
chatRunState,
chatRunBuffers,
chatDeltaSentAt,
addChatRun,
removeChatRun,
chatAbortControllers,
getHealthCache,
refreshGatewayHealthSnapshot,
loadGatewayModelCatalog,
logBridge,
logCanvas,
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
logDiscovery,
});
bridge = bridgeRuntime.bridge;
const bridgeHost = bridgeRuntime.bridgeHost;
canvasHostServer = bridgeRuntime.canvasHostServer;
const nodePresenceTimers = bridgeRuntime.nodePresenceTimers;
bonjourStop = bridgeRuntime.bonjourStop;
const bridgeSendToSession = bridgeRuntime.bridgeSendToSession;
const bridgeSendToAllSubscribed = bridgeRuntime.bridgeSendToAllSubscribed;
const broadcastVoiceWakeChanged = bridgeRuntime.broadcastVoiceWakeChanged;
bonjourStop = discovery.bonjourStop;
setSkillsRemoteBridge(bridge);
setSkillsRemoteRegistry(nodeRegistry);
void primeRemoteSkillsCache();
registerSkillsChangeListener(() => {
const latest = loadConfig();
@@ -328,7 +319,7 @@ export async function startGatewayServer(
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
broadcast,
bridgeSendToAllSubscribed,
nodeSendToAllSubscribed,
getPresenceVersion,
getHealthVersion,
refreshGatewayHealthSnapshot,
@@ -340,13 +331,13 @@ export async function startGatewayServer(
chatDeltaSentAt,
removeChatRun,
agentRunSeq,
bridgeSendToSession,
nodeSendToSession,
});
const agentUnsub = onAgentEvent(
createAgentEventHandler({
broadcast,
bridgeSendToSession,
nodeSendToSession,
agentRunSeq,
chatRunState,
resolveSessionKeyForRun,
@@ -369,7 +360,7 @@ export async function startGatewayServer(
wss,
clients,
port,
bridgeHost: bridgeHost ?? undefined,
gatewayHost: bindHost ?? undefined,
canvasHostEnabled: Boolean(canvasHost),
canvasHostServerPort: canvasHostServer?.port ?? undefined,
resolvedAuth,
@@ -395,9 +386,13 @@ export async function startGatewayServer(
incrementPresenceVersion,
getHealthVersion,
broadcast,
bridge,
bridgeSendToSession,
hasConnectedMobileNode,
nodeSendToSession,
nodeSendToAllSubscribed,
nodeSubscribe,
nodeUnsubscribe,
nodeUnsubscribeAll,
hasConnectedMobileNode: hasMobileNodeConnected,
nodeRegistry,
agentRunSeq,
chatAbortControllers,
chatAbortedRuns: chatRunState.abortedRuns,
@@ -491,7 +486,6 @@ export async function startGatewayServer(
tailscaleCleanup,
canvasHost,
canvasHostServer,
bridge,
stopChannel,
pluginServices,
cron,

View File

@@ -2,10 +2,8 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import { WebSocket } from "ws";
import {
bridgeListConnected,
bridgeSendEvent,
bridgeStartCalls,
connectOk,
installGatewayTestHooks,
onceMessage,
@@ -13,6 +11,7 @@ import {
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
import { GATEWAY_CLIENT_MODES } from "../utils/message-channel.js";
installGatewayTestHooks();
@@ -116,42 +115,50 @@ describe("gateway server models + voicewake", () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const restoreHome = setTempHome(homeDir);
bridgeSendEvent.mockClear();
bridgeListConnected.mockReturnValue([{ nodeId: "n1" }]);
const { server, ws } = await startServerWithClient();
const { server, ws, port } = await startServerWithClient();
await connectOk(ws);
const startCall = bridgeStartCalls.at(-1);
expect(startCall).toBeTruthy();
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
const firstEventP = onceMessage<{ type: "event"; event: string; payload?: unknown }>(
nodeWs,
(o) => o.type === "event" && o.event === "voicewake.changed",
);
await connectOk(nodeWs, {
role: "node",
client: {
id: "n1",
version: "1.0.0",
platform: "ios",
mode: GATEWAY_CLIENT_MODES.NODE,
},
});
await startCall?.onAuthenticated?.({ nodeId: "n1" });
const first = bridgeSendEvent.mock.calls.find(
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
)?.[0] as { payloadJSON?: string | null } | undefined;
expect(first?.payloadJSON).toBeTruthy();
const firstPayload = JSON.parse(String(first?.payloadJSON)) as {
triggers?: unknown;
};
expect(firstPayload.triggers).toEqual(["clawd", "claude", "computer"]);
bridgeSendEvent.mockClear();
const first = await firstEventP;
expect(first.event).toBe("voicewake.changed");
expect((first.payload as { triggers?: unknown } | undefined)?.triggers).toEqual([
"clawd",
"claude",
"computer",
]);
const broadcastP = onceMessage<{ type: "event"; event: string; payload?: unknown }>(
nodeWs,
(o) => o.type === "event" && o.event === "voicewake.changed",
);
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
triggers: ["clawd", "computer"],
});
expect(setRes.ok).toBe(true);
const broadcast = bridgeSendEvent.mock.calls.find(
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
)?.[0] as { payloadJSON?: string | null } | undefined;
expect(broadcast?.payloadJSON).toBeTruthy();
const broadcastPayload = JSON.parse(String(broadcast?.payloadJSON)) as {
triggers?: unknown;
};
expect(broadcastPayload.triggers).toEqual(["clawd", "computer"]);
const broadcast = await broadcastP;
expect(broadcast.event).toBe("voicewake.changed");
expect((broadcast.payload as { triggers?: unknown } | undefined)?.triggers).toEqual([
"clawd",
"computer",
]);
nodeWs.close();
ws.close();
await server.close();
@@ -254,36 +261,4 @@ describe("gateway server models + voicewake", () => {
await server.close();
});
test("bridge RPC supports models.list and validates params", async () => {
piSdkMock.enabled = true;
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const startCall = bridgeStartCalls.at(-1);
expect(startCall).toBeTruthy();
const okRes = await startCall?.onRequest?.("n1", {
id: "1",
method: "models.list",
paramsJSON: "{}",
});
expect(okRes?.ok).toBe(true);
const okPayload = JSON.parse(String(okRes?.payloadJSON ?? "{}")) as {
models?: unknown;
};
expect(Array.isArray(okPayload.models)).toBe(true);
const badRes = await startCall?.onRequest?.("n1", {
id: "2",
method: "models.list",
paramsJSON: JSON.stringify({ extra: true }),
});
expect(badRes?.ok).toBe(false);
expect(badRes && "error" in badRes ? badRes.error.code : "").toBe("INVALID_REQUEST");
ws.close();
await server.close();
});
});

View File

@@ -1,440 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import {
agentCommand,
bridgeListConnected,
bridgeSendEvent,
bridgeStartCalls,
connectOk,
getFreePort,
installGatewayTestHooks,
onceMessage,
rpcReq,
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
const _decodeWsData = (data: unknown): string => {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf-8");
if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8");
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8");
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf-8");
}
return "";
};
async function waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await new Promise((r) => setTimeout(r, 5));
}
throw new Error("timeout waiting for condition");
}
installGatewayTestHooks();
describe("gateway server node/bridge", () => {
test("node.list includes connected unpaired nodes with capabilities + commands", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const reqRes = await rpcReq<{
status?: string;
request?: { requestId?: string };
}>(ws, "node.pair.request", {
nodeId: "p1",
displayName: "Paired",
platform: "iPadOS",
version: "dev",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas"],
commands: ["canvas.eval"],
remoteIp: "10.0.0.10",
});
expect(reqRes.ok).toBe(true);
const requestId = reqRes.payload?.request?.requestId;
expect(typeof requestId).toBe("string");
const approveRes = await rpcReq(ws, "node.pair.approve", { requestId });
expect(approveRes.ok).toBe(true);
bridgeListConnected.mockReturnValueOnce([
{
nodeId: "p1",
displayName: "Paired Live",
platform: "iPadOS",
version: "dev-live",
remoteIp: "10.0.0.11",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas", "camera"],
commands: ["canvas.snapshot", "canvas.eval"],
},
{
nodeId: "u1",
displayName: "Unpaired Live",
platform: "Android",
version: "dev",
remoteIp: "10.0.0.12",
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
caps: ["canvas"],
commands: ["canvas.eval"],
},
]);
const listRes = await rpcReq<{
nodes?: Array<{
nodeId: string;
paired?: boolean;
connected?: boolean;
caps?: string[];
commands?: string[];
displayName?: string;
remoteIp?: string;
}>;
}>(ws, "node.list", {});
expect(listRes.ok).toBe(true);
const nodes = listRes.payload?.nodes ?? [];
const pairedNode = nodes.find((n) => n.nodeId === "p1");
expect(pairedNode).toMatchObject({
nodeId: "p1",
paired: true,
connected: true,
displayName: "Paired Live",
remoteIp: "10.0.0.11",
});
expect(pairedNode?.caps?.slice().sort()).toEqual(["camera", "canvas"]);
expect(pairedNode?.commands?.slice().sort()).toEqual(["canvas.eval", "canvas.snapshot"]);
const unpairedNode = nodes.find((n) => n.nodeId === "u1");
expect(unpairedNode).toMatchObject({
nodeId: "u1",
paired: false,
connected: true,
displayName: "Unpaired Live",
});
expect(unpairedNode?.caps).toEqual(["canvas"]);
expect(unpairedNode?.commands).toEqual(["canvas.eval"]);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("emits presence updates for bridge connect/disconnect", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const before = bridgeStartCalls.length;
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const bridgeCall = bridgeStartCalls[before];
expect(bridgeCall).toBeTruthy();
const waitPresenceReason = async (reason: string) => {
await onceMessage(
ws,
(o) => {
if (o.type !== "event" || o.event !== "presence") return false;
const payload = o.payload as { presence?: unknown } | null;
const list = payload?.presence;
if (!Array.isArray(list)) return false;
return list.some(
(p) =>
typeof p === "object" &&
p !== null &&
(p as { instanceId?: unknown }).instanceId === "node-1" &&
(p as { reason?: unknown }).reason === reason,
);
},
3000,
);
};
const presenceConnectedP = waitPresenceReason("node-connected");
await bridgeCall?.onAuthenticated?.({
nodeId: "node-1",
displayName: "Node",
platform: "ios",
version: "1.0",
remoteIp: "10.0.0.10",
});
await presenceConnectedP;
const presenceDisconnectedP = waitPresenceReason("node-disconnected");
await bridgeCall?.onDisconnected?.({
nodeId: "node-1",
displayName: "Node",
platform: "ios",
version: "1.0",
remoteIp: "10.0.0.10",
});
await presenceDisconnectedP;
} finally {
try {
ws.close();
} catch {
/* ignore */
}
await server.close();
await fs.rm(homeDir, { recursive: true, force: true });
}
} finally {
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("bridge RPC chat.history returns session messages", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
await fs.writeFile(
path.join(dir, "sess-main.jsonl"),
[
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "hi" }],
timestamp: Date.now(),
},
}),
].join("\n"),
"utf-8",
);
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onRequest).toBeDefined();
const res = await bridgeCall?.onRequest?.("ios-node", {
id: "r1",
method: "chat.history",
paramsJSON: JSON.stringify({ sessionKey: "main" }),
});
expect(res?.ok).toBe(true);
const payload = JSON.parse(String((res as { payloadJSON?: string }).payloadJSON ?? "{}")) as {
sessionKey?: string;
sessionId?: string;
messages?: unknown[];
};
expect(payload.sessionKey).toBe("main");
expect(payload.sessionId).toBe("sess-main");
expect(Array.isArray(payload.messages)).toBe(true);
expect(payload.messages?.length).toBeGreaterThan(0);
await server.close();
});
test("bridge RPC sessions.list returns session rows", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onRequest).toBeDefined();
const res = await bridgeCall?.onRequest?.("ios-node", {
id: "r1",
method: "sessions.list",
paramsJSON: JSON.stringify({
includeGlobal: true,
includeUnknown: false,
limit: 50,
}),
});
expect(res?.ok).toBe(true);
const payload = JSON.parse(String((res as { payloadJSON?: string }).payloadJSON ?? "{}")) as {
sessions?: unknown[];
count?: number;
path?: string;
};
expect(Array.isArray(payload.sessions)).toBe(true);
expect(typeof payload.count).toBe("number");
expect(typeof payload.path).toBe("string");
const resolveRes = await bridgeCall?.onRequest?.("ios-node", {
id: "r2",
method: "sessions.resolve",
paramsJSON: JSON.stringify({ key: "main" }),
});
expect(resolveRes?.ok).toBe(true);
const resolvedPayload = JSON.parse(
String((resolveRes as { payloadJSON?: string }).payloadJSON ?? "{}"),
) as { key?: string };
expect(resolvedPayload.key).toBe("agent:main:main");
await server.close();
});
test("bridge chat events are pushed to subscribed nodes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onEvent).toBeDefined();
expect(bridgeCall?.onRequest).toBeDefined();
await bridgeCall?.onEvent?.("ios-node", {
event: "chat.subscribe",
payloadJSON: JSON.stringify({ sessionKey: "main" }),
});
bridgeSendEvent.mockClear();
const reqRes = await bridgeCall?.onRequest?.("ios-node", {
id: "s1",
method: "chat.send",
paramsJSON: JSON.stringify({
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-bridge-chat",
timeoutMs: 30_000,
}),
});
expect(reqRes?.ok).toBe(true);
emitAgentEvent({
runId: "sess-main",
seq: 1,
ts: Date.now(),
stream: "assistant",
data: { text: "hi from agent" },
});
emitAgentEvent({
runId: "sess-main",
seq: 2,
ts: Date.now(),
stream: "lifecycle",
data: { phase: "end" },
});
await new Promise((r) => setTimeout(r, 25));
expect(bridgeSendEvent).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",
event: "agent",
}),
);
expect(bridgeSendEvent).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",
event: "chat",
}),
);
await server.close();
});
test("bridge chat.send forwards image attachments to agentCommand", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onRequest).toBeDefined();
const spy = vi.mocked(agentCommand);
const callsBefore = spy.mock.calls.length;
const pngB64 =
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/woAAn8B9FD5fHAAAAAASUVORK5CYII=";
const reqRes = await bridgeCall?.onRequest?.("ios-node", {
id: "img-1",
method: "chat.send",
paramsJSON: JSON.stringify({
sessionKey: "main",
message: "see image",
idempotencyKey: "idem-bridge-img",
attachments: [
{
type: "image",
fileName: "dot.png",
content: `data:image/png;base64,${pngB64}`,
},
],
}),
});
expect(reqRes?.ok).toBe(true);
await waitFor(() => spy.mock.calls.length > callsBefore, 8000);
const call = spy.mock.calls.at(-1)?.[0] as
| { images?: Array<{ type: string; data: string; mimeType: string }> }
| undefined;
expect(call?.images).toEqual([{ type: "image", data: pngB64, mimeType: "image/png" }]);
await server.close();
});
});

View File

@@ -1,228 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test, vi } from "vitest";
import { emitAgentEvent } from "../infra/agent-events.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import {
agentCommand,
bridgeStartCalls,
connectOk,
getFreePort,
installGatewayTestHooks,
sessionStoreSaveDelayMs,
startGatewayServer,
startServerWithClient,
testState,
writeSessionStore,
} from "./test-helpers.js";
const decodeWsData = (data: unknown): string => {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf-8");
if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8");
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8");
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf-8");
}
return "";
};
async function _waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await new Promise((r) => setTimeout(r, 5));
}
throw new Error("timeout waiting for condition");
}
installGatewayTestHooks();
describe("gateway server node/bridge", () => {
test("bridge voice transcript defaults to main session", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
lastChannel: "whatsapp",
lastTo: "+1555",
},
},
});
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onEvent).toBeDefined();
const spy = vi.mocked(agentCommand);
const beforeCalls = spy.mock.calls.length;
await bridgeCall?.onEvent?.("ios-node", {
event: "voice.transcript",
payloadJSON: JSON.stringify({ text: "hello" }),
});
expect(spy.mock.calls.length).toBe(beforeCalls + 1);
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
expect(call.sessionId).toBe("sess-main");
expect(call.sessionKey).toBe("main");
expect(call.deliver).toBe(false);
expect(call.messageChannel).toBe("node");
const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as Record<
string,
{ sessionId?: string } | undefined
>;
expect(stored["agent:main:main"]?.sessionId).toBe("sess-main");
expect(stored["node-ios-node"]).toBeUndefined();
await server.close();
});
test("bridge voice transcript triggers chat events for webchat clients", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
const { server, ws } = await startServerWithClient();
await connectOk(ws, {
client: {
id: GATEWAY_CLIENT_NAMES.WEBCHAT,
version: "1.0.0",
platform: "test",
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
},
});
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onEvent).toBeDefined();
const isVoiceFinalChatEvent = (o: unknown) => {
if (!o || typeof o !== "object") return false;
const rec = o as Record<string, unknown>;
if (rec.type !== "event" || rec.event !== "chat") return false;
if (!rec.payload || typeof rec.payload !== "object") return false;
const payload = rec.payload as Record<string, unknown>;
const runId = typeof payload.runId === "string" ? payload.runId : "";
const state = typeof payload.state === "string" ? payload.state : "";
return runId.startsWith("voice-") && state === "final";
};
const finalChatP = new Promise<{
type: "event";
event: string;
payload?: unknown;
}>((resolve) => {
ws.on("message", (data) => {
const obj = JSON.parse(decodeWsData(data));
if (isVoiceFinalChatEvent(obj)) {
resolve(obj as never);
}
});
});
await bridgeCall?.onEvent?.("ios-node", {
event: "voice.transcript",
payloadJSON: JSON.stringify({ text: "hello", sessionKey: "main" }),
});
emitAgentEvent({
runId: "sess-main",
seq: 1,
ts: Date.now(),
stream: "assistant",
data: { text: "hi from agent" },
});
emitAgentEvent({
runId: "sess-main",
seq: 2,
ts: Date.now(),
stream: "lifecycle",
data: { phase: "end" },
});
const evt = await finalChatP;
const payload =
evt.payload && typeof evt.payload === "object"
? (evt.payload as Record<string, unknown>)
: {};
expect(payload.sessionKey).toBe("main");
const message =
payload.message && typeof payload.message === "object"
? (payload.message as Record<string, unknown>)
: {};
expect(message.role).toBe("assistant");
ws.close();
await server.close();
});
test("bridge chat.abort cancels while saving the session store", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
testState.sessionStorePath = path.join(dir, "sessions.json");
await writeSessionStore({
entries: {
main: {
sessionId: "sess-main",
updatedAt: Date.now(),
},
},
});
sessionStoreSaveDelayMs.value = 120;
const port = await getFreePort();
const server = await startGatewayServer(port);
const bridgeCall = bridgeStartCalls.at(-1);
expect(bridgeCall?.onRequest).toBeDefined();
const spy = vi.mocked(agentCommand);
spy.mockImplementationOnce(async (opts) => {
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
await new Promise<void>((resolve) => {
if (!signal) return resolve();
if (signal.aborted) return resolve();
signal.addEventListener("abort", () => resolve(), { once: true });
});
});
const sendP = bridgeCall?.onRequest?.("ios-node", {
id: "send-abort-save-bridge-1",
method: "chat.send",
paramsJSON: JSON.stringify({
sessionKey: "main",
message: "hello",
idempotencyKey: "idem-abort-save-bridge-1",
timeoutMs: 30_000,
}),
});
const abortRes = await bridgeCall?.onRequest?.("ios-node", {
id: "abort-save-bridge-1",
method: "chat.abort",
paramsJSON: JSON.stringify({
sessionKey: "main",
runId: "idem-abort-save-bridge-1",
}),
});
expect(abortRes?.ok).toBe(true);
const sendRes = await sendP;
expect(sendRes?.ok).toBe(true);
await server.close();
});
});

View File

@@ -1,343 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, test } from "vitest";
import {
bridgeInvoke,
bridgeListConnected,
connectOk,
installGatewayTestHooks,
onceMessage,
rpcReq,
startServerWithClient,
} from "./test-helpers.js";
const decodeWsData = (data: unknown): string => {
if (typeof data === "string") return data;
if (Buffer.isBuffer(data)) return data.toString("utf-8");
if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8");
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8");
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf-8");
}
return "";
};
async function _waitFor(condition: () => boolean, timeoutMs = 1500) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (condition()) return;
await new Promise((r) => setTimeout(r, 5));
}
throw new Error("timeout waiting for condition");
}
installGatewayTestHooks();
describe("gateway server node/bridge", () => {
test("supports gateway-owned node pairing methods and events", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
const { server, ws } = await startServerWithClient();
await connectOk(ws);
const requestedP = new Promise<{
type: "event";
event: string;
payload?: unknown;
}>((resolve) => {
ws.on("message", (data) => {
const obj = JSON.parse(decodeWsData(data)) as {
type?: string;
event?: string;
payload?: unknown;
};
if (obj.type === "event" && obj.event === "node.pair.requested") {
resolve(obj as never);
}
});
});
const res1 = await rpcReq(ws, "node.pair.request", {
nodeId: "n1",
displayName: "Node",
});
expect(res1.ok).toBe(true);
const req1 = (res1.payload as { request?: { requestId?: unknown } } | null)?.request;
const requestId = typeof req1?.requestId === "string" ? req1.requestId : "";
expect(requestId.length).toBeGreaterThan(0);
const evt1 = await requestedP;
expect(evt1.event).toBe("node.pair.requested");
expect((evt1.payload as { requestId?: unknown } | null)?.requestId).toBe(requestId);
const res2 = await rpcReq(ws, "node.pair.request", {
nodeId: "n1",
displayName: "Node",
});
expect(res2.ok).toBe(true);
await expect(
onceMessage(ws, (o) => o.type === "event" && o.event === "node.pair.requested", 200),
).rejects.toThrow();
const resolvedP = new Promise<{
type: "event";
event: string;
payload?: unknown;
}>((resolve) => {
ws.on("message", (data) => {
const obj = JSON.parse(decodeWsData(data)) as {
type?: string;
event?: string;
payload?: unknown;
};
if (obj.type === "event" && obj.event === "node.pair.resolved") {
resolve(obj as never);
}
});
});
const approveRes = await rpcReq(ws, "node.pair.approve", { requestId });
expect(approveRes.ok).toBe(true);
const tokenValue = (approveRes.payload as { node?: { token?: unknown } } | null)?.node?.token;
const token = typeof tokenValue === "string" ? tokenValue : "";
expect(token.length).toBeGreaterThan(0);
const evt2 = await resolvedP;
expect((evt2.payload as { requestId?: unknown } | null)?.requestId).toBe(requestId);
expect((evt2.payload as { decision?: unknown } | null)?.decision).toBe("approved");
const verifyRes = await rpcReq(ws, "node.pair.verify", {
nodeId: "n1",
token,
});
expect(verifyRes.ok).toBe(true);
expect((verifyRes.payload as { ok?: unknown } | null)?.ok).toBe(true);
const listRes = await rpcReq(ws, "node.pair.list", {});
expect(listRes.ok).toBe(true);
const paired = (listRes.payload as { paired?: unknown } | null)?.paired;
expect(Array.isArray(paired)).toBe(true);
expect((paired as Array<{ nodeId?: unknown }>).some((n) => n.nodeId === "n1")).toBe(true);
ws.close();
await server.close();
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
});
test("routes node.invoke to the node bridge", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
bridgeInvoke.mockResolvedValueOnce({
type: "invoke-res",
id: "inv-1",
ok: true,
payloadJSON: JSON.stringify({ result: "4" }),
error: null,
});
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const res = await rpcReq(ws, "node.invoke", {
nodeId: "ios-node",
command: "canvas.eval",
params: { javaScript: "2+2" },
timeoutMs: 123,
idempotencyKey: "idem-1",
});
expect(res.ok).toBe(true);
expect(bridgeInvoke).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",
command: "canvas.eval",
paramsJSON: JSON.stringify({ javaScript: "2+2" }),
timeoutMs: 123,
}),
);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("routes camera.list invoke to the node bridge", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
bridgeInvoke.mockResolvedValueOnce({
type: "invoke-res",
id: "inv-2",
ok: true,
payloadJSON: JSON.stringify({ devices: [] }),
error: null,
});
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const res = await rpcReq(ws, "node.invoke", {
nodeId: "ios-node",
command: "camera.list",
params: {},
idempotencyKey: "idem-2",
});
expect(res.ok).toBe(true);
expect(bridgeInvoke).toHaveBeenCalledWith(
expect.objectContaining({
nodeId: "ios-node",
command: "camera.list",
paramsJSON: JSON.stringify({}),
}),
);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("node.describe returns supported invoke commands for paired nodes", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
const reqRes = await rpcReq<{
status?: string;
request?: { requestId?: string };
}>(ws, "node.pair.request", {
nodeId: "n1",
displayName: "iPad",
platform: "iPadOS",
version: "dev",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
caps: ["canvas", "camera"],
commands: ["canvas.eval", "canvas.snapshot", "camera.snap"],
remoteIp: "10.0.0.10",
});
expect(reqRes.ok).toBe(true);
const requestId = reqRes.payload?.request?.requestId;
expect(typeof requestId).toBe("string");
const approveRes = await rpcReq(ws, "node.pair.approve", {
requestId,
});
expect(approveRes.ok).toBe(true);
const describeRes = await rpcReq<{ commands?: string[] }>(ws, "node.describe", {
nodeId: "n1",
});
expect(describeRes.ok).toBe(true);
expect(describeRes.payload?.commands).toEqual([
"camera.snap",
"canvas.eval",
"canvas.snapshot",
]);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
test("node.describe works for connected unpaired nodes (caps + commands)", async () => {
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
const prevHome = process.env.HOME;
process.env.HOME = homeDir;
try {
const { server, ws } = await startServerWithClient();
try {
await connectOk(ws);
bridgeListConnected.mockReturnValueOnce([
{
nodeId: "u1",
displayName: "Unpaired Live",
platform: "Android",
version: "dev-live",
remoteIp: "10.0.0.12",
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
caps: ["canvas", "camera", "canvas"],
commands: ["canvas.eval", "camera.snap", "canvas.eval"],
},
]);
const describeRes = await rpcReq<{
paired?: boolean;
connected?: boolean;
caps?: string[];
commands?: string[];
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string;
}>(ws, "node.describe", { nodeId: "u1" });
expect(describeRes.ok).toBe(true);
expect(describeRes.payload).toMatchObject({
paired: false,
connected: true,
deviceFamily: "Android",
modelIdentifier: "samsung SM-X926B",
remoteIp: "10.0.0.12",
});
expect(describeRes.payload?.caps).toEqual(["camera", "canvas"]);
expect(describeRes.payload?.commands).toEqual(["camera.snap", "canvas.eval"]);
} finally {
ws.close();
await server.close();
}
} finally {
await fs.rm(homeDir, { recursive: true, force: true });
if (prevHome === undefined) {
delete process.env.HOME;
} else {
process.env.HOME = prevHome;
}
}
});
});

View File

@@ -1,14 +1,12 @@
import type { BridgeTlsConfig } from "../../config/types.gateway.js";
import type { GatewayTlsConfig } from "../../config/types.gateway.js";
import {
type BridgeTlsRuntime,
loadBridgeTlsRuntime,
} from "../../infra/bridge/server/tls.js";
export type GatewayTlsRuntime = BridgeTlsRuntime;
type GatewayTlsRuntime,
loadGatewayTlsRuntime as loadGatewayTlsRuntimeConfig,
} from "../../infra/tls/gateway.js";
export async function loadGatewayTlsRuntime(
cfg: BridgeTlsConfig | undefined,
cfg: GatewayTlsConfig | undefined,
log?: { info?: (msg: string) => void; warn?: (msg: string) => void },
): Promise<GatewayTlsRuntime> {
return await loadBridgeTlsRuntime(cfg, log);
return await loadGatewayTlsRuntimeConfig(cfg, log);
}

View File

@@ -22,7 +22,7 @@ export function attachGatewayWsConnectionHandler(params: {
wss: WebSocketServer;
clients: Set<GatewayWsClient>;
port: number;
bridgeHost?: string;
gatewayHost?: string;
canvasHostEnabled: boolean;
canvasHostServerPort?: number;
resolvedAuth: ResolvedGatewayAuth;
@@ -46,7 +46,7 @@ export function attachGatewayWsConnectionHandler(params: {
wss,
clients,
port,
bridgeHost,
gatewayHost,
canvasHostEnabled,
canvasHostServerPort,
resolvedAuth,
@@ -76,7 +76,7 @@ export function attachGatewayWsConnectionHandler(params: {
const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
const canvasHostOverride =
bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::" ? bridgeHost : undefined;
gatewayHost && gatewayHost !== "0.0.0.0" && gatewayHost !== "::" ? gatewayHost : undefined;
const canvasHostUrl = resolveCanvasHostUrl({
canvasPort: canvasHostPortForWs,
hostOverride: canvasHostServerPort ? canvasHostOverride : undefined,
@@ -182,6 +182,13 @@ export function attachGatewayWsConnectionHandler(params: {
},
);
}
if (client?.connect?.role === "node") {
const context = buildRequestContext();
const nodeId = context.nodeRegistry.unregister(connId);
if (nodeId) {
context.nodeUnsubscribeAll(nodeId);
}
}
logWs("out", "close", {
connId,
code,

View File

@@ -13,12 +13,15 @@ import {
requestDevicePairing,
updatePairedDeviceMetadata,
} from "../../../infra/device-pairing.js";
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../../../infra/skills-remote.js";
import { loadVoiceWakeConfig } from "../../../infra/voicewake.js";
import { upsertPresence } from "../../../infra/system-presence.js";
import { rawDataToString } from "../../../infra/ws.js";
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
import type { ResolvedGatewayAuth } from "../../auth.js";
import { authorizeGatewayConnect } from "../../auth.js";
import { loadConfig } from "../../../config/config.js";
import { buildDeviceAuthPayload } from "../../device-auth.js";
import { isLoopbackAddress } from "../../net.js";
import {
@@ -478,6 +481,38 @@ export function attachGatewayWsMessageHandler(params: {
};
setClient(nextClient);
setHandshakeState("connected");
if (role === "node") {
const context = buildRequestContext();
const nodeSession = context.nodeRegistry.register(nextClient, { remoteIp: remoteAddr });
recordRemoteNodeInfo({
nodeId: nodeSession.nodeId,
displayName: nodeSession.displayName,
platform: nodeSession.platform,
deviceFamily: nodeSession.deviceFamily,
commands: nodeSession.commands,
remoteIp: nodeSession.remoteIp,
});
void refreshRemoteNodeBins({
nodeId: nodeSession.nodeId,
platform: nodeSession.platform,
deviceFamily: nodeSession.deviceFamily,
commands: nodeSession.commands,
cfg: loadConfig(),
}).catch((err) =>
logGateway.warn(`remote bin probe failed for ${nodeSession.nodeId}: ${formatForLog(err)}`),
);
void loadVoiceWakeConfig()
.then((cfg) => {
context.nodeRegistry.sendEvent(nodeSession.nodeId, "voicewake.changed", {
triggers: cfg.triggers,
});
})
.catch((err) =>
logGateway.warn(
`voicewake snapshot failed for ${nodeSession.nodeId}: ${formatForLog(err)}`,
),
);
}
logWs("out", "hello-ok", {
connId,

View File

@@ -13,34 +13,6 @@ import type { PluginRegistry } from "../plugins/registry.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
export type BridgeClientInfo = {
nodeId: string;
displayName?: string;
platform?: string;
version?: string;
remoteIp?: string;
deviceFamily?: string;
modelIdentifier?: string;
caps?: string[];
commands?: string[];
};
export type BridgeStartOpts = {
onAuthenticated?: (node: BridgeClientInfo) => Promise<void> | void;
onDisconnected?: (node: BridgeClientInfo) => Promise<void> | void;
onPairRequested?: (request: unknown) => Promise<void> | void;
onEvent?: (
nodeId: string,
evt: { event: string; payloadJSON?: string | null },
) => Promise<void> | void;
onRequest?: (
nodeId: string,
req: { id: string; method: string; paramsJSON?: string | null },
) => Promise<
| { ok: true; payloadJSON?: string | null }
| { ok: false; error: { code: string; message: string; details?: unknown } }
>;
};
type StubChannelOptions = {
id: ChannelPlugin["id"];
@@ -173,16 +145,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({
});
const hoisted = vi.hoisted(() => ({
bridgeStartCalls: [] as BridgeStartOpts[],
bridgeInvoke: vi.fn(async () => ({
type: "invoke-res",
id: "1",
ok: true,
payloadJSON: JSON.stringify({ ok: true }),
error: null,
})),
bridgeListConnected: vi.fn(() => [] as BridgeClientInfo[]),
bridgeSendEvent: vi.fn(),
testTailnetIPv4: { value: undefined as string | undefined },
piSdkMock: {
enabled: false,
@@ -232,10 +194,6 @@ export const setTestConfigRoot = (root: string) => {
process.env.CLAWDBOT_CONFIG_PATH = path.join(root, "clawdbot.json");
};
export const bridgeStartCalls = hoisted.bridgeStartCalls;
export const bridgeInvoke = hoisted.bridgeInvoke;
export const bridgeListConnected = hoisted.bridgeListConnected;
export const bridgeSendEvent = hoisted.bridgeSendEvent;
export const testTailnetIPv4 = hoisted.testTailnetIPv4;
export const piSdkMock = hoisted.piSdkMock;
export const cronIsolatedRun = hoisted.cronIsolatedRun;
@@ -282,19 +240,6 @@ vi.mock("@mariozechner/pi-coding-agent", async () => {
};
});
vi.mock("../infra/bridge/server.js", () => ({
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
bridgeStartCalls.push(opts);
return {
port: 18790,
close: async () => {},
listConnected: bridgeListConnected,
invoke: bridgeInvoke,
sendEvent: bridgeSendEvent,
};
}),
}));
vi.mock("../cron/isolated-agent.js", () => ({
runCronIsolatedAgentTurn: (...args: unknown[]) =>
(cronIsolatedRun as (...args: unknown[]) => unknown)(...args),

View File

@@ -247,6 +247,11 @@ export async function connectReq(
modelIdentifier?: string;
instanceId?: string;
};
role?: string;
scopes?: string[];
caps?: string[];
commands?: string[];
permissions?: Record<string, boolean>;
},
): Promise<ConnectResponse> {
const { randomUUID } = await import("node:crypto");
@@ -265,7 +270,11 @@ export async function connectReq(
platform: "test",
mode: GATEWAY_CLIENT_MODES.TEST,
},
caps: [],
caps: opts?.caps ?? [],
commands: opts?.commands ?? [],
permissions: opts?.permissions ?? undefined,
role: opts?.role,
scopes: opts?.scopes,
auth:
opts?.token || opts?.password
? {

View File

@@ -7,7 +7,7 @@ import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
describe("bonjour-discovery", () => {
it("discovers beacons on darwin across local + wide-area domains", async () => {
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
const studioInstance = "Peters Mac Studio Bridge";
const studioInstance = "Peters Mac Studio Gateway";
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
calls.push({ argv, timeoutMs: options.timeoutMs });
@@ -17,8 +17,8 @@ describe("bonjour-discovery", () => {
if (domain === "local.") {
return {
stdout: [
"Add 2 3 local. _clawdbot-bridge._tcp. Peter\\226\\128\\153s Mac Studio Bridge",
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
"Add 2 3 local. _clawdbot-gateway._tcp. Peter\\226\\128\\153s Mac Studio Gateway",
"Add 2 3 local. _clawdbot-gateway._tcp. Laptop Gateway",
"",
].join("\n"),
stderr: "",
@@ -30,7 +30,7 @@ describe("bonjour-discovery", () => {
if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
return {
stdout: [
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-bridge._tcp. Tailnet Bridge`,
`Add 2 3 ${WIDE_AREA_DISCOVERY_DOMAIN} _clawdbot-gateway._tcp. Tailnet Gateway`,
"",
].join("\n"),
stderr: "",
@@ -46,27 +46,26 @@ describe("bonjour-discovery", () => {
const host =
instance === studioInstance
? "studio.local"
: instance === "Laptop Bridge"
: instance === "Laptop Gateway"
? "laptop.local"
: "tailnet.local";
const tailnetDns = instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
const tailnetDns = instance === "Tailnet Gateway" ? "studio.tailnet.ts.net" : "";
const displayName =
instance === studioInstance
? "Peters\\032Mac\\032Studio"
: instance.replace(" Bridge", "");
: instance.replace(" Gateway", "");
const txtParts = [
"txtvers=1",
`displayName=${displayName}`,
`lanHost=${host}`,
"gatewayPort=18789",
"bridgePort=18790",
"sshPort=22",
tailnetDns ? `tailnetDns=${tailnetDns}` : null,
].filter((v): v is string => Boolean(v));
return {
stdout: [
`${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
`${instance}._clawdbot-gateway._tcp. can be reached at ${host}:18789`,
txtParts.join(" "),
"",
].join("\n"),
@@ -113,7 +112,7 @@ describe("bonjour-discovery", () => {
const domain = argv[3] ?? "";
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
return {
stdout: ["Add 2 3 local. _clawdbot-bridge._tcp. Studio Bridge", ""].join("\n"),
stdout: ["Add 2 3 local. _clawdbot-gateway._tcp. Studio Gateway", ""].join("\n"),
stderr: "",
code: 0,
signal: null,
@@ -124,8 +123,8 @@ describe("bonjour-discovery", () => {
if (argv[0] === "dns-sd" && argv[1] === "-L") {
return {
stdout: [
"Studio Bridge._clawdbot-bridge._tcp. can be reached at studio.local:18790",
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 bridgePort=18790 sshPort=22",
"Studio Gateway._clawdbot-gateway._tcp. can be reached at studio.local:18789",
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 sshPort=22",
"",
].join("\n"),
stderr: "",
@@ -154,7 +153,7 @@ describe("bonjour-discovery", () => {
expect(beacons).toEqual([
expect.objectContaining({
domain: "local.",
instanceName: "Studio Bridge",
instanceName: "Studio Gateway",
displayName: "Peters Mac Studio",
txt: expect.objectContaining({
displayName: "Peters Mac Studio",
@@ -204,10 +203,10 @@ describe("bonjour-discovery", () => {
if (
server === "100.123.224.76" &&
qtype === "PTR" &&
qname === "_clawdbot-bridge._tcp.clawdbot.internal"
qname === "_clawdbot-gateway._tcp.clawdbot.internal"
) {
return {
stdout: `studio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n`,
stdout: `studio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n`,
stderr: "",
code: 0,
signal: null,
@@ -218,10 +217,10 @@ describe("bonjour-discovery", () => {
if (
server === "100.123.224.76" &&
qtype === "SRV" &&
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
qname === "studio-gateway._clawdbot-gateway._tcp.clawdbot.internal"
) {
return {
stdout: `0 0 18790 studio.clawdbot.internal.\n`,
stdout: `0 0 18789 studio.clawdbot.internal.\n`,
stderr: "",
code: 0,
signal: null,
@@ -232,14 +231,13 @@ describe("bonjour-discovery", () => {
if (
server === "100.123.224.76" &&
qtype === "TXT" &&
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
qname === "studio-gateway._clawdbot-gateway._tcp.clawdbot.internal"
) {
return {
stdout: [
`"displayName=Studio"`,
`"transport=bridge"`,
`"bridgePort=18790"`,
`"gatewayPort=18789"`,
`"transport=gateway"`,
`"sshPort=22"`,
`"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net"`,
`"cliPath=/opt/homebrew/bin/clawdbot"`,
@@ -266,10 +264,10 @@ describe("bonjour-discovery", () => {
expect(beacons).toEqual([
expect.objectContaining({
domain: WIDE_AREA_DISCOVERY_DOMAIN,
instanceName: "studio-bridge",
instanceName: "studio-gateway",
displayName: "Studio",
host: "studio.clawdbot.internal",
port: 18790,
port: 18789,
tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net",
gatewayPort: 18789,
sshPort: 22,

View File

@@ -9,11 +9,10 @@ export type GatewayBonjourBeacon = {
port?: number;
lanHost?: string;
tailnetDns?: string;
bridgePort?: number;
gatewayPort?: number;
sshPort?: number;
bridgeTls?: boolean;
bridgeTlsFingerprintSha256?: string;
gatewayTls?: boolean;
gatewayTlsFingerprintSha256?: string;
cliPath?: string;
txt?: Record<string, string>;
};
@@ -165,9 +164,9 @@ function parseDnsSdBrowse(stdout: string): string[] {
const instances = new Set<string>();
for (const raw of stdout.split("\n")) {
const line = raw.trim();
if (!line || !line.includes("_clawdbot-bridge._tcp")) continue;
if (!line || !line.includes("_clawdbot-gateway._tcp")) continue;
if (!line.includes("Add")) continue;
const match = line.match(/_clawdbot-bridge\._tcp\.?\s+(.+)$/);
const match = line.match(/_clawdbot-gateway\._tcp\.?\s+(.+)$/);
if (match?.[1]) {
instances.add(decodeDnsSdEscapes(match[1].trim()));
}
@@ -205,14 +204,13 @@ function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjour
if (txt.lanHost) beacon.lanHost = txt.lanHost;
if (txt.tailnetDns) beacon.tailnetDns = txt.tailnetDns;
if (txt.cliPath) beacon.cliPath = txt.cliPath;
beacon.bridgePort = parseIntOrNull(txt.bridgePort);
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
beacon.sshPort = parseIntOrNull(txt.sshPort);
if (txt.bridgeTls) {
const raw = txt.bridgeTls.trim().toLowerCase();
beacon.bridgeTls = raw === "1" || raw === "true" || raw === "yes";
if (txt.gatewayTls) {
const raw = txt.gatewayTls.trim().toLowerCase();
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
}
if (txt.bridgeTlsSha256) beacon.bridgeTlsFingerprintSha256 = txt.bridgeTlsSha256;
if (txt.gatewayTlsSha256) beacon.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256;
if (!beacon.displayName) beacon.displayName = decodedInstanceName;
return beacon;
@@ -223,13 +221,13 @@ async function discoverViaDnsSd(
timeoutMs: number,
run: typeof runCommandWithTimeout,
): Promise<GatewayBonjourBeacon[]> {
const browse = await run(["dns-sd", "-B", "_clawdbot-bridge._tcp", domain], {
const browse = await run(["dns-sd", "-B", "_clawdbot-gateway._tcp", domain], {
timeoutMs,
});
const instances = parseDnsSdBrowse(browse.stdout);
const results: GatewayBonjourBeacon[] = [];
for (const instance of instances) {
const resolved = await run(["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain], {
const resolved = await run(["dns-sd", "-L", instance, "_clawdbot-gateway._tcp", domain], {
timeoutMs,
});
const parsed = parseDnsSdResolve(resolved.stdout, instance);
@@ -266,7 +264,7 @@ async function discoverWideAreaViaTailnetDns(
// Keep scans bounded: this is a fallback and should not block long.
ips = ips.slice(0, 40);
const probeName = `_clawdbot-bridge._tcp.${domain.replace(/\.$/, "")}`;
const probeName = `_clawdbot-gateway._tcp.${domain.replace(/\.$/, "")}`;
const concurrency = 6;
let nextIndex = 0;
@@ -310,7 +308,7 @@ async function discoverWideAreaViaTailnetDns(
if (budget <= 0) break;
const ptrName = ptr.trim().replace(/\.$/, "");
if (!ptrName) continue;
const instanceName = ptrName.replace(/\.?_clawdbot-bridge\._tcp\..*$/, "");
const instanceName = ptrName.replace(/\.?_clawdbot-gateway\._tcp\..*$/, "");
const srv = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "SRV"], {
timeoutMs: Math.max(1, Math.min(350, budget)),
@@ -343,12 +341,16 @@ async function discoverWideAreaViaTailnetDns(
host: srvParsed.host,
port: srvParsed.port,
txt: Object.keys(txtMap).length ? txtMap : undefined,
bridgePort: parseIntOrNull(txtMap.bridgePort),
gatewayPort: parseIntOrNull(txtMap.gatewayPort),
sshPort: parseIntOrNull(txtMap.sshPort),
tailnetDns: txtMap.tailnetDns || undefined,
cliPath: txtMap.cliPath || undefined,
};
if (txtMap.gatewayTls) {
const raw = txtMap.gatewayTls.trim().toLowerCase();
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
}
if (txtMap.gatewayTlsSha256) beacon.gatewayTlsFingerprintSha256 = txtMap.gatewayTlsSha256;
results.push(beacon);
}
@@ -363,9 +365,9 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
for (const raw of stdout.split("\n")) {
const line = raw.trimEnd();
if (!line) continue;
if (line.startsWith("=") && line.includes("_clawdbot-bridge._tcp")) {
if (line.startsWith("=") && line.includes("_clawdbot-gateway._tcp")) {
if (current) results.push(current);
const marker = " _clawdbot-bridge._tcp";
const marker = " _clawdbot-gateway._tcp";
const idx = line.indexOf(marker);
const left = idx >= 0 ? line.slice(0, idx).trim() : line;
const parts = left.split(/\s+/);
@@ -400,9 +402,13 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
if (txt.lanHost) current.lanHost = txt.lanHost;
if (txt.tailnetDns) current.tailnetDns = txt.tailnetDns;
if (txt.cliPath) current.cliPath = txt.cliPath;
current.bridgePort = parseIntOrNull(txt.bridgePort);
current.gatewayPort = parseIntOrNull(txt.gatewayPort);
current.sshPort = parseIntOrNull(txt.sshPort);
if (txt.gatewayTls) {
const raw = txt.gatewayTls.trim().toLowerCase();
current.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
}
if (txt.gatewayTlsSha256) current.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256;
}
}
@@ -415,7 +421,7 @@ async function discoverViaAvahi(
timeoutMs: number,
run: typeof runCommandWithTimeout,
): Promise<GatewayBonjourBeacon[]> {
const args = ["avahi-browse", "-rt", "_clawdbot-bridge._tcp"];
const args = ["avahi-browse", "-rt", "_clawdbot-gateway._tcp"];
if (domain && domain !== "local.") {
// avahi-browse wants a plain domain (no trailing dot)
args.push("-d", domain.replace(/\.$/, ""));

View File

@@ -110,24 +110,23 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
tailnetDns: "host.tailnet.ts.net",
cliPath: "/opt/homebrew/bin/clawdbot",
});
expect(createService).toHaveBeenCalledTimes(1);
const [bridgeCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
expect(bridgeCall?.[0]?.type).toBe("clawdbot-bridge");
expect(bridgeCall?.[0]?.port).toBe(18790);
expect(bridgeCall?.[0]?.domain).toBe("local");
expect(bridgeCall?.[0]?.hostname).toBe("test-host");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe("18790");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
expect(gatewayCall?.[0]?.type).toBe("clawdbot-gateway");
expect(gatewayCall?.[0]?.port).toBe(18789);
expect(gatewayCall?.[0]?.domain).toBe("local");
expect(gatewayCall?.[0]?.hostname).toBe("test-host");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayPort).toBe("18789");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
"/opt/homebrew/bin/clawdbot",
);
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe("bridge");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.transport).toBe("gateway");
// We don't await `advertise()`, but it should still be called for each service.
expect(advertise).toHaveBeenCalledTimes(1);
@@ -166,7 +165,6 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
// 1 service × 2 listeners
@@ -209,7 +207,6 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
await started.stop();
@@ -248,7 +245,6 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
// initial advertise attempt happens immediately
@@ -295,7 +291,6 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
expect(advertise).toHaveBeenCalledTimes(1);
@@ -328,14 +323,13 @@ describe("gateway bonjour advertiser", () => {
const started = await startGatewayBonjourAdvertiser({
gatewayPort: 18789,
sshPort: 2222,
bridgePort: 18790,
});
const [bridgeCall] = createService.mock.calls as Array<[ServiceCall]>;
expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdbot)");
expect(bridgeCall?.[0]?.domain).toBe("local");
expect(bridgeCall?.[0]?.hostname).toBe("Mac");
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
expect(gatewayCall?.[0]?.name).toBe("Mac (Clawdbot)");
expect(gatewayCall?.[0]?.domain).toBe("local");
expect(gatewayCall?.[0]?.hostname).toBe("Mac");
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
await started.stop();
});

Some files were not shown because too many files have changed in this diff Show More