diff --git a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift index ad7d010bd..8ff032af2 100644 --- a/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift +++ b/apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift @@ -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 diff --git a/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift b/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift deleted file mode 100644 index ea38c3ee1..000000000 --- a/apps/macos/Sources/Clawdbot/Bridge/BridgeConnectionHandler.swift +++ /dev/null @@ -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] = [:] - 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) 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) - } - } -} diff --git a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift b/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift deleted file mode 100644 index f45dcae20..000000000 --- a/apps/macos/Sources/Clawdbot/Bridge/BridgeServer.swift +++ /dev/null @@ -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] = [:] - private var chatSubscriptions: [String: Set] = [:] - private var gatewayPushTask: Task? - - 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() - 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() - 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 = ["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 diff --git a/apps/macos/Sources/Clawdbot/Bridge/PairedNodesStore.swift b/apps/macos/Sources/Clawdbot/Bridge/PairedNodesStore.swift deleted file mode 100644 index e78215635..000000000 --- a/apps/macos/Sources/Clawdbot/Bridge/PairedNodesStore.swift +++ /dev/null @@ -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]) - } -} diff --git a/apps/macos/Sources/Clawdbot/GatewayChannel.swift b/apps/macos/Sources/Clawdbot/GatewayChannel.swift index b777b6a17..ff893f1e0 100644 --- a/apps/macos/Sources/Clawdbot/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdbot/GatewayChannel.swift @@ -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? 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() } diff --git a/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift b/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift index f0e5a40a0..a064b788a 100644 --- a/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift +++ b/apps/macos/Sources/Clawdbot/GatewayDiscoveryMenu.swift @@ -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") } } diff --git a/apps/macos/Sources/Clawdbot/BridgeDiscoveryPreferences.swift b/apps/macos/Sources/Clawdbot/GatewayDiscoveryPreferences.swift similarity index 51% rename from apps/macos/Sources/Clawdbot/BridgeDiscoveryPreferences.swift rename to apps/macos/Sources/Clawdbot/GatewayDiscoveryPreferences.swift index 6147bbfd2..d725fdba5 100644 --- a/apps/macos/Sources/Clawdbot/BridgeDiscoveryPreferences.swift +++ b/apps/macos/Sources/Clawdbot/GatewayDiscoveryPreferences.swift @@ -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) } } } diff --git a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift index 3e9707f93..031f3dc22 100644 --- a/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift +++ b/apps/macos/Sources/Clawdbot/GatewayEndpointStore.swift @@ -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 = ["loopback", "tailnet", "lan", "auto"] + private static let supportedBindModes: Set = [ + "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 diff --git a/apps/macos/Sources/Clawdbot/GeneralSettings.swift b/apps/macos/Sources/Clawdbot/GeneralSettings.swift index 5f144864b..8210cacd1 100644 --- a/apps/macos/Sources/Clawdbot/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdbot/GeneralSettings.swift @@ -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 } diff --git a/apps/macos/Sources/Clawdbot/NodeMode/GatewayTLSPinning.swift b/apps/macos/Sources/Clawdbot/NodeMode/GatewayTLSPinning.swift new file mode 100644 index 000000000..69f36bdb8 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/NodeMode/GatewayTLSPinning.swift @@ -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) +} diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift deleted file mode 100644 index 22341d902..000000000 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgePairingClient.swift +++ /dev/null @@ -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) 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) 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 { 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 - } - } - } -} diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift deleted file mode 100644 index c739cd389..000000000 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeSession.swift +++ /dev/null @@ -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] = [:] - private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] - private var invokeTasks: [UUID: Task] = [:] - private var pingTask: Task? - 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 { - 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) 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) 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) 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 - { - 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, - 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", - ]) - }) - } -} diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeTLS.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeTLS.swift deleted file mode 100644 index cd6d32f29..000000000 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeBridgeTLS.swift +++ /dev/null @@ -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) -} diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeGatewaySession.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeGatewaySession.swift new file mode 100644 index 000000000..237410456 --- /dev/null +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeGatewaySession.swift @@ -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)") + } + } +} diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift index 7217d76a0..995d39a58 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeModeCoordinator.swift @@ -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? 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 - { - 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, - 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? - - 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) } } diff --git a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift index 6d4913d15..7fa50cc75 100644 --- a/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift +++ b/apps/macos/Sources/Clawdbot/NodeMode/MacNodeRuntime.swift @@ -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(_ 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", ]) } diff --git a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift index 2f6c3d0f5..6f7b43156 100644 --- a/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift +++ b/apps/macos/Sources/Clawdbot/NodePairingApprovalPrompter.swift @@ -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 ?? diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift index b84ca5e8d..5942be760 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Actions.swift @@ -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) { diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift index f180f37fb..1fb1e65a0 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Layout.swift @@ -63,7 +63,7 @@ extension OnboardingView { await self.ensureDefaultWorkspace() self.refreshAnthropicOAuthStatus() self.refreshBootstrapStatus() - self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID() + self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID() } } diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift index 69a1bbd43..e32252e81 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Pages.swift @@ -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 } diff --git a/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift b/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift index 33904a4f6..33726b7c4 100644 --- a/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift +++ b/apps/macos/Sources/Clawdbot/OnboardingView+Testing.swift @@ -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] diff --git a/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift b/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift index 9fced5a7d..4d953baac 100644 --- a/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift +++ b/apps/macos/Sources/ClawdbotDiscovery/GatewayDiscoveryModel.swift @@ -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) } } diff --git a/apps/macos/Sources/ClawdbotDiscovery/BridgeEndpointID.swift b/apps/macos/Sources/ClawdbotDiscovery/GatewayEndpointID.swift similarity index 96% rename from apps/macos/Sources/ClawdbotDiscovery/BridgeEndpointID.swift rename to apps/macos/Sources/ClawdbotDiscovery/GatewayEndpointID.swift index c89348122..4163f28fe 100644 --- a/apps/macos/Sources/ClawdbotDiscovery/BridgeEndpointID.swift +++ b/apps/macos/Sources/ClawdbotDiscovery/GatewayEndpointID.swift @@ -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, _): diff --git a/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift b/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift index f70e862a0..b0240f05c 100644 --- a/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift +++ b/apps/macos/Sources/ClawdbotDiscovery/WideAreaGatewayDiscovery.swift @@ -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) diff --git a/apps/macos/Tests/ClawdbotIPCTests/BridgeServerTests.swift b/apps/macos/Tests/ClawdbotIPCTests/BridgeServerTests.swift deleted file mode 100644 index 9b43d669b..000000000 --- a/apps/macos/Tests/ClawdbotIPCTests/BridgeServerTests.swift +++ /dev/null @@ -1,10 +0,0 @@ -import Testing -@testable import Clawdbot - -@Suite(.serialized) -struct BridgeServerTests { - @Test func bridgeServerExercisesPaths() async { - let server = BridgeServer() - await server.exerciseForTesting() - } -} diff --git a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift index 9ee97e22c..513117c72 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/ClawdbotConfigFileTests.swift @@ -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) } } diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift index 214cb7390..55c128a2f 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayDiscoveryModelTests.swift @@ -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)) } diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeDiscoveryTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeDiscoveryTests.swift deleted file mode 100644 index 3863f331c..000000000 --- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeDiscoveryTests.swift +++ /dev/null @@ -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 = { 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.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.size)) - } - } - if bindResult != 0 { - throw TestError(message: "bind failed") - } - - var resolved = sockaddr_in() - var length = socklen_t(MemoryLayout.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 -} diff --git a/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeSessionTests.swift b/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeSessionTests.swift deleted file mode 100644 index e7f2b8651..000000000 --- a/apps/macos/Tests/ClawdbotIPCTests/MacNodeBridgeSessionTests.swift +++ /dev/null @@ -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) - } - } -} diff --git a/apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift b/apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift index 4f081538b..95f1e003c 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/WideAreaGatewayDiscoveryTests.swift @@ -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") diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift index 8f9df3617..664013830 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/BonjourTypes.swift @@ -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 + ".") diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift index 1de76dbc6..89857da9d 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/SystemCommands.swift @@ -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 } } diff --git a/src/agents/bash-tools.exec.ts b/src/agents/bash-tools.exec.ts index fc5902e9f..cfaa50982 100644 --- a/src/agents/bash-tools.exec.ts +++ b/src/agents/bash-tools.exec.ts @@ -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 = { 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(), }; diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 4c8c85d50..1a2239cb9 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -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; diff --git a/src/cli/dns-cli.ts b/src/cli/dns-cli.ts index c500c8193..6dba47d1a 100644 --- a/src/cli/dns-cli.ts +++ b/src/cli/dns-cli.ts @@ -122,7 +122,7 @@ export function registerDnsCli(program: Command) { console.log( JSON.stringify( { - bridge: { bind: "tailnet" }, + gateway: { bind: "auto" }, discovery: { wideArea: { enabled: true } }, }, null, diff --git a/src/cli/gateway-cli.coverage.test.ts b/src/cli/gateway-cli.coverage.test.ts index 5e380e4b2..d6ea4a199 100644 --- a/src/cli/gateway-cli.coverage.test.ts +++ b/src/cli/gateway-cli.coverage.test.ts @@ -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, }, ]); diff --git a/src/cli/gateway-cli/discover.ts b/src/cli/gateway-cli/discover.ts index a0519bc5e..e0e4f2fa9 100644 --- a/src/cli/gateway-cli/discover.ts +++ b/src/cli/gateway-cli/discover.ts @@ -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; diff --git a/src/cli/gateway.sigterm.test.ts b/src/cli/gateway.sigterm.test.ts index 7ff77727b..0ead48360 100644 --- a/src/cli/gateway.sigterm.test.ts +++ b/src/cli/gateway.sigterm.test.ts @@ -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", }, diff --git a/src/cli/node-cli/daemon.ts b/src/cli/node-cli/daemon.ts index 3bebec983..e90dd2387 100644 --- a/src/cli/node-cli/daemon.ts +++ b/src/cli/node-cli/daemon.ts @@ -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, diff --git a/src/cli/node-cli/register.ts b/src/cli/node-cli/register.ts index 61a5bc268..6717d001a 100644 --- a/src/cli/node-cli/register.ts +++ b/src/cli/node-cli/register.ts @@ -30,17 +30,17 @@ export function registerNodeCli(program: Command) { node .command("start") .description("Start the headless node host (foreground)") - .option("--host ", "Gateway bridge host") - .option("--port ", "Gateway bridge port") - .option("--tls", "Use TLS for the bridge connection", false) + .option("--host ", "Gateway host") + .option("--port ", "Gateway port") + .option("--tls", "Use TLS for the gateway connection", false) .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") - .option("--node-id ", "Override node id (clears pairing token)") + .option("--node-id ", "Override node id") .option("--display-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 ", "Gateway bridge host") - .option("--port ", "Gateway bridge port") - .option("--tls", "Use TLS for the bridge connection", false) + .option("--host ", "Gateway host") + .option("--port ", "Gateway port") + .option("--tls", "Use TLS for the gateway connection", false) .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") - .option("--node-id ", "Override node id (clears pairing token)") + .option("--node-id ", "Override node id") .option("--display-name ", "Override node display name") .option("--runtime ", "Service runtime (node|bun). Default: node") .option("--force", "Reinstall/overwrite if already installed", false) diff --git a/src/cli/program/help.ts b/src/cli/program/help.ts index 12ca20920..37ad155d7 100644 --- a/src/cli/program/help.ts +++ b/src/cli/program/help.ts @@ -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 ", diff --git a/src/cli/service-cli.ts b/src/cli/service-cli.ts index 50fe1a087..6943f372f 100644 --- a/src/cli/service-cli.ts +++ b/src/cli/service-cli.ts @@ -107,9 +107,9 @@ export function registerServiceCli(program: Command) { node .command("install") .description("Install the node host service (launchd/systemd/schtasks)") - .option("--host ", "Gateway bridge host") - .option("--port ", "Gateway bridge port") - .option("--tls", "Use TLS for the bridge connection", false) + .option("--host ", "Gateway host") + .option("--port ", "Gateway port") + .option("--tls", "Use TLS for the Gateway connection", false) .option("--tls-fingerprint ", "Expected TLS certificate fingerprint (sha256)") .option("--node-id ", "Override node id (clears pairing token)") .option("--display-name ", "Override node display name") diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 4e9fb16aa..c7616f32f 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -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: [], }, diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index 57e0b1e08..2023cc37b 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -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(""); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index 0aecf6fe0..ca48ee0ba 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -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; const gateway = (cfg.gateway ?? {}) as Record; - const bridge = (cfg.bridge ?? {}) as Record; const discovery = (cfg.discovery ?? {}) as Record; const wideArea = (discovery.wideArea ?? {}) as Record; @@ -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 }, }; } diff --git a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts index 2af325033..92f0f74cb 100644 --- a/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts +++ b/src/config/config.legacy-config-detection.rejects-routing-allowfrom.test.ts @@ -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(); diff --git a/src/config/legacy.migrations.part-3.ts b/src/config/legacy.migrations.part-3.ts index aafe97c73..111053fc0 100644 --- a/src/config/legacy.migrations.part-3.ts +++ b/src/config/legacy.migrations.part-3.ts @@ -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 | 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"); }, }, ]; diff --git a/src/config/types.clawdbot.ts b/src/config/types.clawdbot.ts index 03339436b..aaa81f2a0 100644 --- a/src/config/types.clawdbot.ts +++ b/src/config/types.clawdbot.ts @@ -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; diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts index d35796361..06918c934 100644 --- a/src/config/types.gateway.ts +++ b/src/config/types.gateway.ts @@ -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; diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts index 253ac0148..5fa3b362e 100644 --- a/src/config/zod-schema.ts +++ b/src/config/zod-schema.ts @@ -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({ diff --git a/src/gateway/chat-abort.ts b/src/gateway/chat-abort.ts index 87946f0fc..5e673f0ad 100644 --- a/src/gateway/chat-abort.ts +++ b/src/gateway/chat-abort.ts @@ -41,7 +41,7 @@ export type ChatAbortOps = { ) => { sessionKey: string; clientRunId: string } | undefined; agentRunSeq: Map; 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( diff --git a/src/gateway/client.ts b/src/gateway/client.ts index 3e15acbd5..b7c19ac1b 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -40,9 +40,13 @@ export type GatewayClientOptions = { mode?: GatewayClientMode; role?: string; scopes?: string[]; + caps?: string[]; + commands?: string[]; + permissions?: Record; 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[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(); +} diff --git a/src/gateway/config-reload.ts b/src/gateway/config-reload.ts index 63f4cb6ad..9b023db77 100644 --- a/src/gateway/config-reload.ts +++ b/src/gateway/config-reload.ts @@ -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" }, ]; diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index 3456ade5b..8414ab4b6 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -191,7 +191,7 @@ async function isPortFree(port: number): Promise { } async function getFreeGatewayPort(): Promise { - // 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(); diff --git a/src/gateway/net.ts b/src/gateway/net.ts index 27e905392..f46c1592d 100644 --- a/src/gateway/net.ts +++ b/src/gateway/net.ts @@ -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 { const mode = bind ?? "loopback"; diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts new file mode 100644 index 000000000..1a19f9c65 --- /dev/null +++ b/src/gateway/node-registry.ts @@ -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; + connectedAtMs: number; +}; + +type PendingInvoke = { + nodeId: string; + command: string; + resolve: (value: NodeInvokeResult) => void; + reject: (err: Error) => void; + timer: ReturnType; +}; + +export type NodeInvokeResult = { + ok: boolean; + payload?: unknown; + payloadJSON?: string | null; + error?: { code?: string; message?: string } | null; +}; + +export class NodeRegistry { + private nodesById = new Map(); + private nodesByConn = new Map(); + private pendingInvokes = new Map(); + + 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 }).permissions === "object" + ? ((connect as { permissions?: Record }).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 { + 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((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); + } +} diff --git a/src/gateway/protocol/client-info.ts b/src/gateway/protocol/client-info.ts index b7501f74d..54720bcc5 100644 --- a/src/gateway/protocol/client-info.ts +++ b/src/gateway/protocol/client-info.ts @@ -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; diff --git a/src/gateway/protocol/index.ts b/src/gateway/protocol/index.ts index 084603846..caa958dc4 100644 --- a/src/gateway/protocol/index.ts +++ b/src/gateway/protocol/index.ts @@ -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(NodeRename export const validateNodeListParams = ajv.compile(NodeListParamsSchema); export const validateNodeDescribeParams = ajv.compile(NodeDescribeParamsSchema); export const validateNodeInvokeParams = ajv.compile(NodeInvokeParamsSchema); +export const validateNodeInvokeResultParams = ajv.compile( + NodeInvokeResultParamsSchema, +); +export const validateNodeEventParams = ajv.compile(NodeEventParamsSchema); export const validateSessionsListParams = ajv.compile(SessionsListParamsSchema); export const validateSessionsResolveParams = ajv.compile( SessionsResolveParamsSchema, @@ -422,6 +430,8 @@ export type { NodePairVerifyParams, NodeListParams, NodeInvokeParams, + NodeInvokeResultParams, + NodeEventParams, SessionsListParams, SessionsResolveParams, SessionsPatchParams, diff --git a/src/gateway/protocol/schema/frames.ts b/src/gateway/protocol/schema/frames.ts index 41279ab64..5ad1d113b 100644 --- a/src/gateway/protocol/schema/frames.ts +++ b/src/gateway/protocol/schema/frames.ts @@ -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( diff --git a/src/gateway/protocol/schema/nodes.ts b/src/gateway/protocol/schema/nodes.ts index fdfb47ed5..7762f30b8 100644 --- a/src/gateway/protocol/schema/nodes.ts +++ b/src/gateway/protocol/schema/nodes.ts @@ -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 }, +); diff --git a/src/gateway/protocol/schema/protocol-schemas.ts b/src/gateway/protocol/schema/protocol-schemas.ts index dc13885d0..cb995913b 100644 --- a/src/gateway/protocol/schema/protocol-schemas.ts +++ b/src/gateway/protocol/schema/protocol-schemas.ts @@ -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 = { NodeListParams: NodeListParamsSchema, NodeDescribeParams: NodeDescribeParamsSchema, NodeInvokeParams: NodeInvokeParamsSchema, + NodeInvokeResultParams: NodeInvokeResultParamsSchema, + NodeEventParams: NodeEventParamsSchema, + NodeInvokeRequestEvent: NodeInvokeRequestEventSchema, SessionsListParams: SessionsListParamsSchema, SessionsResolveParams: SessionsResolveParamsSchema, SessionsPatchParams: SessionsPatchParamsSchema, diff --git a/src/gateway/protocol/schema/types.ts b/src/gateway/protocol/schema/types.ts index 3a7266bea..67dffe836 100644 --- a/src/gateway/protocol/schema/types.ts +++ b/src/gateway/protocol/schema/types.ts @@ -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; export type NodeListParams = Static; export type NodeDescribeParams = Static; export type NodeInvokeParams = Static; +export type NodeInvokeResultParams = Static; +export type NodeEventParams = Static; export type SessionsListParams = Static; export type SessionsResolveParams = Static; export type SessionsPatchParams = Static; diff --git a/src/gateway/server-bridge-methods-chat.ts b/src/gateway/server-bridge-methods-chat.ts deleted file mode 100644 index 5bd8ab31d..000000000 --- a/src/gateway/server-bridge-methods-chat.ts +++ /dev/null @@ -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 = { - 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; - } -}; diff --git a/src/gateway/server-bridge-methods-config.ts b/src/gateway/server-bridge-methods-config.ts deleted file mode 100644 index 42e338724..000000000 --- a/src/gateway/server-bridge-methods-config.ts +++ /dev/null @@ -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>, -): { 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; - } -}; diff --git a/src/gateway/server-bridge-methods-sessions.ts b/src/gateway/server-bridge-methods-sessions.ts deleted file mode 100644 index 4522d5957..000000000 --- a/src/gateway/server-bridge-methods-sessions.ts +++ /dev/null @@ -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; - } -}; diff --git a/src/gateway/server-bridge-methods-system.ts b/src/gateway/server-bridge-methods-system.ts deleted file mode 100644 index a54ec5cb9..000000000 --- a/src/gateway/server-bridge-methods-system.ts +++ /dev/null @@ -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; - } -}; diff --git a/src/gateway/server-bridge-runtime.ts b/src/gateway/server-bridge-runtime.ts deleted file mode 100644 index 1cf9c07d0..000000000 --- a/src/gateway/server-bridge-runtime.ts +++ /dev/null @@ -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>; - bonjourStop: (() => Promise) | 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; - agentRunSeq: Map; - chatRunState: { abortedRuns: Map }; - chatRunBuffers: Map; - chatDeltaSentAt: Map; - addChatRun: (sessionId: string, entry: ChatRunEntry) => void; - removeChatRun: ( - sessionId: string, - clientRunId: string, - sessionKey?: string, - ) => ChatRunEntry | undefined; - chatAbortControllers: Map; - getHealthCache: () => HealthSummary | null; - refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise; - loadGatewayModelCatalog?: () => Promise; - logBridge: { info: (msg: string) => void; warn: (msg: string) => void }; - logCanvas: { warn: (msg: string) => void }; - logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void }; -}): Promise { - 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, - }; -} diff --git a/src/gateway/server-bridge.ts b/src/gateway/server-bridge.ts deleted file mode 100644 index 36ebc1066..000000000 --- a/src/gateway/server-bridge.ts +++ /dev/null @@ -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 => { - const method = req.method.trim(); - - const parseParams = (): Record => { - 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) - : {}; - }; - - 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 }; -} diff --git a/src/gateway/server-chat.ts b/src/gateway/server-chat.ts index 5f6d8f55d..cab2e94f7 100644 --- a/src/gateway/server-chat.ts +++ b/src/gateway/server-chat.ts @@ -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; 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")) { diff --git a/src/gateway/server-close.ts b/src/gateway/server-close.ts index 43d511005..ff1d98120 100644 --- a/src/gateway/server-close.ts +++ b/src/gateway/server-close.ts @@ -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) | null; canvasHost: CanvasHostHandler | null; canvasHostServer: CanvasHostServer | null; - bridge: NodeBridgeServer | null; stopChannel: (name: ChannelId, accountId?: string) => Promise; 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); } diff --git a/src/gateway/server-discovery-runtime.ts b/src/gateway/server-discovery-runtime.ts index 1ca9863d5..f7d16f3b2 100644 --- a/src/gateway/server-discovery-runtime.ts +++ b/src/gateway/server-discovery-runtime.ts @@ -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(), diff --git a/src/gateway/server-maintenance.ts b/src/gateway/server-maintenance.ts index 6879e2a07..499521b84 100644 --- a/src/gateway/server-maintenance.ts +++ b/src/gateway/server-maintenance.ts @@ -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; @@ -36,7 +36,7 @@ export function startGatewayMaintenanceTimers(params: { sessionKey?: string, ) => ChatRunEntry | undefined; agentRunSeq: Map; - bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void; + nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void; }): { tickInterval: ReturnType; healthInterval: ReturnType; @@ -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" }, ); diff --git a/src/gateway/server-methods-list.ts b/src/gateway/server-methods-list.ts index 2ac5101b9..e97b245ca 100644 --- a/src/gateway/server-methods-list.ts +++ b/src/gateway/server-methods-list.ts @@ -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", diff --git a/src/gateway/server-methods.ts b/src/gateway/server-methods.ts index c16de5f37..3a6178017 100644 --- a/src/gateway/server-methods.ts +++ b/src/gateway/server-methods.ts @@ -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}`); } diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 7c2b5c581..7ba466e9f 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -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 }); }, diff --git a/src/gateway/server-methods/exec-approvals.ts b/src/gateway/server-methods/exec-approvals.ts index 73e6de660..71821b7d0 100644 --- a/src/gateway/server-methods/exec-approvals.ts +++ b/src/gateway/server-methods/exec-approvals.ts @@ -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( diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 971220d9d..16eda4c9a 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -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([...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); + }); + }, }; diff --git a/src/gateway/server-methods/types.ts b/src/gateway/server-methods/types.ts index 6c8862451..5e048e0d2 100644 --- a/src/gateway/server-methods/types.ts +++ b/src/gateway/server-methods/types.ts @@ -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> | 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; chatAbortControllers: Map; chatAbortedRuns: Map; diff --git a/src/gateway/server-mobile-nodes.ts b/src/gateway/server-mobile-nodes.ts index 126870b49..c9271f15c 100644 --- a/src/gateway/server-mobile-nodes.ts +++ b/src/gateway/server-mobile-nodes.ts @@ -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)); } diff --git a/src/gateway/server-node-bridge.ts b/src/gateway/server-node-bridge.ts deleted file mode 100644 index 3312e637f..000000000 --- a/src/gateway/server-node-bridge.ts +++ /dev/null @@ -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>; -}; - -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; - handleBridgeEvent: (nodeId: string, evt: BridgeEvent) => Promise | void; - logBridge: { info: (msg: string) => void; warn: (msg: string) => void }; -}): Promise { - const nodePresenceTimers = new Map>(); - - 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 }; -} diff --git a/src/gateway/server-bridge-types.ts b/src/gateway/server-node-events-types.ts similarity index 59% rename from src/gateway/server-bridge-types.ts rename to src/gateway/server-node-events-types.ts index cf7866a75..90595eabc 100644 --- a/src/gateway/server-bridge-types.ts +++ b/src/gateway/server-node-events-types.ts @@ -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; loadGatewayModelCatalog: () => Promise; - 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; - -export type BridgeMethodHandler = ( - ctx: BridgeHandlersContext, - nodeId: string, - method: string, - params: BridgeRequestParams, -) => Promise; diff --git a/src/gateway/server-bridge-events.test.ts b/src/gateway/server-node-events.test.ts similarity index 85% rename from src/gateway/server-bridge-events.test.ts rename to src/gateway/server-node-events.test.ts index cb901b343..25372dabb 100644 --- a/src/gateway/server-bridge-events.test.ts +++ b/src/gateway/server-node-events.test.ts @@ -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", diff --git a/src/gateway/server-bridge-events.ts b/src/gateway/server-node-events.ts similarity index 94% rename from src/gateway/server-bridge-events.ts rename to src/gateway/server-node-events.ts index 29ef76383..2c12bec39 100644 --- a/src/gateway/server-bridge-events.ts +++ b/src/gateway/server-node-events.ts @@ -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) : {}; 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) : {}; const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : ""; if (!sessionKey) return; - ctx.bridgeUnsubscribe(nodeId, sessionKey); + ctx.nodeUnsubscribe(nodeId, sessionKey); return; } case "exec.started": diff --git a/src/gateway/server-bridge-subscriptions.test.ts b/src/gateway/server-node-subscriptions.test.ts similarity index 82% rename from src/gateway/server-bridge-subscriptions.test.ts rename to src/gateway/server-node-subscriptions.test.ts index 5bc30db2b..9af7a9630 100644 --- a/src/gateway/server-bridge-subscriptions.test.ts +++ b/src/gateway/server-node-subscriptions.test.ts @@ -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}`); diff --git a/src/gateway/server-bridge-subscriptions.ts b/src/gateway/server-node-subscriptions.ts similarity index 60% rename from src/gateway/server-bridge-subscriptions.ts rename to src/gateway/server-node-subscriptions.ts index da34610d6..33eecb0be 100644 --- a/src/gateway/server-bridge-subscriptions.ts +++ b/src/gateway/server-node-subscriptions.ts @@ -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>(); - const bridgeSessionSubscribers = new Map>(); +export function createNodeSubscriptionManager(): NodeSubscriptionManager { + const nodeSubscriptions = new Map>(); + const sessionSubscribers = new Map>(); 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(); - 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(); - 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 { diff --git a/src/gateway/server-runtime-config.ts b/src/gateway/server-runtime-config.ts index 4696f41be..946e4b19c 100644 --- a/src/gateway/server-runtime-config.ts +++ b/src/gateway/server-runtime-config.ts @@ -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; port: number; - bind?: BridgeBindMode; + bind?: GatewayBindMode; host?: string; controlUiEnabled?: boolean; openAiChatCompletionsEnabled?: boolean; diff --git a/src/gateway/server-ws-runtime.ts b/src/gateway/server-ws-runtime.ts index a20cf85e7..6f12748e7 100644 --- a/src/gateway/server-ws-runtime.ts +++ b/src/gateway/server-ws-runtime.ts @@ -9,7 +9,7 @@ export function attachGatewayWsHandlers(params: { wss: WebSocketServer; clients: Set; 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, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index b67ae15e2..9d977143a 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -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 { - // 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) | null = null; - let bridge: import("../infra/bridge/server.js").NodeBridgeServer | null = null; - - const hasConnectedMobileNode = () => hasConnectedMobileNodeFromBridge(bridge); + const nodeRegistry = new NodeRegistry(); + const nodePresenceTimers = new Map>(); + 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, diff --git a/src/gateway/server.models-voicewake.test.ts b/src/gateway/server.models-voicewake.test.ts index 2a4099a71..f3408e478 100644 --- a/src/gateway/server.models-voicewake.test.ts +++ b/src/gateway/server.models-voicewake.test.ts @@ -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((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(); - }); }); diff --git a/src/gateway/server.node-bridge.gateway-server-node-bridge-b.test.ts b/src/gateway/server.node-bridge.gateway-server-node-bridge-b.test.ts deleted file mode 100644 index c06c23d61..000000000 --- a/src/gateway/server.node-bridge.gateway-server-node-bridge-b.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/src/gateway/server.node-bridge.gateway-server-node-bridge-c.test.ts b/src/gateway/server.node-bridge.gateway-server-node-bridge-c.test.ts deleted file mode 100644 index d4c87a727..000000000 --- a/src/gateway/server.node-bridge.gateway-server-node-bridge-c.test.ts +++ /dev/null @@ -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; - 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; - if (rec.type !== "event" || rec.event !== "chat") return false; - if (!rec.payload || typeof rec.payload !== "object") return false; - const payload = rec.payload as Record; - 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) - : {}; - expect(payload.sessionKey).toBe("main"); - const message = - payload.message && typeof payload.message === "object" - ? (payload.message as Record) - : {}; - 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((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(); - }); -}); diff --git a/src/gateway/server.node-bridge.gateway-server-node-bridge.test.ts b/src/gateway/server.node-bridge.gateway-server-node-bridge.test.ts deleted file mode 100644 index ef52d8d6e..000000000 --- a/src/gateway/server.node-bridge.gateway-server-node-bridge.test.ts +++ /dev/null @@ -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; - } - } - }); -}); diff --git a/src/gateway/server/tls.ts b/src/gateway/server/tls.ts index 48daa90ac..9f82aad51 100644 --- a/src/gateway/server/tls.ts +++ b/src/gateway/server/tls.ts @@ -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 { - return await loadBridgeTlsRuntime(cfg, log); + return await loadGatewayTlsRuntimeConfig(cfg, log); } diff --git a/src/gateway/server/ws-connection.ts b/src/gateway/server/ws-connection.ts index 365b20474..8b30ef53b 100644 --- a/src/gateway/server/ws-connection.ts +++ b/src/gateway/server/ws-connection.ts @@ -22,7 +22,7 @@ export function attachGatewayWsConnectionHandler(params: { wss: WebSocketServer; clients: Set; 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, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index c0aee20c8..b4f9495d3 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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, diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 64bc7f28f..1cd8dc082 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -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; - onDisconnected?: (node: BridgeClientInfo) => Promise | void; - onPairRequested?: (request: unknown) => Promise | void; - onEvent?: ( - nodeId: string, - evt: { event: string; payloadJSON?: string | null }, - ) => Promise | 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), diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 796222af8..f6c4771cd 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -247,6 +247,11 @@ export async function connectReq( modelIdentifier?: string; instanceId?: string; }; + role?: string; + scopes?: string[]; + caps?: string[]; + commands?: string[]; + permissions?: Record; }, ): Promise { 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 ? { diff --git a/src/infra/bonjour-discovery.test.ts b/src/infra/bonjour-discovery.test.ts index 952764f0b..a151729fc 100644 --- a/src/infra/bonjour-discovery.test.ts +++ b/src/infra/bonjour-discovery.test.ts @@ -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 = "Peter’s Mac Studio Bridge"; + const studioInstance = "Peter’s 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 ? "Peter’s\\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: "Peter’s Mac Studio", txt: expect.objectContaining({ displayName: "Peter’s 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, diff --git a/src/infra/bonjour-discovery.ts b/src/infra/bonjour-discovery.ts index 60c175914..a93ba2478 100644 --- a/src/infra/bonjour-discovery.ts +++ b/src/infra/bonjour-discovery.ts @@ -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; }; @@ -165,9 +164,9 @@ function parseDnsSdBrowse(stdout: string): string[] { const instances = new Set(); 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 { - 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 { - 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(/\.$/, "")); diff --git a/src/infra/bonjour.test.ts b/src/infra/bonjour.test.ts index aa5ab3d7e..e3c35ab99 100644 --- a/src/infra/bonjour.test.ts +++ b/src/infra/bonjour.test.ts @@ -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]>; - 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)?.lanHost).toBe("test-host.local"); - expect((bridgeCall?.[0]?.txt as Record)?.bridgePort).toBe("18790"); - expect((bridgeCall?.[0]?.txt as Record)?.sshPort).toBe("2222"); - expect((bridgeCall?.[0]?.txt as Record)?.cliPath).toBe( + const [gatewayCall] = createService.mock.calls as Array<[Record]>; + 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)?.lanHost).toBe("test-host.local"); + expect((gatewayCall?.[0]?.txt as Record)?.gatewayPort).toBe("18789"); + expect((gatewayCall?.[0]?.txt as Record)?.sshPort).toBe("2222"); + expect((gatewayCall?.[0]?.txt as Record)?.cliPath).toBe( "/opt/homebrew/bin/clawdbot", ); - expect((bridgeCall?.[0]?.txt as Record)?.transport).toBe("bridge"); + expect((gatewayCall?.[0]?.txt as Record)?.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)?.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)?.lanHost).toBe("Mac.local"); await started.stop(); }); diff --git a/src/infra/bonjour.ts b/src/infra/bonjour.ts index 3eae92732..dd50ab3c5 100644 --- a/src/infra/bonjour.ts +++ b/src/infra/bonjour.ts @@ -17,10 +17,7 @@ export type GatewayBonjourAdvertiseOpts = { sshPort?: number; gatewayTlsEnabled?: boolean; gatewayTlsFingerprintSha256?: string; - bridgePort?: number; canvasPort?: number; - bridgeTlsEnabled?: boolean; - bridgeTlsFingerprintSha256?: string; tailnetDns?: string; cliPath?: string; }; @@ -106,9 +103,6 @@ export async function startGatewayBonjourAdvertiser( lanHost: `${hostname}.local`, displayName, }; - if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) { - txtBase.bridgePort = String(opts.bridgePort); - } if (opts.gatewayTlsEnabled) { txtBase.gatewayTls = "1"; if (opts.gatewayTlsFingerprintSha256) { @@ -118,12 +112,6 @@ export async function startGatewayBonjourAdvertiser( if (typeof opts.canvasPort === "number" && opts.canvasPort > 0) { txtBase.canvasPort = String(opts.canvasPort); } - if (opts.bridgeTlsEnabled) { - txtBase.bridgeTls = "1"; - if (opts.bridgeTlsFingerprintSha256) { - txtBase.bridgeTlsSha256 = opts.bridgeTlsFingerprintSha256; - } - } if (typeof opts.tailnetDns === "string" && opts.tailnetDns.trim()) { txtBase.tailnetDns = opts.tailnetDns.trim(); } @@ -133,26 +121,23 @@ export async function startGatewayBonjourAdvertiser( const services: Array<{ label: string; svc: BonjourService }> = []; - // Bridge beacon (used by macOS/iOS/Android nodes and the mac app onboarding flow). - if (typeof opts.bridgePort === "number" && opts.bridgePort > 0) { - const bridge = responder.createService({ - name: safeServiceName(instanceName), - type: "clawdbot-bridge", - protocol: Protocol.TCP, - port: opts.bridgePort, - domain: "local", - hostname, - txt: { - ...txtBase, - sshPort: String(opts.sshPort ?? 22), - transport: "bridge", - }, - }); - services.push({ - label: "bridge", - svc: bridge as unknown as BonjourService, - }); - } + const gateway = responder.createService({ + name: safeServiceName(instanceName), + type: "clawdbot-gateway", + protocol: Protocol.TCP, + port: opts.gatewayPort, + domain: "local", + hostname, + txt: { + ...txtBase, + sshPort: String(opts.sshPort ?? 22), + transport: "gateway", + }, + }); + services.push({ + label: "gateway", + svc: gateway as unknown as BonjourService, + }); let ciaoCancellationRejectionHandler: (() => void) | undefined; if (services.length > 0) { @@ -164,9 +149,7 @@ export async function startGatewayBonjourAdvertiser( logDebug( `bonjour: starting (hostname=${hostname}, instance=${JSON.stringify( safeServiceName(instanceName), - )}, gatewayPort=${opts.gatewayPort}, bridgePort=${opts.bridgePort ?? 0}, sshPort=${ - opts.sshPort ?? 22 - })`, + )}, gatewayPort=${opts.gatewayPort}, sshPort=${opts.sshPort ?? 22})`, ); for (const { label, svc } of services) { diff --git a/src/infra/bridge/server.enables-keepalive-sockets.test.ts b/src/infra/bridge/server.enables-keepalive-sockets.test.ts deleted file mode 100644 index 01491b8e1..000000000 --- a/src/infra/bridge/server.enables-keepalive-sockets.test.ts +++ /dev/null @@ -1,420 +0,0 @@ -import fs from "node:fs/promises"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; - -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; - -import { pollUntil } from "../../../test/helpers/poll.js"; -import { approveNodePairing, listNodePairing } from "../node-pairing.js"; -import { configureNodeBridgeSocket, startNodeBridgeServer } from "./server.js"; - -const pairingTimeoutMs = process.platform === "win32" ? 8000 : 3000; -const suiteTimeoutMs = process.platform === "win32" ? 20000 : 10000; - -function createLineReader(socket: net.Socket) { - let buffer = ""; - const pending: Array<(line: string) => void> = []; - - const flush = () => { - while (pending.length > 0) { - const idx = buffer.indexOf("\n"); - if (idx === -1) return; - const line = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - const resolve = pending.shift(); - resolve?.(line); - } - }; - - socket.on("data", (chunk) => { - buffer += chunk.toString("utf8"); - flush(); - }); - - const readLine = async () => { - flush(); - const idx = buffer.indexOf("\n"); - if (idx !== -1) { - const line = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - return line; - } - return await new Promise((resolve) => pending.push(resolve)); - }; - - return readLine; -} - -function sendLine(socket: net.Socket, obj: unknown) { - socket.write(`${JSON.stringify(obj)}\n`); -} - -async function waitForSocketConnect(socket: net.Socket) { - if (!socket.connecting) return; - await new Promise((resolve, reject) => { - socket.once("connect", resolve); - socket.once("error", reject); - }); -} - -describe("node bridge server", { timeout: suiteTimeoutMs }, () => { - let baseDir = ""; - - const pickNonLoopbackIPv4 = () => { - const ifaces = os.networkInterfaces(); - for (const entries of Object.values(ifaces)) { - for (const info of entries ?? []) { - if (info.family === "IPv4" && info.internal === false) return info.address; - } - } - return null; - }; - - beforeAll(async () => { - process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS = "1"; - baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bridge-test-")); - }); - - afterAll(async () => { - await fs.rm(baseDir, { recursive: true, force: true }); - delete process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS; - }); - - it("enables keepalive on sockets", () => { - const socket = { - setNoDelay: vi.fn(), - setKeepAlive: vi.fn(), - }; - configureNodeBridgeSocket(socket); - expect(socket.setNoDelay).toHaveBeenCalledWith(true); - expect(socket.setKeepAlive).toHaveBeenCalledWith(true, 15_000); - }); - - it("rejects hello when not paired", async () => { - const server = await startNodeBridgeServer({ - host: "127.0.0.1", - port: 0, - pairingBaseDir: baseDir, - }); - - const socket = net.connect({ host: "127.0.0.1", port: server.port }); - const readLine = createLineReader(socket); - sendLine(socket, { type: "hello", nodeId: "n1" }); - const line = await readLine(); - const msg = JSON.parse(line) as { type: string; code?: string }; - expect(msg.type).toBe("error"); - expect(msg.code).toBe("NOT_PAIRED"); - socket.destroy(); - await server.close(); - }); - - it("does not add a loopback listener when bind already includes loopback", async () => { - const loopback = await startNodeBridgeServer({ - host: "127.0.0.1", - port: 0, - pairingBaseDir: baseDir, - }); - expect(loopback.listeners).toHaveLength(1); - expect(loopback.listeners[0]?.host).toBe("127.0.0.1"); - await loopback.close(); - - const wildcard = await startNodeBridgeServer({ - host: "0.0.0.0", - port: 0, - pairingBaseDir: baseDir, - }); - expect(wildcard.listeners).toHaveLength(1); - expect(wildcard.listeners[0]?.host).toBe("0.0.0.0"); - await wildcard.close(); - }); - - it("also listens on loopback when bound to a non-loopback host", async () => { - const host = pickNonLoopbackIPv4(); - if (!host) return; - - const server = await startNodeBridgeServer({ - host, - port: 0, - pairingBaseDir: baseDir, - }); - - const hosts = server.listeners.map((l) => l.host).sort(); - expect(hosts).toContain(host); - const hasLoopback = hosts.includes("127.0.0.1"); - if (hasLoopback) { - const socket = net.connect({ host: "127.0.0.1", port: server.port }); - await new Promise((resolve, reject) => { - socket.once("connect", resolve); - socket.once("error", reject); - }); - const readLine = createLineReader(socket); - sendLine(socket, { type: "hello", nodeId: "n-loopback" }); - const line = await readLine(); - const msg = JSON.parse(line) as { type: string; code?: string }; - expect(msg.type).toBe("error"); - expect(msg.code).toBe("NOT_PAIRED"); - socket.destroy(); - } - await server.close(); - }); - - it("pairs after approval and then accepts hello", async () => { - const server = await startNodeBridgeServer({ - host: "127.0.0.1", - port: 0, - pairingBaseDir: baseDir, - }); - - const socket = net.connect({ host: "127.0.0.1", port: server.port }); - await waitForSocketConnect(socket); - const readLine = createLineReader(socket); - sendLine(socket, { type: "pair-request", nodeId: "n2", platform: "ios" }); - - // Approve the pending request from the gateway side. - const pending = await pollUntil( - async () => { - const list = await listNodePairing(baseDir); - return list.pending.find((p) => p.nodeId === "n2"); - }, - { timeoutMs: pairingTimeoutMs }, - ); - expect(pending).toBeTruthy(); - if (!pending) throw new Error("expected a pending request"); - await approveNodePairing(pending.requestId, baseDir); - - const line1 = JSON.parse(await readLine()) as { - type: string; - token?: string; - }; - expect(line1.type).toBe("pair-ok"); - expect(typeof line1.token).toBe("string"); - if (!line1.token) throw new Error("expected pair-ok token"); - const token = line1.token; - - const line2 = JSON.parse(await readLine()) as { type: string }; - expect(line2.type).toBe("hello-ok"); - - socket.destroy(); - - const socket2 = net.connect({ host: "127.0.0.1", port: server.port }); - await waitForSocketConnect(socket2); - const readLine2 = createLineReader(socket2); - sendLine(socket2, { type: "hello", nodeId: "n2", token }); - const line3 = JSON.parse(await readLine2()) as { type: string }; - expect(line3.type).toBe("hello-ok"); - socket2.destroy(); - - await server.close(); - }); - - it("calls onPairRequested for newly created pending requests", async () => { - let requested: { nodeId?: string; requestId?: string } | null = null; - const server = await startNodeBridgeServer({ - host: "127.0.0.1", - port: 0, - pairingBaseDir: baseDir, - onPairRequested: async (req) => { - requested = req; - }, - }); - - const socket = net.connect({ host: "127.0.0.1", port: server.port }); - await waitForSocketConnect(socket); - sendLine(socket, { type: "pair-request", nodeId: "n3", platform: "ios" }); - - await pollUntil(async () => requested, { timeoutMs: pairingTimeoutMs }); - - expect(requested?.nodeId).toBe("n3"); - expect(typeof requested?.requestId).toBe("string"); - - socket.destroy(); - await server.close(); - }); - - it("handles req/res RPC after authentication", async () => { - let lastRequest: { nodeId?: string; id?: string; method?: string } | null = null; - - const server = await startNodeBridgeServer({ - host: "127.0.0.1", - port: 0, - pairingBaseDir: baseDir, - onRequest: async (nodeId, req) => { - lastRequest = { nodeId, id: req.id, method: req.method }; - return { ok: true, payloadJSON: JSON.stringify({ ok: true }) }; - }, - }); - - const socket = net.connect({ host: "127.0.0.1", port: server.port }); - await waitForSocketConnect(socket); - const readLine = createLineReader(socket); - sendLine(socket, { - type: "pair-request", - nodeId: "n3-rpc", - platform: "ios", - }); - - // Approve the pending request from the gateway side. - const pending = await pollUntil( - async () => { - const list = await listNodePairing(baseDir); - return list.pending.find((p) => p.nodeId === "n3-rpc"); - }, - { timeoutMs: pairingTimeoutMs }, - ); - expect(pending).toBeTruthy(); - if (!pending) throw new Error("expected a pending request"); - await approveNodePairing(pending.requestId, baseDir); - - const line1 = JSON.parse(await readLine()) as { type: string }; - expect(line1.type).toBe("pair-ok"); - const line2 = JSON.parse(await readLine()) as { type: string }; - expect(line2.type).toBe("hello-ok"); - - sendLine(socket, { type: "req", id: "r1", method: "health" }); - const res = JSON.parse(await readLine()) as { - type: string; - id?: string; - ok?: boolean; - payloadJSON?: string | null; - error?: unknown; - }; - expect(res.type).toBe("res"); - expect(res.id).toBe("r1"); - expect(res.ok).toBe(true); - expect(res.payloadJSON).toBe(JSON.stringify({ ok: true })); - expect(res.error).toBeUndefined(); - - expect(lastRequest).toEqual({ - nodeId: "n3-rpc", - id: "r1", - method: "health", - }); - - socket.destroy(); - await server.close(); - }); - - it("passes node metadata to onAuthenticated and onDisconnected", async () => { - let lastAuthed: { - nodeId?: string; - displayName?: string; - platform?: string; - version?: string; - deviceFamily?: string; - modelIdentifier?: string; - remoteIp?: string; - permissions?: Record; - } | null = null; - - let disconnected: { - nodeId?: string; - displayName?: string; - platform?: string; - version?: string; - deviceFamily?: string; - modelIdentifier?: string; - remoteIp?: string; - permissions?: Record; - } | null = null; - - let resolveDisconnected: (() => void) | null = null; - const disconnectedP = new Promise((resolve) => { - resolveDisconnected = resolve; - }); - - let pendingRequest: { - requestId: string; - nodeId: string; - ts: number; - } | null = null; - const server = await startNodeBridgeServer({ - host: "127.0.0.1", - port: 0, - pairingBaseDir: baseDir, - onAuthenticated: async (node) => { - lastAuthed = node; - }, - onPairRequested: async (request) => { - pendingRequest = { - requestId: request.requestId, - nodeId: request.nodeId, - ts: request.ts, - }; - }, - onDisconnected: async (node) => { - disconnected = node; - resolveDisconnected?.(); - }, - }); - - const socket = net.connect({ host: "127.0.0.1", port: server.port }); - await waitForSocketConnect(socket); - const readLine = createLineReader(socket); - sendLine(socket, { - type: "pair-request", - nodeId: "n4", - displayName: "Node", - platform: "ios", - version: "1.0", - deviceFamily: "iPad", - modelIdentifier: "iPad16,6", - permissions: { screenRecording: true, notifications: false }, - }); - - // Approve the pending request from the gateway side. - const pending = await pollUntil(async () => pendingRequest, { timeoutMs: pairingTimeoutMs }); - expect(pending).toBeTruthy(); - if (!pending) throw new Error("expected a pending request"); - const approved = await approveNodePairing(pending.requestId, baseDir); - const token = approved?.node?.token ?? ""; - expect(token.length).toBeGreaterThan(0); - - const line1 = JSON.parse(await readLine()) as { type: string }; - expect(line1.type).toBe("pair-ok"); - const line2 = JSON.parse(await readLine()) as { type: string }; - expect(line2.type).toBe("hello-ok"); - socket.destroy(); - - const socket2 = net.connect({ host: "127.0.0.1", port: server.port }); - await waitForSocketConnect(socket2); - const readLine2 = createLineReader(socket2); - sendLine(socket2, { - type: "hello", - nodeId: "n4", - token, - displayName: "Different name", - platform: "ios", - version: "2.0", - deviceFamily: "iPad", - modelIdentifier: "iPad99,1", - permissions: { screenRecording: false }, - }); - const line3 = JSON.parse(await readLine2()) as { type: string }; - expect(line3.type).toBe("hello-ok"); - - await pollUntil(async () => (lastAuthed?.nodeId === "n4" ? lastAuthed : null), { - timeoutMs: pairingTimeoutMs, - }); - - expect(lastAuthed?.nodeId).toBe("n4"); - // Prefer paired metadata over hello payload (token verifies the stored node record). - expect(lastAuthed?.displayName).toBe("Node"); - expect(lastAuthed?.platform).toBe("ios"); - expect(lastAuthed?.version).toBe("1.0"); - expect(lastAuthed?.deviceFamily).toBe("iPad"); - expect(lastAuthed?.modelIdentifier).toBe("iPad16,6"); - expect(lastAuthed?.permissions).toEqual({ - screenRecording: false, - notifications: false, - }); - expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true); - - socket2.destroy(); - await disconnectedP; - expect(disconnected?.nodeId).toBe("n4"); - expect(disconnected?.remoteIp?.includes("127.0.0.1")).toBe(true); - - await server.close(); - }); -}); diff --git a/src/infra/bridge/server.supports-invoke-roundtrip-connected-node.test.ts b/src/infra/bridge/server.supports-invoke-roundtrip-connected-node.test.ts deleted file mode 100644 index 814eb7dbb..000000000 --- a/src/infra/bridge/server.supports-invoke-roundtrip-connected-node.test.ts +++ /dev/null @@ -1,220 +0,0 @@ -import fs from "node:fs/promises"; -import net from "node:net"; -import os from "node:os"; -import path from "node:path"; - -import { afterAll, beforeAll, describe, expect, it } from "vitest"; - -import { pollUntil } from "../../../test/helpers/poll.js"; -import { approveNodePairing, listNodePairing } from "../node-pairing.js"; -import { startNodeBridgeServer } from "./server.js"; - -function createLineReader(socket: net.Socket) { - let buffer = ""; - const pending: Array<(line: string) => void> = []; - - const flush = () => { - while (pending.length > 0) { - const idx = buffer.indexOf("\n"); - if (idx === -1) return; - const line = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - const resolve = pending.shift(); - resolve?.(line); - } - }; - - socket.on("data", (chunk) => { - buffer += chunk.toString("utf8"); - flush(); - }); - - const readLine = async () => { - flush(); - const idx = buffer.indexOf("\n"); - if (idx !== -1) { - const line = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - return line; - } - return await new Promise((resolve) => pending.push(resolve)); - }; - - return readLine; -} - -function sendLine(socket: net.Socket, obj: unknown) { - socket.write(`${JSON.stringify(obj)}\n`); -} - -async function waitForSocketConnect(socket: net.Socket) { - if (!socket.connecting) return; - await new Promise((resolve, reject) => { - socket.once("connect", resolve); - socket.once("error", reject); - }); -} - -describe("node bridge server", () => { - let baseDir = ""; - - const _pickNonLoopbackIPv4 = () => { - const ifaces = os.networkInterfaces(); - for (const entries of Object.values(ifaces)) { - for (const info of entries ?? []) { - if (info.family === "IPv4" && info.internal === false) return info.address; - } - } - return null; - }; - - beforeAll(async () => { - process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS = "1"; - baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-bridge-test-")); - }); - - afterAll(async () => { - await fs.rm(baseDir, { recursive: true, force: true }); - delete process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS; - }); - - it("supports invoke roundtrip to a connected node", async () => { - const server = await startNodeBridgeServer({ - host: "127.0.0.1", - port: 0, - pairingBaseDir: baseDir, - }); - - const socket = net.connect({ host: "127.0.0.1", port: server.port }); - await waitForSocketConnect(socket); - const readLine = createLineReader(socket); - sendLine(socket, { type: "pair-request", nodeId: "n5", platform: "ios" }); - - // Approve the pending request from the gateway side. - const pending = await pollUntil( - async () => { - const list = await listNodePairing(baseDir); - return list.pending.find((p) => p.nodeId === "n5"); - }, - { timeoutMs: 3000 }, - ); - expect(pending).toBeTruthy(); - if (!pending) throw new Error("expected a pending request"); - await approveNodePairing(pending.requestId, baseDir); - - const pairOk = JSON.parse(await readLine()) as { - type: string; - token?: string; - }; - expect(pairOk.type).toBe("pair-ok"); - expect(typeof pairOk.token).toBe("string"); - if (!pairOk.token) throw new Error("expected pair-ok token"); - const token = pairOk.token; - - const helloOk = JSON.parse(await readLine()) as { type: string }; - expect(helloOk.type).toBe("hello-ok"); - - const responder = (async () => { - while (true) { - const frame = JSON.parse(await readLine()) as { - type: string; - id?: string; - command?: string; - }; - if (frame.type !== "invoke") continue; - sendLine(socket, { - type: "invoke-res", - id: frame.id, - ok: true, - payloadJSON: JSON.stringify({ echo: frame.command }), - }); - break; - } - })(); - - const res = await server.invoke({ - nodeId: "n5", - command: "canvas.eval", - paramsJSON: JSON.stringify({ javaScript: "1+1" }), - timeoutMs: 3000, - }); - - expect(res.ok).toBe(true); - const payload = JSON.parse(String(res.payloadJSON ?? "null")) as { - echo?: string; - }; - expect(payload.echo).toBe("canvas.eval"); - - await responder; - socket.destroy(); - - // Ensure invoke works only for connected nodes (hello with token on a new socket). - const socket2 = net.connect({ host: "127.0.0.1", port: server.port }); - await waitForSocketConnect(socket2); - const readLine2 = createLineReader(socket2); - sendLine(socket2, { type: "hello", nodeId: "n5", token }); - const hello2 = JSON.parse(await readLine2()) as { type: string }; - expect(hello2.type).toBe("hello-ok"); - socket2.destroy(); - - await server.close(); - }); - - it("tracks connected node caps and hardware identifiers", async () => { - const server = await startNodeBridgeServer({ - host: "127.0.0.1", - port: 0, - pairingBaseDir: baseDir, - }); - - const socket = net.connect({ host: "127.0.0.1", port: server.port }); - await waitForSocketConnect(socket); - const readLine = createLineReader(socket); - sendLine(socket, { - type: "pair-request", - nodeId: "n-caps", - displayName: "Node", - platform: "ios", - version: "1.0", - deviceFamily: "iPad", - modelIdentifier: "iPad14,5", - caps: ["canvas", "camera"], - commands: ["canvas.eval", "canvas.snapshot", "camera.snap"], - permissions: { accessibility: true }, - }); - - // Approve the pending request from the gateway side. - const pending = await pollUntil( - async () => { - const list = await listNodePairing(baseDir); - return list.pending.find((p) => p.nodeId === "n-caps"); - }, - { timeoutMs: 3000 }, - ); - expect(pending).toBeTruthy(); - if (!pending) throw new Error("expected a pending request"); - await approveNodePairing(pending.requestId, baseDir); - - const pairOk = JSON.parse(await readLine()) as { type: string }; - expect(pairOk.type).toBe("pair-ok"); - const helloOk = JSON.parse(await readLine()) as { type: string }; - expect(helloOk.type).toBe("hello-ok"); - - const connected = server.listConnected(); - const node = connected.find((n) => n.nodeId === "n-caps"); - expect(node?.deviceFamily).toBe("iPad"); - expect(node?.modelIdentifier).toBe("iPad14,5"); - expect(node?.caps).toEqual(["canvas", "camera"]); - expect(node?.commands).toEqual(["canvas.eval", "canvas.snapshot", "camera.snap"]); - expect(node?.permissions).toEqual({ accessibility: true }); - - const after = await listNodePairing(baseDir); - const paired = after.paired.find((p) => p.nodeId === "n-caps"); - expect(paired?.caps).toEqual(["canvas", "camera"]); - expect(paired?.commands).toEqual(["canvas.eval", "canvas.snapshot", "camera.snap"]); - expect(paired?.permissions).toEqual({ accessibility: true }); - - socket.destroy(); - await server.close(); - }); -}); diff --git a/src/infra/bridge/server.ts b/src/infra/bridge/server.ts deleted file mode 100644 index 06f9af9be..000000000 --- a/src/infra/bridge/server.ts +++ /dev/null @@ -1,10 +0,0 @@ -export { configureNodeBridgeSocket } from "./server/socket.js"; -export { startNodeBridgeServer } from "./server/start.js"; -export type { - BridgeEventFrame, - BridgeInvokeResponseFrame, - BridgeRPCRequestFrame, - NodeBridgeClientInfo, - NodeBridgeServer, - NodeBridgeServerOpts, -} from "./server/types.js"; diff --git a/src/infra/bridge/server/connection.ts b/src/infra/bridge/server/connection.ts deleted file mode 100644 index cf1028bbd..000000000 --- a/src/infra/bridge/server/connection.ts +++ /dev/null @@ -1,482 +0,0 @@ -import type net from "node:net"; - -import { - getPairedNode, - listNodePairing, - requestNodePairing, - updatePairedNodeMetadata, - verifyNodeToken, -} from "../../node-pairing.js"; - -import { encodeLine } from "./encode.js"; -import { configureNodeBridgeSocket } from "./socket.js"; -import type { - AnyBridgeFrame, - BridgeErrorFrame, - BridgeEventFrame, - BridgeHelloFrame, - BridgeHelloOkFrame, - BridgeInvokeResponseFrame, - BridgePairOkFrame, - BridgePairRequestFrame, - BridgePingFrame, - BridgePongFrame, - BridgeRPCRequestFrame, - BridgeRPCResponseFrame, - NodeBridgeClientInfo, - NodeBridgeServerOpts, -} from "./types.js"; - -type InvokeWaiter = { - resolve: (value: BridgeInvokeResponseFrame) => void; - reject: (err: Error) => void; - timer: ReturnType; -}; - -export type ConnectionState = { - socket: net.Socket; - nodeInfo: NodeBridgeClientInfo; - invokeWaiters: Map; -}; - -async function sleep(ms: number) { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -export function createNodeBridgeConnectionHandler(params: { - opts: NodeBridgeServerOpts; - connections: Map; - serverName: string; - buildCanvasHostUrl: (socket: net.Socket) => string | undefined; -}) { - const { opts, connections, serverName } = params; - - return (socket: net.Socket) => { - configureNodeBridgeSocket(socket); - - let buffer = ""; - let isAuthenticated = false; - let nodeId: string | null = null; - let nodeInfo: NodeBridgeClientInfo | null = null; - const invokeWaiters = new Map(); - - const abort = new AbortController(); - const stop = () => { - if (!abort.signal.aborted) abort.abort(); - for (const [, waiter] of invokeWaiters) { - clearTimeout(waiter.timer); - waiter.reject(new Error("bridge connection closed")); - } - invokeWaiters.clear(); - if (nodeId) { - const existing = connections.get(nodeId); - if (existing?.socket === socket) connections.delete(nodeId); - } - }; - - const send = (frame: AnyBridgeFrame) => { - try { - socket.write(encodeLine(frame)); - } catch { - // ignore - } - }; - - const sendError = (code: string, message: string) => { - send({ type: "error", code, message } satisfies BridgeErrorFrame); - }; - - const remoteAddress = (() => { - const addr = socket.remoteAddress?.trim(); - return addr && addr.length > 0 ? addr : undefined; - })(); - - const inferCaps = (frame: { - platform?: string; - deviceFamily?: string; - }): string[] | undefined => { - const platform = String(frame.platform ?? "") - .trim() - .toLowerCase(); - const family = String(frame.deviceFamily ?? "") - .trim() - .toLowerCase(); - if (platform.includes("ios") || platform.includes("ipados")) return ["canvas", "camera"]; - if (platform.includes("android")) return ["canvas", "camera"]; - if (family === "ipad" || family === "iphone" || family === "ios") return ["canvas", "camera"]; - if (family === "android") return ["canvas", "camera"]; - return undefined; - }; - - const normalizePermissions = (raw: unknown): Record | undefined => { - if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined; - const entries = Object.entries(raw as Record) - .map(([key, value]) => [String(key).trim(), value === true] as const) - .filter(([key]) => key.length > 0); - if (entries.length === 0) return undefined; - return Object.fromEntries(entries); - }; - - const handleHello = async (hello: BridgeHelloFrame) => { - nodeId = String(hello.nodeId ?? "").trim(); - if (!nodeId) { - sendError("INVALID_REQUEST", "nodeId required"); - return; - } - - const token = typeof hello.token === "string" ? hello.token.trim() : ""; - if (!token) { - const paired = await getPairedNode(nodeId, opts.pairingBaseDir); - sendError(paired ? "UNAUTHORIZED" : "NOT_PAIRED", "pairing required"); - return; - } - - const verified = await verifyNodeToken(nodeId, token, opts.pairingBaseDir); - if (!verified.ok || !verified.node) { - sendError("UNAUTHORIZED", "invalid token"); - return; - } - - const caps = - (Array.isArray(hello.caps) - ? hello.caps.map((c) => String(c)).filter(Boolean) - : undefined) ?? - verified.node.caps ?? - inferCaps(hello); - - const commands = - Array.isArray(hello.commands) && hello.commands.length > 0 - ? hello.commands.map((c) => String(c)).filter(Boolean) - : verified.node.commands; - const helloPermissions = normalizePermissions(hello.permissions); - const basePermissions = verified.node.permissions ?? {}; - const permissions = helloPermissions - ? { ...basePermissions, ...helloPermissions } - : verified.node.permissions; - - isAuthenticated = true; - const existing = connections.get(nodeId); - if (existing?.socket && existing.socket !== socket) { - try { - existing.socket.destroy(); - } catch { - /* ignore */ - } - } - nodeInfo = { - nodeId, - displayName: verified.node.displayName ?? hello.displayName, - platform: verified.node.platform ?? hello.platform, - version: verified.node.version ?? hello.version, - coreVersion: verified.node.coreVersion ?? hello.coreVersion, - uiVersion: verified.node.uiVersion ?? hello.uiVersion, - deviceFamily: verified.node.deviceFamily ?? hello.deviceFamily, - modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier, - caps, - commands, - permissions, - remoteIp: remoteAddress, - }; - await updatePairedNodeMetadata( - nodeId, - { - displayName: nodeInfo.displayName, - platform: nodeInfo.platform, - version: nodeInfo.version, - coreVersion: nodeInfo.coreVersion, - uiVersion: nodeInfo.uiVersion, - deviceFamily: nodeInfo.deviceFamily, - modelIdentifier: nodeInfo.modelIdentifier, - remoteIp: nodeInfo.remoteIp, - caps: nodeInfo.caps, - commands: nodeInfo.commands, - permissions: nodeInfo.permissions, - }, - opts.pairingBaseDir, - ); - connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); - send({ - type: "hello-ok", - serverName, - canvasHostUrl: params.buildCanvasHostUrl(socket), - } satisfies BridgeHelloOkFrame); - await opts.onAuthenticated?.(nodeInfo); - }; - - const waitForApproval = async (request: { - requestId: string; - nodeId: string; - ts: number; - }): Promise<{ ok: true; token: string } | { ok: false; reason: string }> => { - const deadline = Date.now() + 5 * 60 * 1000; - while (!abort.signal.aborted && Date.now() < deadline) { - const list = await listNodePairing(opts.pairingBaseDir); - const stillPending = list.pending.some((p) => p.requestId === request.requestId); - if (stillPending) { - await sleep(250); - continue; - } - - const paired = await getPairedNode(request.nodeId, opts.pairingBaseDir); - if (!paired) return { ok: false, reason: "pairing rejected" }; - - // Ensure this approval happened after the request was created. - if (paired.approvedAtMs < request.ts) { - return { ok: false, reason: "pairing rejected" }; - } - - return { ok: true, token: paired.token }; - } - - return { - ok: false, - reason: abort.signal.aborted ? "disconnected" : "pairing expired", - }; - }; - - const handlePairRequest = async (req: BridgePairRequestFrame) => { - nodeId = String(req.nodeId ?? "").trim(); - if (!nodeId) { - sendError("INVALID_REQUEST", "nodeId required"); - return; - } - - const result = await requestNodePairing( - { - nodeId, - displayName: req.displayName, - platform: req.platform, - version: req.version, - coreVersion: req.coreVersion, - uiVersion: req.uiVersion, - deviceFamily: req.deviceFamily, - modelIdentifier: req.modelIdentifier, - caps: Array.isArray(req.caps) - ? req.caps.map((c) => String(c)).filter(Boolean) - : undefined, - commands: Array.isArray(req.commands) - ? req.commands.map((c) => String(c)).filter(Boolean) - : undefined, - permissions: - req.permissions && typeof req.permissions === "object" - ? (req.permissions as Record) - : undefined, - remoteIp: remoteAddress, - silent: req.silent === true ? true : undefined, - }, - opts.pairingBaseDir, - ); - if (result.created) await opts.onPairRequested?.(result.request); - - const wait = await waitForApproval({ - requestId: result.request.requestId, - nodeId: result.request.nodeId, - ts: result.request.ts, - }); - if (!wait.ok) { - sendError("UNAUTHORIZED", wait.reason); - return; - } - - isAuthenticated = true; - const existing = connections.get(nodeId); - if (existing?.socket && existing.socket !== socket) { - try { - existing.socket.destroy(); - } catch { - /* ignore */ - } - } - nodeInfo = { - nodeId, - displayName: req.displayName, - platform: req.platform, - version: req.version, - coreVersion: req.coreVersion, - uiVersion: req.uiVersion, - deviceFamily: req.deviceFamily, - modelIdentifier: req.modelIdentifier, - caps: Array.isArray(req.caps) ? req.caps.map((c) => String(c)).filter(Boolean) : undefined, - commands: Array.isArray(req.commands) - ? req.commands.map((c) => String(c)).filter(Boolean) - : undefined, - permissions: - req.permissions && typeof req.permissions === "object" - ? (req.permissions as Record) - : undefined, - remoteIp: remoteAddress, - }; - connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); - send({ type: "pair-ok", token: wait.token } satisfies BridgePairOkFrame); - send({ - type: "hello-ok", - serverName, - canvasHostUrl: params.buildCanvasHostUrl(socket), - } satisfies BridgeHelloOkFrame); - await opts.onAuthenticated?.(nodeInfo); - }; - - const handleEvent = async (evt: BridgeEventFrame) => { - if (!isAuthenticated || !nodeId) { - sendError("UNAUTHORIZED", "not authenticated"); - return; - } - await opts.onEvent?.(nodeId, evt); - }; - - const handleRequest = async (req: BridgeRPCRequestFrame) => { - if (!isAuthenticated || !nodeId) { - send({ - type: "res", - id: String(req.id ?? ""), - ok: false, - error: { code: "UNAUTHORIZED", message: "not authenticated" }, - } satisfies BridgeRPCResponseFrame); - return; - } - - if (!opts.onRequest) { - send({ - type: "res", - id: String(req.id ?? ""), - ok: false, - error: { code: "UNAVAILABLE", message: "RPC not supported" }, - } satisfies BridgeRPCResponseFrame); - return; - } - - const id = String(req.id ?? ""); - const method = String(req.method ?? ""); - if (!id || !method) { - send({ - type: "res", - id: id || "invalid", - ok: false, - error: { code: "INVALID_REQUEST", message: "id and method required" }, - } satisfies BridgeRPCResponseFrame); - return; - } - - try { - const result = await opts.onRequest(nodeId, { - type: "req", - id, - method, - paramsJSON: req.paramsJSON ?? null, - }); - if (result.ok) { - send({ - type: "res", - id, - ok: true, - payloadJSON: result.payloadJSON ?? null, - } satisfies BridgeRPCResponseFrame); - } else { - send({ - type: "res", - id, - ok: false, - error: result.error, - } satisfies BridgeRPCResponseFrame); - } - } catch (err) { - send({ - type: "res", - id, - ok: false, - error: { code: "UNAVAILABLE", message: String(err) }, - } satisfies BridgeRPCResponseFrame); - } - }; - - socket.on("data", (chunk) => { - buffer += chunk.toString("utf8"); - while (true) { - const idx = buffer.indexOf("\n"); - if (idx === -1) break; - const line = buffer.slice(0, idx); - buffer = buffer.slice(idx + 1); - const trimmed = line.trim(); - if (!trimmed) continue; - - void (async () => { - let frame: AnyBridgeFrame; - try { - frame = JSON.parse(trimmed) as AnyBridgeFrame; - } catch (err) { - sendError("INVALID_REQUEST", String(err)); - return; - } - - const type = typeof frame.type === "string" ? frame.type : ""; - try { - switch (type) { - case "hello": - await handleHello(frame as BridgeHelloFrame); - break; - case "pair-request": - await handlePairRequest(frame as BridgePairRequestFrame); - break; - case "event": - await handleEvent(frame as BridgeEventFrame); - break; - case "req": - await handleRequest(frame as BridgeRPCRequestFrame); - break; - case "ping": { - if (!isAuthenticated) { - sendError("UNAUTHORIZED", "not authenticated"); - break; - } - const ping = frame as BridgePingFrame; - send({ - type: "pong", - id: String(ping.id ?? ""), - } satisfies BridgePongFrame); - break; - } - case "invoke-res": { - if (!isAuthenticated) { - sendError("UNAUTHORIZED", "not authenticated"); - break; - } - const res = frame as BridgeInvokeResponseFrame; - const waiter = invokeWaiters.get(res.id); - if (waiter) { - invokeWaiters.delete(res.id); - clearTimeout(waiter.timer); - waiter.resolve(res); - } - break; - } - case "invoke": - // Direction is gateway -> node only. - sendError("INVALID_REQUEST", "invoke not allowed from node"); - break; - case "res": - // Direction is node -> gateway only. - sendError("INVALID_REQUEST", "res not allowed from node"); - break; - case "pong": - // ignore - break; - default: - sendError("INVALID_REQUEST", "unknown type"); - } - } catch (err) { - sendError("INVALID_REQUEST", String(err)); - } - })(); - } - }); - - socket.on("close", () => { - const info = nodeInfo; - stop(); - if (info && isAuthenticated) void opts.onDisconnected?.(info); - }); - socket.on("error", () => { - // close handler will run after close - }); - }; -} diff --git a/src/infra/bridge/server/disabled.ts b/src/infra/bridge/server/disabled.ts deleted file mode 100644 index 8b243d6ea..000000000 --- a/src/infra/bridge/server/disabled.ts +++ /dev/null @@ -1,14 +0,0 @@ -import type { NodeBridgeServer } from "./types.js"; - -export function createDisabledNodeBridgeServer(): NodeBridgeServer { - return { - port: 0, - close: async () => {}, - invoke: async () => { - throw new Error("bridge disabled in tests"); - }, - sendEvent: () => {}, - listConnected: () => [], - listeners: [], - }; -} diff --git a/src/infra/bridge/server/encode.ts b/src/infra/bridge/server/encode.ts deleted file mode 100644 index db814aaf0..000000000 --- a/src/infra/bridge/server/encode.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { AnyBridgeFrame } from "./types.js"; - -export function encodeLine(frame: AnyBridgeFrame) { - return `${JSON.stringify(frame)}\n`; -} diff --git a/src/infra/bridge/server/loopback.ts b/src/infra/bridge/server/loopback.ts deleted file mode 100644 index f4ed21d62..000000000 --- a/src/infra/bridge/server/loopback.ts +++ /dev/null @@ -1,11 +0,0 @@ -export function shouldAlsoListenOnLoopback(host: string | undefined) { - const h = String(host ?? "") - .trim() - .toLowerCase(); - if (!h) return false; // default listen() already includes loopback - if (h === "0.0.0.0" || h === "::") return false; // already includes loopback - if (h === "localhost") return false; - if (h === "127.0.0.1" || h.startsWith("127.")) return false; - if (h === "::1") return false; - return true; -} diff --git a/src/infra/bridge/server/socket.ts b/src/infra/bridge/server/socket.ts deleted file mode 100644 index f63ba284b..000000000 --- a/src/infra/bridge/server/socket.ts +++ /dev/null @@ -1,7 +0,0 @@ -export function configureNodeBridgeSocket(socket: { - setNoDelay: (noDelay?: boolean) => void; - setKeepAlive: (enable?: boolean, initialDelay?: number) => void; -}) { - socket.setNoDelay(true); - socket.setKeepAlive(true, 15_000); -} diff --git a/src/infra/bridge/server/start.ts b/src/infra/bridge/server/start.ts deleted file mode 100644 index 35d124abb..000000000 --- a/src/infra/bridge/server/start.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { randomUUID } from "node:crypto"; -import net from "node:net"; -import os from "node:os"; -import tls from "node:tls"; - -import { resolveCanvasHostUrl } from "../../canvas-host-url.js"; - -import { type ConnectionState, createNodeBridgeConnectionHandler } from "./connection.js"; -import { createDisabledNodeBridgeServer } from "./disabled.js"; -import { encodeLine } from "./encode.js"; -import { shouldAlsoListenOnLoopback } from "./loopback.js"; -import { isNodeBridgeTestEnv } from "./test-env.js"; -import type { - BridgeEventFrame, - BridgeInvokeRequestFrame, - BridgeInvokeResponseFrame, - NodeBridgeServer, - NodeBridgeServerOpts, -} from "./types.js"; - -export async function startNodeBridgeServer(opts: NodeBridgeServerOpts): Promise { - if (isNodeBridgeTestEnv() && process.env.CLAWDBOT_ENABLE_BRIDGE_IN_TESTS !== "1") { - return createDisabledNodeBridgeServer(); - } - - const serverName = - typeof opts.serverName === "string" && opts.serverName.trim() - ? opts.serverName.trim() - : os.hostname(); - - const buildCanvasHostUrl = (socket: net.Socket) => { - return resolveCanvasHostUrl({ - canvasPort: opts.canvasHostPort, - hostOverride: opts.canvasHostHost, - localAddress: socket.localAddress, - scheme: "http", - }); - }; - - const connections = new Map(); - const onConnection = createNodeBridgeConnectionHandler({ - opts, - connections, - serverName, - buildCanvasHostUrl, - }); - - const loopbackHost = "127.0.0.1"; - - const listeners: Array<{ host: string; server: net.Server }> = []; - const createServer = () => - opts.tls ? tls.createServer(opts.tls, onConnection) : net.createServer(onConnection); - const primary = createServer(); - await new Promise((resolve, reject) => { - const onError = (err: Error) => reject(err); - primary.once("error", onError); - primary.listen(opts.port, opts.host, () => { - primary.off("error", onError); - resolve(); - }); - }); - listeners.push({ - host: String(opts.host ?? "").trim() || "(default)", - server: primary, - }); - - const address = primary.address(); - const port = typeof address === "object" && address ? address.port : opts.port; - - if (shouldAlsoListenOnLoopback(opts.host)) { - const loopback = createServer(); - try { - await new Promise((resolve, reject) => { - const onError = (err: Error) => reject(err); - loopback.once("error", onError); - loopback.listen(port, loopbackHost, () => { - loopback.off("error", onError); - resolve(); - }); - }); - listeners.push({ host: loopbackHost, server: loopback }); - } catch { - try { - loopback.close(); - } catch { - /* ignore */ - } - } - } - - return { - port, - close: async () => { - for (const sock of connections.values()) { - try { - sock.socket.destroy(); - } catch { - /* ignore */ - } - } - connections.clear(); - await Promise.all( - listeners.map( - (l) => - new Promise((resolve, reject) => - l.server.close((err) => (err ? reject(err) : resolve())), - ), - ), - ); - }, - listConnected: () => [...connections.values()].map((c) => c.nodeInfo), - listeners: listeners.map((l) => ({ host: l.host, port })), - sendEvent: ({ nodeId, event, payloadJSON }) => { - const normalizedNodeId = String(nodeId ?? "").trim(); - const normalizedEvent = String(event ?? "").trim(); - if (!normalizedNodeId || !normalizedEvent) return; - const conn = connections.get(normalizedNodeId); - if (!conn) return; - try { - conn.socket.write( - encodeLine({ - type: "event", - event: normalizedEvent, - payloadJSON: payloadJSON ?? null, - } satisfies BridgeEventFrame), - ); - } catch { - // ignore - } - }, - invoke: async ({ nodeId, command, paramsJSON, timeoutMs }) => { - const normalizedNodeId = String(nodeId ?? "").trim(); - const normalizedCommand = String(command ?? "").trim(); - if (!normalizedNodeId) throw new Error("INVALID_REQUEST: nodeId required"); - if (!normalizedCommand) throw new Error("INVALID_REQUEST: command required"); - - const conn = connections.get(normalizedNodeId); - if (!conn) throw new Error(`UNAVAILABLE: node not connected (${normalizedNodeId})`); - - const id = randomUUID(); - const timeout = Number.isFinite(timeoutMs) ? Number(timeoutMs) : 15_000; - - return await new Promise((resolve, reject) => { - const timer = setTimeout( - () => { - conn.invokeWaiters.delete(id); - reject(new Error("UNAVAILABLE: invoke timeout")); - }, - Math.max(0, timeout), - ); - - conn.invokeWaiters.set(id, { resolve, reject, timer }); - try { - conn.socket.write( - encodeLine({ - type: "invoke", - id, - command: normalizedCommand, - paramsJSON: paramsJSON ?? null, - } satisfies BridgeInvokeRequestFrame), - ); - } catch (err) { - conn.invokeWaiters.delete(id); - clearTimeout(timer); - reject(err instanceof Error ? err : new Error(String(err))); - } - }); - }, - }; -} diff --git a/src/infra/bridge/server/test-env.ts b/src/infra/bridge/server/test-env.ts deleted file mode 100644 index 1c29fd84e..000000000 --- a/src/infra/bridge/server/test-env.ts +++ /dev/null @@ -1,3 +0,0 @@ -export function isNodeBridgeTestEnv() { - return process.env.NODE_ENV === "test" || Boolean(process.env.VITEST); -} diff --git a/src/infra/bridge/server/types.ts b/src/infra/bridge/server/types.ts deleted file mode 100644 index 1b3be129a..000000000 --- a/src/infra/bridge/server/types.ts +++ /dev/null @@ -1,149 +0,0 @@ -import type { TlsOptions } from "node:tls"; - -import type { NodePairingPendingRequest } from "../../node-pairing.js"; - -export type BridgeHelloFrame = { - type: "hello"; - nodeId: string; - displayName?: string; - token?: string; - platform?: string; - version?: string; - coreVersion?: string; - uiVersion?: string; - deviceFamily?: string; - modelIdentifier?: string; - caps?: string[]; - commands?: string[]; - permissions?: Record; -}; - -export type BridgePairRequestFrame = { - type: "pair-request"; - nodeId: string; - displayName?: string; - platform?: string; - version?: string; - coreVersion?: string; - uiVersion?: string; - deviceFamily?: string; - modelIdentifier?: string; - caps?: string[]; - commands?: string[]; - permissions?: Record; - remoteAddress?: string; - silent?: boolean; -}; - -export type BridgeEventFrame = { - type: "event"; - event: string; - payloadJSON?: string | null; -}; - -export type BridgeRPCRequestFrame = { - type: "req"; - id: string; - method: string; - paramsJSON?: string | null; -}; - -export type BridgeRPCResponseFrame = { - type: "res"; - id: string; - ok: boolean; - payloadJSON?: string | null; - error?: { code: string; message: string; details?: unknown } | null; -}; - -export type BridgePingFrame = { type: "ping"; id: string }; -export type BridgePongFrame = { type: "pong"; id: string }; - -export type BridgeInvokeRequestFrame = { - type: "invoke"; - id: string; - command: string; - paramsJSON?: string | null; -}; - -export type BridgeInvokeResponseFrame = { - type: "invoke-res"; - id: string; - ok: boolean; - payloadJSON?: string | null; - error?: { code: string; message: string } | null; -}; - -export type BridgeHelloOkFrame = { - type: "hello-ok"; - serverName: string; - canvasHostUrl?: string; -}; - -export type BridgePairOkFrame = { type: "pair-ok"; token: string }; -export type BridgeErrorFrame = { type: "error"; code: string; message: string }; - -export type AnyBridgeFrame = - | BridgeHelloFrame - | BridgePairRequestFrame - | BridgeEventFrame - | BridgeRPCRequestFrame - | BridgeRPCResponseFrame - | BridgePingFrame - | BridgePongFrame - | BridgeInvokeRequestFrame - | BridgeInvokeResponseFrame - | BridgeHelloOkFrame - | BridgePairOkFrame - | BridgeErrorFrame - | { type: string; [k: string]: unknown }; - -export type NodeBridgeServer = { - port: number; - close: () => Promise; - invoke: (opts: { - nodeId: string; - command: string; - paramsJSON?: string | null; - timeoutMs?: number; - }) => Promise; - sendEvent: (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => void; - listConnected: () => NodeBridgeClientInfo[]; - listeners: Array<{ host: string; port: number }>; -}; - -export type NodeBridgeClientInfo = { - nodeId: string; - displayName?: string; - platform?: string; - version?: string; - coreVersion?: string; - uiVersion?: string; - deviceFamily?: string; - modelIdentifier?: string; - remoteIp?: string; - caps?: string[]; - commands?: string[]; - permissions?: Record; -}; - -export type NodeBridgeServerOpts = { - host: string; - port: number; // 0 = ephemeral - tls?: TlsOptions; - pairingBaseDir?: string; - canvasHostPort?: number; - canvasHostHost?: string; - onEvent?: (nodeId: string, evt: BridgeEventFrame) => Promise | void; - onRequest?: ( - nodeId: string, - req: BridgeRPCRequestFrame, - ) => Promise< - | { ok: true; payloadJSON?: string | null } - | { ok: false; error: { code: string; message: string; details?: unknown } } - >; - onAuthenticated?: (node: NodeBridgeClientInfo) => Promise | void; - onDisconnected?: (node: NodeBridgeClientInfo) => Promise | void; - onPairRequested?: (request: NodePairingPendingRequest) => Promise | void; - serverName?: string; -}; diff --git a/src/infra/skills-remote.ts b/src/infra/skills-remote.ts index e4bc88c5a..2c907dbed 100644 --- a/src/infra/skills-remote.ts +++ b/src/infra/skills-remote.ts @@ -2,10 +2,10 @@ import type { SkillEligibilityContext, SkillEntry } from "../agents/skills.js"; import { loadWorkspaceSkillEntries } from "../agents/skills.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; import type { ClawdbotConfig } from "../config/config.js"; -import type { NodeBridgeServer } from "./bridge/server.js"; import { listNodePairing, updatePairedNodeMetadata } from "./node-pairing.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js"; +import type { NodeRegistry } from "../gateway/node-registry.js"; type RemoteNodeRecord = { nodeId: string; @@ -19,7 +19,7 @@ type RemoteNodeRecord = { const log = createSubsystemLogger("gateway/skills-remote"); const remoteNodes = new Map(); -let remoteBridge: NodeBridgeServer | null = null; +let remoteRegistry: NodeRegistry | null = null; function describeNode(nodeId: string): string { const record = remoteNodes.get(nodeId); @@ -55,22 +55,16 @@ function extractErrorMessage(err: unknown): string | undefined { function logRemoteBinProbeFailure(nodeId: string, err: unknown) { const message = extractErrorMessage(err); const label = describeNode(nodeId); - if (message?.includes("UNAVAILABLE: node not connected")) { + if (message?.includes("node not connected")) { log.info( `remote bin probe skipped: node not connected (${label}); check nodes list/status for ${label}`, ); return; } - if (message?.includes("UNAVAILABLE: invoke timeout")) { + if (message?.includes("invoke timed out") || message?.includes("timeout")) { log.warn(`remote bin probe timed out (${label}); check node connectivity for ${label}`); return; } - if (message?.includes("bridge connection closed")) { - log.warn( - `remote bin probe aborted: bridge connection closed (${label}); check nodes list/status for ${label}`, - ); - return; - } log.warn(`remote bin probe error (${label}): ${message ?? "unknown"}`); } @@ -117,8 +111,8 @@ function upsertNode(record: { }); } -export function setSkillsRemoteBridge(bridge: NodeBridgeServer | null) { - remoteBridge = bridge; +export function setSkillsRemoteRegistry(registry: NodeRegistry | null) { + remoteRegistry = registry; } export async function primeRemoteSkillsCache() { @@ -198,10 +192,12 @@ function buildBinProbeScript(bins: string[]): string { return `for b in ${escaped}; do if command -v "$b" >/dev/null 2>&1; then echo "$b"; fi; done`; } -function parseBinProbePayload(payloadJSON: string | null | undefined): string[] { - if (!payloadJSON) return []; +function parseBinProbePayload(payloadJSON: string | null | undefined, payload?: unknown): string[] { + if (!payloadJSON && !payload) return []; try { - const parsed = JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown }; + const parsed = payloadJSON + ? (JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown }) + : (payload as { stdout?: unknown; bins?: unknown }); if (Array.isArray(parsed.bins)) { return parsed.bins.map((bin) => String(bin).trim()).filter(Boolean); } @@ -225,7 +221,7 @@ export async function refreshRemoteNodeBins(params: { cfg: ClawdbotConfig; timeoutMs?: number; }) { - if (!remoteBridge) return; + if (!remoteRegistry) return; if (!isMacPlatform(params.platform, params.deviceFamily)) return; const canWhich = supportsSystemWhich(params.commands); const canRun = supportsSystemRun(params.commands); @@ -243,20 +239,20 @@ export async function refreshRemoteNodeBins(params: { try { const binsList = [...requiredBins]; - const res = await remoteBridge.invoke( + const res = await remoteRegistry.invoke( canWhich ? { nodeId: params.nodeId, command: "system.which", - paramsJSON: JSON.stringify({ bins: binsList }), + params: { bins: binsList }, timeoutMs: params.timeoutMs ?? 15_000, } : { nodeId: params.nodeId, command: "system.run", - paramsJSON: JSON.stringify({ + params: { command: ["/bin/sh", "-lc", buildBinProbeScript(binsList)], - }), + }, timeoutMs: params.timeoutMs ?? 15_000, }, ); @@ -264,7 +260,7 @@ export async function refreshRemoteNodeBins(params: { logRemoteBinProbeFailure(params.nodeId, res.error?.message ?? "unknown"); return; } - const bins = parseBinProbePayload(res.payloadJSON); + const bins = parseBinProbePayload(res.payloadJSON, res.payload); recordRemoteNodeBins(params.nodeId, bins); await updatePairedNodeMetadata(params.nodeId, { bins }); bumpSkillsSnapshotVersion({ reason: "remote-node" }); @@ -296,8 +292,8 @@ export function getRemoteSkillEligibility(): SkillEligibilityContext["remote"] | } export async function refreshRemoteBinsForConnectedNodes(cfg: ClawdbotConfig) { - if (!remoteBridge) return; - const connected = remoteBridge.listConnected(); + if (!remoteRegistry) return; + const connected = remoteRegistry.listConnected(); for (const node of connected) { await refreshRemoteNodeBins({ nodeId: node.nodeId, diff --git a/src/infra/bridge/server/tls.ts b/src/infra/tls/gateway.ts similarity index 81% rename from src/infra/bridge/server/tls.ts rename to src/infra/tls/gateway.ts index 0461c1bd0..00e6da46c 100644 --- a/src/infra/bridge/server/tls.ts +++ b/src/infra/tls/gateway.ts @@ -5,12 +5,12 @@ import path from "node:path"; import tls from "node:tls"; import { promisify } from "node:util"; -import type { BridgeTlsConfig } from "../../../config/types.gateway.js"; -import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../../utils.js"; +import type { GatewayTlsConfig } from "../../config/types.gateway.js"; +import { CONFIG_DIR, ensureDir, resolveUserPath, shortenHomeInString } from "../../utils.js"; const execFileAsync = promisify(execFile); -export type BridgeTlsRuntime = { +export type GatewayTlsRuntime = { enabled: boolean; required: boolean; certPath?: string; @@ -59,25 +59,25 @@ async function generateSelfSignedCert(params: { "-out", params.certPath, "-subj", - "/CN=clawdbot-bridge", + "/CN=clawdbot-gateway", ]); await fs.chmod(params.keyPath, 0o600).catch(() => {}); await fs.chmod(params.certPath, 0o600).catch(() => {}); params.log?.info?.( - `bridge tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`, + `gateway tls: generated self-signed cert at ${shortenHomeInString(params.certPath)}`, ); } -export async function loadBridgeTlsRuntime( - cfg: BridgeTlsConfig | undefined, +export async function loadGatewayTlsRuntime( + cfg: GatewayTlsConfig | undefined, log?: { info?: (msg: string) => void; warn?: (msg: string) => void }, -): Promise { +): Promise { if (!cfg || cfg.enabled !== true) return { enabled: false, required: false }; const autoGenerate = cfg.autoGenerate !== false; - const baseDir = path.join(CONFIG_DIR, "bridge", "tls"); - const certPath = resolveUserPath(cfg.certPath ?? path.join(baseDir, "bridge-cert.pem")); - const keyPath = resolveUserPath(cfg.keyPath ?? path.join(baseDir, "bridge-key.pem")); + const baseDir = path.join(CONFIG_DIR, "gateway", "tls"); + const certPath = resolveUserPath(cfg.certPath ?? path.join(baseDir, "gateway-cert.pem")); + const keyPath = resolveUserPath(cfg.keyPath ?? path.join(baseDir, "gateway-key.pem")); const caPath = cfg.caPath ? resolveUserPath(cfg.caPath) : undefined; const hasCert = await fileExists(certPath); @@ -92,7 +92,7 @@ export async function loadBridgeTlsRuntime( required: true, certPath, keyPath, - error: `bridge tls: failed to generate cert (${String(err)})`, + error: `gateway tls: failed to generate cert (${String(err)})`, }; } } @@ -103,7 +103,7 @@ export async function loadBridgeTlsRuntime( required: true, certPath, keyPath, - error: "bridge tls: cert/key missing", + error: "gateway tls: cert/key missing", }; } @@ -121,7 +121,7 @@ export async function loadBridgeTlsRuntime( certPath, keyPath, caPath, - error: "bridge tls: unable to compute certificate fingerprint", + error: "gateway tls: unable to compute certificate fingerprint", }; } @@ -146,7 +146,7 @@ export async function loadBridgeTlsRuntime( certPath, keyPath, caPath, - error: `bridge tls: failed to load cert (${String(err)})`, + error: `gateway tls: failed to load cert (${String(err)})`, }; } } diff --git a/src/infra/widearea-dns.test.ts b/src/infra/widearea-dns.test.ts index b3f278bd0..f9e170db5 100644 --- a/src/infra/widearea-dns.test.ts +++ b/src/infra/widearea-dns.test.ts @@ -1,12 +1,11 @@ import { describe, expect, it } from "vitest"; -import { renderWideAreaBridgeZoneText, WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js"; +import { renderWideAreaGatewayZoneText, WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js"; describe("wide-area DNS-SD zone rendering", () => { - it("renders a clawdbot.internal zone with bridge PTR/SRV/TXT records", () => { - const txt = renderWideAreaBridgeZoneText({ + it("renders a clawdbot.internal zone with gateway PTR/SRV/TXT records", () => { + const txt = renderWideAreaGatewayZoneText({ serial: 2025121701, - bridgePort: 18790, gatewayPort: 18789, displayName: "Mac Studio (Clawdbot)", tailnetIPv4: "100.123.224.76", @@ -20,8 +19,8 @@ describe("wide-area DNS-SD zone rendering", () => { expect(txt).toContain(`$ORIGIN ${WIDE_AREA_DISCOVERY_DOMAIN}`); expect(txt).toContain(`studio-london IN A 100.123.224.76`); expect(txt).toContain(`studio-london IN AAAA fd7a:115c:a1e0::8801:e04c`); - expect(txt).toContain(`_clawdbot-bridge._tcp IN PTR studio-london._clawdbot-bridge._tcp`); - expect(txt).toContain(`studio-london._clawdbot-bridge._tcp IN SRV 0 0 18790 studio-london`); + expect(txt).toContain(`_clawdbot-gateway._tcp IN PTR studio-london._clawdbot-gateway._tcp`); + expect(txt).toContain(`studio-london._clawdbot-gateway._tcp IN SRV 0 0 18789 studio-london`); expect(txt).toContain(`displayName=Mac Studio (Clawdbot)`); expect(txt).toContain(`gatewayPort=18789`); expect(txt).toContain(`sshPort=22`); @@ -29,9 +28,8 @@ describe("wide-area DNS-SD zone rendering", () => { }); it("includes tailnetDns when provided", () => { - const txt = renderWideAreaBridgeZoneText({ + const txt = renderWideAreaGatewayZoneText({ serial: 2025121701, - bridgePort: 18790, gatewayPort: 18789, displayName: "Mac Studio (Clawdbot)", tailnetIPv4: "100.123.224.76", diff --git a/src/infra/widearea-dns.ts b/src/infra/widearea-dns.ts index 75f983d11..b6259402e 100644 --- a/src/infra/widearea-dns.ts +++ b/src/infra/widearea-dns.ts @@ -65,14 +65,13 @@ function computeContentHash(body: string): string { return (h >>> 0).toString(16).padStart(8, "0"); } -export type WideAreaBridgeZoneOpts = { - bridgePort: number; - gatewayPort?: number; +export type WideAreaGatewayZoneOpts = { + gatewayPort: number; displayName: string; tailnetIPv4: string; tailnetIPv6?: string; - bridgeTlsEnabled?: boolean; - bridgeTlsFingerprintSha256?: string; + gatewayTlsEnabled?: boolean; + gatewayTlsFingerprintSha256?: string; instanceLabel?: string; hostLabel?: string; tailnetDns?: string; @@ -80,23 +79,20 @@ export type WideAreaBridgeZoneOpts = { cliPath?: string; }; -function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string { +function renderZone(opts: WideAreaGatewayZoneOpts & { serial: number }): string { const hostname = os.hostname().split(".")[0] ?? "clawdbot"; const hostLabel = dnsLabel(opts.hostLabel ?? hostname, "clawdbot"); - const instanceLabel = dnsLabel(opts.instanceLabel ?? `${hostname}-bridge`, "clawdbot-bridge"); + const instanceLabel = dnsLabel(opts.instanceLabel ?? `${hostname}-gateway`, "clawdbot-gateway"); const txt = [ `displayName=${opts.displayName.trim() || hostname}`, - `transport=bridge`, - `bridgePort=${opts.bridgePort}`, + `transport=gateway`, + `gatewayPort=${opts.gatewayPort}`, ]; - if (typeof opts.gatewayPort === "number" && opts.gatewayPort > 0) { - txt.push(`gatewayPort=${opts.gatewayPort}`); - } - if (opts.bridgeTlsEnabled) { - txt.push(`bridgeTls=1`); - if (opts.bridgeTlsFingerprintSha256) { - txt.push(`bridgeTlsSha256=${opts.bridgeTlsFingerprintSha256}`); + if (opts.gatewayTlsEnabled) { + txt.push(`gatewayTls=1`); + if (opts.gatewayTlsFingerprintSha256) { + txt.push(`gatewayTlsSha256=${opts.gatewayTlsFingerprintSha256}`); } } if (opts.tailnetDns?.trim()) { @@ -122,9 +118,11 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string { records.push(`${hostLabel} IN AAAA ${opts.tailnetIPv6}`); } - records.push(`_clawdbot-bridge._tcp IN PTR ${instanceLabel}._clawdbot-bridge._tcp`); - records.push(`${instanceLabel}._clawdbot-bridge._tcp IN SRV 0 0 ${opts.bridgePort} ${hostLabel}`); - records.push(`${instanceLabel}._clawdbot-bridge._tcp IN TXT ${txt.map(txtQuote).join(" ")}`); + records.push(`_clawdbot-gateway._tcp IN PTR ${instanceLabel}._clawdbot-gateway._tcp`); + records.push( + `${instanceLabel}._clawdbot-gateway._tcp IN SRV 0 0 ${opts.gatewayPort} ${hostLabel}`, + ); + records.push(`${instanceLabel}._clawdbot-gateway._tcp IN TXT ${txt.map(txtQuote).join(" ")}`); const contentBody = `${records.join("\n")}\n`; const hashBody = `${records @@ -137,14 +135,14 @@ function renderZone(opts: WideAreaBridgeZoneOpts & { serial: number }): string { return `; clawdbot-content-hash: ${contentHash}\n${contentBody}`; } -export function renderWideAreaBridgeZoneText( - opts: WideAreaBridgeZoneOpts & { serial: number }, +export function renderWideAreaGatewayZoneText( + opts: WideAreaGatewayZoneOpts & { serial: number }, ): string { return renderZone(opts); } -export async function writeWideAreaBridgeZone( - opts: WideAreaBridgeZoneOpts, +export async function writeWideAreaGatewayZone( + opts: WideAreaGatewayZoneOpts, ): Promise<{ zonePath: string; changed: boolean }> { const zonePath = getWideAreaZonePath(); await ensureDir(path.dirname(zonePath)); @@ -157,7 +155,7 @@ export async function writeWideAreaBridgeZone( } })(); - const nextNoSerial = renderWideAreaBridgeZoneText({ ...opts, serial: 0 }); + const nextNoSerial = renderWideAreaGatewayZoneText({ ...opts, serial: 0 }); const nextHash = extractContentHash(nextNoSerial); const existingHash = existing ? extractContentHash(existing) : null; @@ -167,7 +165,7 @@ export async function writeWideAreaBridgeZone( const existingSerial = existing ? extractSerial(existing) : null; const serial = nextSerial(existingSerial, new Date()); - const next = renderWideAreaBridgeZoneText({ ...opts, serial }); + const next = renderWideAreaGatewayZoneText({ ...opts, serial }); fs.writeFileSync(zonePath, next, "utf-8"); return { zonePath, changed: true }; } diff --git a/src/node-host/bridge-client.ts b/src/node-host/bridge-client.ts deleted file mode 100644 index 4d7e9e7c4..000000000 --- a/src/node-host/bridge-client.ts +++ /dev/null @@ -1,308 +0,0 @@ -import crypto from "node:crypto"; -import net from "node:net"; -import tls from "node:tls"; - -import type { - BridgeErrorFrame, - BridgeEventFrame, - BridgeHelloFrame, - BridgeHelloOkFrame, - BridgeInvokeRequestFrame, - BridgeInvokeResponseFrame, - BridgePairOkFrame, - BridgePairRequestFrame, - BridgePingFrame, - BridgePongFrame, - BridgeRPCRequestFrame, - BridgeRPCResponseFrame, -} from "../infra/bridge/server/types.js"; - -export type BridgeClientOptions = { - host: string; - port: number; - tls?: boolean; - tlsFingerprint?: string; - nodeId: string; - token?: string; - displayName?: string; - platform?: string; - version?: string; - coreVersion?: string; - uiVersion?: string; - deviceFamily?: string; - modelIdentifier?: string; - caps?: string[]; - commands?: string[]; - permissions?: Record; - onInvoke?: (frame: BridgeInvokeRequestFrame) => void | Promise; - onEvent?: (frame: BridgeEventFrame) => void | Promise; - onPairToken?: (token: string) => void | Promise; - onAuthReset?: () => void | Promise; - onConnected?: (hello: BridgeHelloOkFrame) => void | Promise; - onDisconnected?: (err?: Error) => void | Promise; - log?: { info?: (msg: string) => void; warn?: (msg: string) => void }; -}; - -type PendingRpc = { - resolve: (frame: BridgeRPCResponseFrame) => void; - reject: (err: Error) => void; - timer?: NodeJS.Timeout; -}; - -function normalizeFingerprint(input: string): string { - return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase(); -} - -function extractFingerprint(raw: tls.PeerCertificate | tls.DetailedPeerCertificate): string | null { - const value = "fingerprint256" in raw ? raw.fingerprint256 : undefined; - if (!value) return null; - return normalizeFingerprint(value); -} - -export class BridgeClient { - private opts: BridgeClientOptions; - private socket: net.Socket | tls.TLSSocket | null = null; - private buffer = ""; - private pendingRpc = new Map(); - private connected = false; - private helloReady: Promise | null = null; - private helloResolve: (() => void) | null = null; - private helloReject: ((err: Error) => void) | null = null; - - constructor(opts: BridgeClientOptions) { - this.opts = opts; - } - - async connect(): Promise { - if (this.connected) return; - this.helloReady = new Promise((resolve, reject) => { - this.helloResolve = resolve; - this.helloReject = reject; - }); - const socket = this.opts.tls - ? tls.connect({ - host: this.opts.host, - port: this.opts.port, - rejectUnauthorized: false, - }) - : net.connect({ host: this.opts.host, port: this.opts.port }); - this.socket = socket; - socket.setNoDelay(true); - - socket.on("connect", () => { - this.sendHello(); - }); - socket.on("error", (err: Error) => { - this.handleDisconnect(err); - }); - socket.on("close", () => { - this.handleDisconnect(); - }); - socket.on("data", (chunk: Buffer) => { - this.buffer += chunk.toString("utf8"); - this.flush(); - }); - - if (this.opts.tls && socket instanceof tls.TLSSocket && this.opts.tlsFingerprint) { - socket.once("secureConnect", () => { - const cert = socket.getPeerCertificate(true); - const fingerprint = cert ? extractFingerprint(cert) : null; - if (!fingerprint || fingerprint !== normalizeFingerprint(this.opts.tlsFingerprint ?? "")) { - const err = new Error("bridge tls fingerprint mismatch"); - this.handleDisconnect(err); - socket.destroy(err); - } - }); - } - - await this.helloReady; - } - - async close(): Promise { - if (this.socket) { - this.socket.destroy(); - this.socket = null; - } - this.connected = false; - this.pendingRpc.forEach((pending) => { - if (pending.timer) clearTimeout(pending.timer); - pending.reject(new Error("bridge client closed")); - }); - this.pendingRpc.clear(); - } - - async request(method: string, params: Record | null = null, timeoutMs = 5000) { - const id = crypto.randomUUID(); - const frame: BridgeRPCRequestFrame = { - type: "req", - id, - method, - paramsJSON: params ? JSON.stringify(params) : null, - }; - const res = await new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pendingRpc.delete(id); - reject(new Error(`bridge request timeout (${method})`)); - }, timeoutMs); - this.pendingRpc.set(id, { resolve, reject, timer }); - this.send(frame); - }); - if (!res.ok) { - throw new Error(res.error?.message ?? "bridge request failed"); - } - return res.payloadJSON ? JSON.parse(res.payloadJSON) : null; - } - - sendEvent(event: string, payload?: unknown) { - const frame: BridgeEventFrame = { - type: "event", - event, - payloadJSON: payload ? JSON.stringify(payload) : null, - }; - this.send(frame); - } - - sendInvokeResponse(frame: BridgeInvokeResponseFrame) { - this.send(frame); - } - - private sendHello() { - const hello: BridgeHelloFrame = { - type: "hello", - nodeId: this.opts.nodeId, - token: this.opts.token, - displayName: this.opts.displayName, - platform: this.opts.platform, - version: this.opts.version, - coreVersion: this.opts.coreVersion, - uiVersion: this.opts.uiVersion, - deviceFamily: this.opts.deviceFamily, - modelIdentifier: this.opts.modelIdentifier, - caps: this.opts.caps, - commands: this.opts.commands, - permissions: this.opts.permissions, - }; - this.send(hello); - } - - private sendPairRequest() { - const req: BridgePairRequestFrame = { - type: "pair-request", - nodeId: this.opts.nodeId, - displayName: this.opts.displayName, - platform: this.opts.platform, - version: this.opts.version, - coreVersion: this.opts.coreVersion, - uiVersion: this.opts.uiVersion, - deviceFamily: this.opts.deviceFamily, - modelIdentifier: this.opts.modelIdentifier, - caps: this.opts.caps, - commands: this.opts.commands, - permissions: this.opts.permissions, - }; - this.send(req); - } - - private send(frame: object) { - if (!this.socket) return; - this.socket.write(`${JSON.stringify(frame)}\n`); - } - - private handleDisconnect(err?: Error) { - if (!this.connected && this.helloReject) { - this.helloReject(err ?? new Error("bridge connection failed")); - this.helloResolve = null; - this.helloReject = null; - } - if (!this.connected && !this.socket) return; - this.connected = false; - this.socket = null; - this.pendingRpc.forEach((pending) => { - if (pending.timer) clearTimeout(pending.timer); - pending.reject(err ?? new Error("bridge connection closed")); - }); - this.pendingRpc.clear(); - void this.opts.onDisconnected?.(err); - } - - private flush() { - while (true) { - const idx = this.buffer.indexOf("\n"); - if (idx === -1) break; - const line = this.buffer.slice(0, idx).trim(); - this.buffer = this.buffer.slice(idx + 1); - if (!line) continue; - let frame: { type?: string; [key: string]: unknown }; - try { - frame = JSON.parse(line) as { type?: string }; - } catch { - continue; - } - this.handleFrame(frame as BridgeErrorFrame); - } - } - - private handleFrame(frame: { type?: string; [key: string]: unknown }) { - const type = String(frame.type ?? ""); - switch (type) { - case "hello-ok": { - this.connected = true; - this.helloResolve?.(); - this.helloResolve = null; - this.helloReject = null; - void this.opts.onConnected?.(frame as BridgeHelloOkFrame); - return; - } - case "pair-ok": { - const token = String((frame as BridgePairOkFrame).token ?? "").trim(); - if (token) { - this.opts.token = token; - void this.opts.onPairToken?.(token); - } - return; - } - case "error": { - const code = String((frame as BridgeErrorFrame).code ?? ""); - if (code === "NOT_PAIRED" || code === "UNAUTHORIZED") { - this.opts.token = undefined; - void this.opts.onAuthReset?.(); - this.sendPairRequest(); - return; - } - this.handleDisconnect(new Error((frame as BridgeErrorFrame).message ?? "bridge error")); - return; - } - case "pong": - return; - case "ping": { - const ping = frame as BridgePingFrame; - const pong: BridgePongFrame = { type: "pong", id: String(ping.id ?? "") }; - this.send(pong); - return; - } - case "event": { - void this.opts.onEvent?.(frame as BridgeEventFrame); - return; - } - case "res": { - const res = frame as BridgeRPCResponseFrame; - const pending = this.pendingRpc.get(res.id); - if (pending) { - if (pending.timer) clearTimeout(pending.timer); - this.pendingRpc.delete(res.id); - pending.resolve(res); - } - return; - } - case "invoke": { - void this.opts.onInvoke?.(frame as BridgeInvokeRequestFrame); - return; - } - case "invoke-res": { - return; - } - default: - return; - } - } -} diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index 1b69015a3..bc9a189e2 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -4,13 +4,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import type { BridgeInvokeRequestFrame } from "../infra/bridge/server/types.js"; import { addAllowlistEntry, matchAllowlist, normalizeExecApprovals, recordAllowlistUse, - requestExecApprovalViaSocket, resolveCommandResolution, resolveExecApprovals, ensureExecApprovals, @@ -26,10 +24,16 @@ import { type ExecHostRunResult, } from "../infra/exec-host.js"; import { getMachineDisplayName } from "../infra/machine-name.js"; +import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; +import { loadConfig } from "../config/config.js"; import { VERSION } from "../version.js"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../utils/message-channel.js"; -import { BridgeClient } from "./bridge-client.js"; import { ensureNodeHostConfig, saveNodeHostConfig, type NodeHostGatewayConfig } from "./config.js"; +import { GatewayClient } from "../gateway/client.js"; type NodeHostRunOptions = { gatewayHost: string; @@ -49,6 +53,7 @@ type SystemRunParams = { needsScreenRecording?: boolean | null; agentId?: string | null; sessionKey?: string | null; + approved?: boolean | null; }; type SystemWhichParams = { @@ -89,6 +94,15 @@ type ExecEventPayload = { reason?: string; }; +type NodeInvokeRequestPayload = { + id: string; + nodeId: string; + command: string; + paramsJSON?: string | null; + timeoutMs?: number | null; + idempotencyKey?: string | null; +}; + const OUTPUT_CAP = 200_000; const OUTPUT_EVENT_TAIL = 20_000; @@ -331,7 +345,6 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const nodeId = opts.nodeId?.trim() || config.nodeId; if (nodeId !== config.nodeId) { config.nodeId = nodeId; - config.token = undefined; } const displayName = opts.displayName?.trim() || config.displayName || (await getMachineDisplayName()); @@ -339,37 +352,38 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { const gateway: NodeHostGatewayConfig = { host: opts.gatewayHost, port: opts.gatewayPort, - tls: opts.gatewayTls === true, + tls: opts.gatewayTls ?? loadConfig().gateway?.tls?.enabled ?? false, tlsFingerprint: opts.gatewayTlsFingerprint, }; config.gateway = gateway; await saveNodeHostConfig(config); - let disconnectResolve: (() => void) | null = null; - let disconnectSignal = false; - const waitForDisconnect = () => - new Promise((resolve) => { - if (disconnectSignal) { - disconnectSignal = false; - resolve(); - return; - } - disconnectResolve = resolve; - }); + const cfg = loadConfig(); + const isRemoteMode = cfg.gateway?.mode === "remote"; + const token = + process.env.CLAWDBOT_GATEWAY_TOKEN?.trim() || + (isRemoteMode ? cfg.gateway?.remote?.token : cfg.gateway?.auth?.token); + const password = + process.env.CLAWDBOT_GATEWAY_PASSWORD?.trim() || + (isRemoteMode ? cfg.gateway?.remote?.password : cfg.gateway?.auth?.password); - const client = new BridgeClient({ - host: gateway.host ?? "127.0.0.1", - port: gateway.port ?? 18790, - tls: gateway.tls, - tlsFingerprint: gateway.tlsFingerprint, - nodeId, - token: config.token, - displayName, + const host = gateway.host ?? "127.0.0.1"; + const port = gateway.port ?? 18789; + const scheme = gateway.tls ? "wss" : "ws"; + const url = `${scheme}://${host}:${port}`; + + const client = new GatewayClient({ + url, + token: token?.trim() || undefined, + password: password?.trim() || undefined, + instanceId: nodeId, + clientName: GATEWAY_CLIENT_NAMES.NODE_HOST, + clientDisplayName: displayName, + clientVersion: VERSION, platform: process.platform, - version: VERSION, - coreVersion: VERSION, - deviceFamily: os.platform(), - modelIdentifier: os.hostname(), + mode: GATEWAY_CLIENT_MODES.NODE, + role: "node", + scopes: [], caps: ["system"], commands: [ "system.run", @@ -377,25 +391,23 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { "system.execApprovals.get", "system.execApprovals.set", ], - onPairToken: async (token) => { - config.token = token; - await saveNodeHostConfig(config); + permissions: undefined, + deviceIdentity: loadOrCreateDeviceIdentity(), + tlsFingerprint: gateway.tlsFingerprint, + onEvent: (evt) => { + if (evt.event !== "node.invoke.request") return; + const payload = coerceNodeInvokePayload(evt.payload); + if (!payload) return; + void handleInvoke(payload, client, skillBins); }, - onAuthReset: async () => { - if (!config.token) return; - config.token = undefined; - await saveNodeHostConfig(config); + onConnectError: (err) => { + // keep retrying (handled by GatewayClient) + // eslint-disable-next-line no-console + console.error(`node host gateway connect failed: ${err.message}`); }, - onInvoke: async (frame) => { - await handleInvoke(frame, client, skillBins); - }, - onDisconnected: () => { - if (disconnectResolve) { - disconnectResolve(); - disconnectResolve = null; - } else { - disconnectSignal = true; - } + onClose: (code, reason) => { + // eslint-disable-next-line no-console + console.error(`node host gateway closed (${code}): ${reason}`); }, }); @@ -408,20 +420,13 @@ export async function runNodeHost(opts: NodeHostRunOptions): Promise { return bins; }); - while (true) { - try { - await client.connect(); - await waitForDisconnect(); - } catch { - // ignore connect errors; retry - } - await new Promise((resolve) => setTimeout(resolve, 1500)); - } + client.start(); + await new Promise(() => {}); } async function handleInvoke( - frame: BridgeInvokeRequestFrame, - client: BridgeClient, + frame: NodeInvokeRequestPayload, + client: GatewayClient, skillBins: SkillBinsCache, ) { const command = String(frame.command ?? ""); @@ -435,16 +440,12 @@ async function handleInvoke( hash: snapshot.hash, file: redactExecApprovals(snapshot.file), }; - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: true, payloadJSON: JSON.stringify(payload), }); } catch (err) { - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "INVALID_REQUEST", message: String(err) }, }); @@ -482,16 +483,12 @@ async function handleInvoke( hash: nextSnapshot.hash, file: redactExecApprovals(nextSnapshot.file), }; - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: true, payloadJSON: JSON.stringify(payload), }); } catch (err) { - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "INVALID_REQUEST", message: String(err) }, }); @@ -507,16 +504,12 @@ async function handleInvoke( } const env = sanitizeEnv(undefined); const payload = await handleSystemWhich(params, env); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: true, payloadJSON: JSON.stringify(payload), }); } catch (err) { - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "INVALID_REQUEST", message: String(err) }, }); @@ -525,9 +518,7 @@ async function handleInvoke( } if (command !== "system.run") { - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "UNAVAILABLE", message: "command not supported" }, }); @@ -538,9 +529,7 @@ async function handleInvoke( try { params = decodeParams(frame.paramsJSON); } catch (err) { - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "INVALID_REQUEST", message: String(err) }, }); @@ -548,9 +537,7 @@ async function handleInvoke( } if (!Array.isArray(params.command) || params.command.length === 0) { - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "INVALID_REQUEST", message: "command required" }, }); @@ -564,7 +551,6 @@ async function handleInvoke( const approvals = resolveExecApprovals(agentId); const security = approvals.agent.security; const ask = approvals.agent.ask; - const askFallback = approvals.agent.askFallback; const autoAllowSkills = approvals.agent.autoAllowSkills; const sessionKey = params.sessionKey?.trim() || "node"; const runId = crypto.randomUUID(); @@ -591,7 +577,8 @@ async function handleInvoke( }; const response = await runViaMacAppExecHost({ approvals, request: execRequest }); if (!response) { - client.sendEvent( + await sendNodeEvent( + client, "exec.denied", buildExecEventPayload({ sessionKey, @@ -601,9 +588,7 @@ async function handleInvoke( reason: "companion-unavailable", }), ); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "UNAVAILABLE", @@ -615,7 +600,8 @@ async function handleInvoke( if (!response.ok) { const reason = response.error.reason ?? "approval-required"; - client.sendEvent( + await sendNodeEvent( + client, "exec.denied", buildExecEventPayload({ sessionKey, @@ -625,9 +611,7 @@ async function handleInvoke( reason, }), ); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "UNAVAILABLE", message: response.error.message }, }); @@ -636,7 +620,8 @@ async function handleInvoke( const result: ExecHostRunResult = response.payload; const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n"); - client.sendEvent( + await sendNodeEvent( + client, "exec.finished", buildExecEventPayload({ sessionKey, @@ -649,9 +634,7 @@ async function handleInvoke( output: combined, }), ); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: true, payloadJSON: JSON.stringify(result), }); @@ -659,7 +642,8 @@ async function handleInvoke( } if (security === "deny") { - client.sendEvent( + await sendNodeEvent( + client, "exec.denied", buildExecEventPayload({ sessionKey, @@ -669,9 +653,7 @@ async function handleInvoke( reason: "security=deny", }), ); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DISABLED: security=deny" }, }); @@ -682,99 +664,33 @@ async function handleInvoke( ask === "always" || (ask === "on-miss" && security === "allowlist" && !allowlistMatch && !skillAllow); - let approvedByAsk = false; - if (requiresAsk) { - const decision = await requestExecApprovalViaSocket({ - socketPath: approvals.socketPath, - token: approvals.token, - request: { - command: cmdText, - cwd: params.cwd ?? undefined, + const approvedByAsk = params.approved === true; + if (requiresAsk && !approvedByAsk) { + await sendNodeEvent( + client, + "exec.denied", + buildExecEventPayload({ + sessionKey, + runId, host: "node", - security, - ask, - agentId, - resolvedPath: resolution?.resolvedPath ?? null, - }, + command: cmdText, + reason: "approval-required", + }), + ); + await sendInvokeResult(client, frame, { + ok: false, + error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" }, }); - if (decision === "deny") { - client.sendEvent( - "exec.denied", - buildExecEventPayload({ - sessionKey, - runId, - host: "node", - command: cmdText, - reason: "user-denied", - }), - ); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, - ok: false, - error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: user denied" }, - }); - return; - } - if (!decision) { - if (askFallback === "full") { - approvedByAsk = true; - } else if (askFallback === "allowlist") { - if (allowlistMatch || skillAllow) { - approvedByAsk = true; - } else { - client.sendEvent( - "exec.denied", - buildExecEventPayload({ - sessionKey, - runId, - host: "node", - command: cmdText, - reason: "approval-required", - }), - ); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, - ok: false, - error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" }, - }); - return; - } - } else { - client.sendEvent( - "exec.denied", - buildExecEventPayload({ - sessionKey, - runId, - host: "node", - command: cmdText, - reason: "approval-required", - }), - ); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, - ok: false, - error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: approval required" }, - }); - return; - } - } - if (decision === "allow-once") { - approvedByAsk = true; - } - if (decision === "allow-always") { - approvedByAsk = true; - if (security === "allowlist") { - const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? ""; - if (pattern) addAllowlistEntry(approvals.file, agentId, pattern); - } - } + return; + } + if (approvedByAsk && security === "allowlist") { + const pattern = resolution?.resolvedPath ?? resolution?.rawExecutable ?? argv[0] ?? ""; + if (pattern) addAllowlistEntry(approvals.file, agentId, pattern); } if (security === "allowlist" && !allowlistMatch && !skillAllow && !approvedByAsk) { - client.sendEvent( + await sendNodeEvent( + client, "exec.denied", buildExecEventPayload({ sessionKey, @@ -784,9 +700,7 @@ async function handleInvoke( reason: "allowlist-miss", }), ); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "UNAVAILABLE", message: "SYSTEM_RUN_DENIED: allowlist miss" }, }); @@ -798,7 +712,8 @@ async function handleInvoke( } if (params.needsScreenRecording === true) { - client.sendEvent( + await sendNodeEvent( + client, "exec.denied", buildExecEventPayload({ sessionKey, @@ -808,16 +723,15 @@ async function handleInvoke( reason: "permission:screenRecording", }), ); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: false, error: { code: "UNAVAILABLE", message: "PERMISSION_MISSING: screenRecording" }, }); return; } - client.sendEvent( + await sendNodeEvent( + client, "exec.started", buildExecEventPayload({ sessionKey, @@ -842,7 +756,8 @@ async function handleInvoke( } } const combined = [result.stdout, result.stderr, result.error].filter(Boolean).join("\n"); - client.sendEvent( + await sendNodeEvent( + client, "exec.finished", buildExecEventPayload({ sessionKey, @@ -856,9 +771,7 @@ async function handleInvoke( }), ); - client.sendInvokeResponse({ - type: "invoke-res", - id: frame.id, + await sendInvokeResult(client, frame, { ok: true, payloadJSON: JSON.stringify({ exitCode: result.exitCode, @@ -877,3 +790,68 @@ function decodeParams(raw?: string | null): T { } return JSON.parse(raw) as T; } + +function coerceNodeInvokePayload(payload: unknown): NodeInvokeRequestPayload | null { + if (!payload || typeof payload !== "object") return null; + const obj = payload as Record; + const id = typeof obj.id === "string" ? obj.id.trim() : ""; + const nodeId = typeof obj.nodeId === "string" ? obj.nodeId.trim() : ""; + const command = typeof obj.command === "string" ? obj.command.trim() : ""; + if (!id || !nodeId || !command) return null; + const paramsJSON = + typeof obj.paramsJSON === "string" + ? obj.paramsJSON + : obj.params !== undefined + ? JSON.stringify(obj.params) + : null; + const timeoutMs = typeof obj.timeoutMs === "number" ? obj.timeoutMs : null; + const idempotencyKey = + typeof obj.idempotencyKey === "string" ? obj.idempotencyKey : null; + return { + id, + nodeId, + command, + paramsJSON, + timeoutMs, + idempotencyKey, + }; +} + +async function sendInvokeResult( + client: GatewayClient, + frame: NodeInvokeRequestPayload, + result: { + ok: boolean; + payload?: unknown; + payloadJSON?: string | null; + error?: { code?: string; message?: string } | null; + }, +) { + try { + await client.request("node.invoke.result", { + id: frame.id, + nodeId: frame.nodeId, + ok: result.ok, + payload: result.payload, + payloadJSON: result.payloadJSON ?? null, + error: result.error ?? null, + }); + } catch { + // ignore: node invoke responses are best-effort + } +} + +async function sendNodeEvent( + client: GatewayClient, + event: string, + payload: unknown, +) { + try { + await client.request("node.event", { + event, + payloadJSON: payload ? JSON.stringify(payload) : null, + }); + } catch { + // ignore: node events are best-effort + } +}