mirror of
https://github.com/openclaw/openclaw.git
synced 2026-02-19 18:39:20 -05:00
refactor: remove bridge protocol
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,462 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
struct BridgeNodeInfo: Sendable {
|
||||
var nodeId: String
|
||||
var displayName: String?
|
||||
var platform: String?
|
||||
var version: String?
|
||||
var coreVersion: String?
|
||||
var uiVersion: String?
|
||||
var deviceFamily: String?
|
||||
var modelIdentifier: String?
|
||||
var remoteAddress: String?
|
||||
var caps: [String]?
|
||||
}
|
||||
|
||||
actor BridgeConnectionHandler {
|
||||
private let connection: NWConnection
|
||||
private let logger: Logger
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private let queue = DispatchQueue(label: "com.clawdbot.bridge.connection")
|
||||
|
||||
private var buffer = Data()
|
||||
private var isAuthenticated = false
|
||||
private var nodeId: String?
|
||||
private var pendingInvokes: [String: CheckedContinuation<BridgeInvokeResponse, Error>] = [:]
|
||||
private var isClosed = false
|
||||
|
||||
init(connection: NWConnection, logger: Logger) {
|
||||
self.connection = connection
|
||||
self.logger = logger
|
||||
}
|
||||
|
||||
enum AuthResult: Sendable {
|
||||
case ok
|
||||
case notPaired
|
||||
case unauthorized
|
||||
case error(code: String, message: String)
|
||||
}
|
||||
|
||||
enum PairResult: Sendable {
|
||||
case ok(token: String)
|
||||
case rejected
|
||||
case error(code: String, message: String)
|
||||
}
|
||||
|
||||
private struct FrameContext: Sendable {
|
||||
var serverName: String
|
||||
var resolveAuth: @Sendable (BridgeHello) async -> AuthResult
|
||||
var handlePair: @Sendable (BridgePairRequest) async -> PairResult
|
||||
var onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)?
|
||||
var onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?
|
||||
var onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?
|
||||
}
|
||||
|
||||
func run(
|
||||
resolveAuth: @escaping @Sendable (BridgeHello) async -> AuthResult,
|
||||
handlePair: @escaping @Sendable (BridgePairRequest) async -> PairResult,
|
||||
onAuthenticated: (@Sendable (BridgeNodeInfo) async -> Void)? = nil,
|
||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)? = nil,
|
||||
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)? = nil) async
|
||||
{
|
||||
self.configureStateLogging()
|
||||
self.connection.start(queue: self.queue)
|
||||
|
||||
let context = FrameContext(
|
||||
serverName: Host.current().localizedName ?? ProcessInfo.processInfo.hostName,
|
||||
resolveAuth: resolveAuth,
|
||||
handlePair: handlePair,
|
||||
onAuthenticated: onAuthenticated,
|
||||
onEvent: onEvent,
|
||||
onRequest: onRequest)
|
||||
|
||||
while true {
|
||||
do {
|
||||
guard let line = try await self.receiveLine() else { break }
|
||||
guard let data = line.data(using: .utf8) else { continue }
|
||||
let base = try self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
try await self.handleFrame(
|
||||
baseType: base.type,
|
||||
data: data,
|
||||
context: context)
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
await self.close(with: onDisconnected)
|
||||
}
|
||||
|
||||
private func configureStateLogging() {
|
||||
self.connection.stateUpdateHandler = { [logger] state in
|
||||
switch state {
|
||||
case .ready:
|
||||
logger.debug("bridge conn ready")
|
||||
case let .failed(err):
|
||||
logger.error("bridge conn failed: \(err.localizedDescription, privacy: .public)")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleFrame(
|
||||
baseType: String,
|
||||
data: Data,
|
||||
context: FrameContext) async throws
|
||||
{
|
||||
switch baseType {
|
||||
case "hello":
|
||||
await self.handleHelloFrame(
|
||||
data: data,
|
||||
context: context)
|
||||
case "pair-request":
|
||||
await self.handlePairRequestFrame(
|
||||
data: data,
|
||||
context: context)
|
||||
case "event":
|
||||
await self.handleEventFrame(data: data, onEvent: context.onEvent)
|
||||
case "req":
|
||||
try await self.handleRPCRequestFrame(data: data, onRequest: context.onRequest)
|
||||
case "ping":
|
||||
try await self.handlePingFrame(data: data)
|
||||
case "invoke-res":
|
||||
await self.handleInvokeResponseFrame(data: data)
|
||||
default:
|
||||
await self.sendError(code: "INVALID_REQUEST", message: "unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
private func handleHelloFrame(
|
||||
data: Data,
|
||||
context: FrameContext) async
|
||||
{
|
||||
do {
|
||||
let hello = try self.decoder.decode(BridgeHello.self, from: data)
|
||||
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.nodeId = nodeId
|
||||
let result = await context.resolveAuth(hello)
|
||||
await self.handleAuthResult(result, serverName: context.serverName)
|
||||
if case .ok = result {
|
||||
await context.onAuthenticated?(
|
||||
BridgeNodeInfo(
|
||||
nodeId: nodeId,
|
||||
displayName: hello.displayName,
|
||||
platform: hello.platform,
|
||||
version: hello.version,
|
||||
coreVersion: hello.coreVersion,
|
||||
uiVersion: hello.uiVersion,
|
||||
deviceFamily: hello.deviceFamily,
|
||||
modelIdentifier: hello.modelIdentifier,
|
||||
remoteAddress: self.remoteAddressString(),
|
||||
caps: hello.caps))
|
||||
}
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePairRequestFrame(
|
||||
data: Data,
|
||||
context: FrameContext) async
|
||||
{
|
||||
do {
|
||||
let req = try self.decoder.decode(BridgePairRequest.self, from: data)
|
||||
let nodeId = req.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
self.nodeId = nodeId
|
||||
let enriched = BridgePairRequest(
|
||||
type: req.type,
|
||||
nodeId: nodeId,
|
||||
displayName: req.displayName,
|
||||
platform: req.platform,
|
||||
version: req.version,
|
||||
coreVersion: req.coreVersion,
|
||||
uiVersion: req.uiVersion,
|
||||
deviceFamily: req.deviceFamily,
|
||||
modelIdentifier: req.modelIdentifier,
|
||||
caps: req.caps,
|
||||
commands: req.commands,
|
||||
remoteAddress: self.remoteAddressString(),
|
||||
silent: req.silent)
|
||||
let result = await context.handlePair(enriched)
|
||||
await self.handlePairResult(result, serverName: context.serverName)
|
||||
if case .ok = result {
|
||||
await context.onAuthenticated?(
|
||||
BridgeNodeInfo(
|
||||
nodeId: nodeId,
|
||||
displayName: enriched.displayName,
|
||||
platform: enriched.platform,
|
||||
version: enriched.version,
|
||||
coreVersion: enriched.coreVersion,
|
||||
uiVersion: enriched.uiVersion,
|
||||
deviceFamily: enriched.deviceFamily,
|
||||
modelIdentifier: enriched.modelIdentifier,
|
||||
remoteAddress: enriched.remoteAddress,
|
||||
caps: enriched.caps))
|
||||
}
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEventFrame(
|
||||
data: Data,
|
||||
onEvent: (@Sendable (String, BridgeEventFrame) async -> Void)?) async
|
||||
{
|
||||
guard self.isAuthenticated, let nodeId = self.nodeId else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let evt = try self.decoder.decode(BridgeEventFrame.self, from: data)
|
||||
await onEvent?(nodeId, evt)
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRPCRequestFrame(
|
||||
data: Data,
|
||||
onRequest: (@Sendable (String, BridgeRPCRequest) async -> BridgeRPCResponse)?) async throws
|
||||
{
|
||||
let req = try self.decoder.decode(BridgeRPCRequest.self, from: data)
|
||||
guard self.isAuthenticated, let nodeId = self.nodeId else {
|
||||
try await self.send(
|
||||
BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAUTHORIZED", message: "not authenticated")))
|
||||
return
|
||||
}
|
||||
|
||||
if let onRequest {
|
||||
let res = await onRequest(nodeId, req)
|
||||
try await self.send(res)
|
||||
} else {
|
||||
try await self.send(
|
||||
BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: "RPC not supported")))
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePingFrame(data: Data) async throws {
|
||||
guard self.isAuthenticated else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
return
|
||||
}
|
||||
let ping = try self.decoder.decode(BridgePing.self, from: data)
|
||||
try await self.send(BridgePong(type: "pong", id: ping.id))
|
||||
}
|
||||
|
||||
private func handleInvokeResponseFrame(data: Data) async {
|
||||
guard self.isAuthenticated else {
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "not authenticated")
|
||||
return
|
||||
}
|
||||
do {
|
||||
let res = try self.decoder.decode(BridgeInvokeResponse.self, from: data)
|
||||
if let cont = self.pendingInvokes.removeValue(forKey: res.id) {
|
||||
cont.resume(returning: res)
|
||||
}
|
||||
} catch {
|
||||
await self.sendError(code: "INVALID_REQUEST", message: error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
private func remoteAddressString() -> String? {
|
||||
switch self.connection.endpoint {
|
||||
case let .hostPort(host: host, port: _):
|
||||
let value = String(describing: host)
|
||||
return value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func remoteAddress() -> String? {
|
||||
self.remoteAddressString()
|
||||
}
|
||||
|
||||
private func handlePairResult(_ result: PairResult, serverName: String) async {
|
||||
switch result {
|
||||
case let .ok(token):
|
||||
do {
|
||||
try await self.send(BridgePairOk(type: "pair-ok", token: token))
|
||||
self.isAuthenticated = true
|
||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
try await self.send(
|
||||
BridgeHelloOk(
|
||||
type: "hello-ok",
|
||||
serverName: serverName,
|
||||
mainSessionKey: mainSessionKey))
|
||||
} catch {
|
||||
self.logger.error("bridge send pair-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
case .rejected:
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "pairing rejected")
|
||||
case let .error(code, message):
|
||||
await self.sendError(code: code, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleAuthResult(_ result: AuthResult, serverName: String) async {
|
||||
switch result {
|
||||
case .ok:
|
||||
self.isAuthenticated = true
|
||||
do {
|
||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
try await self.send(
|
||||
BridgeHelloOk(
|
||||
type: "hello-ok",
|
||||
serverName: serverName,
|
||||
mainSessionKey: mainSessionKey))
|
||||
} catch {
|
||||
self.logger.error("bridge send hello-ok failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
case .notPaired:
|
||||
await self.sendError(code: "NOT_PAIRED", message: "pairing required")
|
||||
case .unauthorized:
|
||||
await self.sendError(code: "UNAUTHORIZED", message: "invalid token")
|
||||
case let .error(code, message):
|
||||
await self.sendError(code: code, message: message)
|
||||
}
|
||||
}
|
||||
|
||||
private func sendError(code: String, message: String) async {
|
||||
do {
|
||||
try await self.send(BridgeErrorFrame(type: "error", code: code, message: message))
|
||||
} catch {
|
||||
self.logger.error("bridge send error failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func invoke(command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
|
||||
guard self.isAuthenticated else {
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAUTHORIZED: not authenticated",
|
||||
])
|
||||
}
|
||||
let id = UUID().uuidString
|
||||
let req = BridgeInvokeRequest(type: "invoke", id: id, command: command, paramsJSON: paramsJSON)
|
||||
|
||||
let timeoutTask = Task {
|
||||
try await Task.sleep(nanoseconds: 15 * 1_000_000_000)
|
||||
await self.timeoutInvoke(id: id)
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
return try await withCheckedThrowingContinuation { cont in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.beginInvoke(id: id, request: req, continuation: cont)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beginInvoke(
|
||||
id: String,
|
||||
request: BridgeInvokeRequest,
|
||||
continuation: CheckedContinuation<BridgeInvokeResponse, Error>) async
|
||||
{
|
||||
self.pendingInvokes[id] = continuation
|
||||
do {
|
||||
try await self.send(request)
|
||||
} catch {
|
||||
await self.failInvoke(id: id, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutInvoke(id: String) async {
|
||||
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: invoke timeout",
|
||||
]))
|
||||
}
|
||||
|
||||
private func failInvoke(id: String, error: Error) async {
|
||||
guard let cont = self.pendingInvokes.removeValue(forKey: id) else { return }
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable) async throws {
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A) // \n
|
||||
let _: Void = try await withCheckedThrowingContinuation { cont in
|
||||
self.connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err {
|
||||
cont.resume(throwing: err)
|
||||
} else {
|
||||
cont.resume(returning: ())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sendServerEvent(event: String, payloadJSON: String?) async {
|
||||
guard self.isAuthenticated else { return }
|
||||
do {
|
||||
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
|
||||
} catch {
|
||||
self.logger.error("bridge send event failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLine() async throws -> String? {
|
||||
while true {
|
||||
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
||||
let lineData = self.buffer.prefix(upTo: idx)
|
||||
self.buffer.removeSubrange(...idx)
|
||||
return String(data: lineData, encoding: .utf8)
|
||||
}
|
||||
|
||||
let chunk = try await self.receiveChunk()
|
||||
if chunk.isEmpty { return nil }
|
||||
self.buffer.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveChunk() async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.connection
|
||||
.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
cont.resume(returning: Data())
|
||||
return
|
||||
}
|
||||
cont.resume(returning: data ?? Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func close(with onDisconnected: (@Sendable (String) async -> Void)? = nil) async {
|
||||
if self.isClosed { return }
|
||||
self.isClosed = true
|
||||
|
||||
let nodeId = self.nodeId
|
||||
let pending = self.pendingInvokes.values
|
||||
self.pendingInvokes.removeAll()
|
||||
for cont in pending {
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 4, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
|
||||
]))
|
||||
}
|
||||
|
||||
self.connection.cancel()
|
||||
if let nodeId {
|
||||
await onDisconnected?(nodeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,542 +0,0 @@
|
||||
import AppKit
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
actor BridgeServer {
|
||||
static let shared = BridgeServer()
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "bridge")
|
||||
private var listener: NWListener?
|
||||
private var isRunning = false
|
||||
private var store: PairedNodesStore?
|
||||
private var connections: [String: BridgeConnectionHandler] = [:]
|
||||
private var nodeInfoById: [String: BridgeNodeInfo] = [:]
|
||||
private var presenceTasks: [String: Task<Void, Never>] = [:]
|
||||
private var chatSubscriptions: [String: Set<String>] = [:]
|
||||
private var gatewayPushTask: Task<Void, Never>?
|
||||
|
||||
func start() async {
|
||||
if self.isRunning { return }
|
||||
self.isRunning = true
|
||||
|
||||
do {
|
||||
let storeURL = try Self.defaultStoreURL()
|
||||
let store = PairedNodesStore(fileURL: storeURL)
|
||||
await store.load()
|
||||
self.store = store
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
let listener = try NWListener(using: params, on: .any)
|
||||
|
||||
listener.newConnectionHandler = { [weak self] connection in
|
||||
guard let self else { return }
|
||||
Task { await self.handle(connection: connection) }
|
||||
}
|
||||
|
||||
listener.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
Task { await self.handleListenerState(state) }
|
||||
}
|
||||
|
||||
listener.start(queue: DispatchQueue(label: "com.clawdbot.bridge"))
|
||||
self.listener = listener
|
||||
} catch {
|
||||
self.logger.error("bridge start failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.isRunning = false
|
||||
}
|
||||
}
|
||||
|
||||
func stop() async {
|
||||
self.isRunning = false
|
||||
self.listener?.cancel()
|
||||
self.listener = nil
|
||||
}
|
||||
|
||||
private func handleListenerState(_ state: NWListener.State) {
|
||||
switch state {
|
||||
case .ready:
|
||||
self.logger.info("bridge listening")
|
||||
case let .failed(err):
|
||||
self.logger.error("bridge listener failed: \(err.localizedDescription, privacy: .public)")
|
||||
case .cancelled:
|
||||
self.logger.info("bridge listener cancelled")
|
||||
case .waiting:
|
||||
self.logger.info("bridge listener waiting")
|
||||
case .setup:
|
||||
break
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(connection: NWConnection) async {
|
||||
let handler = BridgeConnectionHandler(connection: connection, logger: self.logger)
|
||||
await handler.run(
|
||||
resolveAuth: { [weak self] hello in
|
||||
await self?.authorize(hello: hello) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
|
||||
},
|
||||
handlePair: { [weak self] request in
|
||||
await self?.pair(request: request) ?? .error(code: "UNAVAILABLE", message: "bridge unavailable")
|
||||
},
|
||||
onAuthenticated: { [weak self] node in
|
||||
await self?.registerConnection(handler: handler, node: node)
|
||||
},
|
||||
onDisconnected: { [weak self] nodeId in
|
||||
await self?.unregisterConnection(nodeId: nodeId)
|
||||
},
|
||||
onEvent: { [weak self] nodeId, evt in
|
||||
await self?.handleEvent(nodeId: nodeId, evt: evt)
|
||||
},
|
||||
onRequest: { [weak self] nodeId, req in
|
||||
await self?.handleRequest(nodeId: nodeId, req: req)
|
||||
?? BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: "bridge unavailable"))
|
||||
})
|
||||
}
|
||||
|
||||
func invoke(nodeId: String, command: String, paramsJSON: String?) async throws -> BridgeInvokeResponse {
|
||||
guard let handler = self.connections[nodeId] else {
|
||||
throw NSError(domain: "Bridge", code: 10, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: node not connected",
|
||||
])
|
||||
}
|
||||
return try await handler.invoke(command: command, paramsJSON: paramsJSON)
|
||||
}
|
||||
|
||||
func connectedNodeIds() -> [String] {
|
||||
Array(self.connections.keys).sorted()
|
||||
}
|
||||
|
||||
func connectedNodes() -> [BridgeNodeInfo] {
|
||||
self.nodeInfoById.values.sorted { a, b in
|
||||
(a.displayName ?? a.nodeId) < (b.displayName ?? b.nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
func pairedNodes() async -> [PairedNode] {
|
||||
guard let store = self.store else { return [] }
|
||||
return await store.all()
|
||||
}
|
||||
|
||||
private func registerConnection(handler: BridgeConnectionHandler, node: BridgeNodeInfo) async {
|
||||
self.connections[node.nodeId] = handler
|
||||
self.nodeInfoById[node.nodeId] = node
|
||||
await self.beaconPresence(nodeId: node.nodeId, reason: "connect")
|
||||
self.startPresenceTask(nodeId: node.nodeId)
|
||||
self.ensureGatewayPushTask()
|
||||
}
|
||||
|
||||
private func unregisterConnection(nodeId: String) async {
|
||||
await self.beaconPresence(nodeId: nodeId, reason: "disconnect")
|
||||
self.stopPresenceTask(nodeId: nodeId)
|
||||
self.connections.removeValue(forKey: nodeId)
|
||||
self.nodeInfoById.removeValue(forKey: nodeId)
|
||||
self.chatSubscriptions[nodeId] = nil
|
||||
self.stopGatewayPushTaskIfIdle()
|
||||
}
|
||||
|
||||
private struct VoiceTranscriptPayload: Codable, Sendable {
|
||||
var text: String
|
||||
var sessionKey: String?
|
||||
}
|
||||
|
||||
private func handleEvent(nodeId: String, evt: BridgeEventFrame) async {
|
||||
switch evt.event {
|
||||
case "chat.subscribe":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
|
||||
struct Subscribe: Codable { var sessionKey: String }
|
||||
guard let payload = try? JSONDecoder().decode(Subscribe.self, from: data) else { return }
|
||||
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
|
||||
set.insert(key)
|
||||
self.chatSubscriptions[nodeId] = set
|
||||
|
||||
case "chat.unsubscribe":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else { return }
|
||||
struct Unsubscribe: Codable { var sessionKey: String }
|
||||
guard let payload = try? JSONDecoder().decode(Unsubscribe.self, from: data) else { return }
|
||||
let key = payload.sessionKey.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !key.isEmpty else { return }
|
||||
var set = self.chatSubscriptions[nodeId] ?? Set<String>()
|
||||
set.remove(key)
|
||||
self.chatSubscriptions[nodeId] = set.isEmpty ? nil : set
|
||||
|
||||
case "voice.transcript":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
guard let payload = try? JSONDecoder().decode(VoiceTranscriptPayload.self, from: data) else {
|
||||
return
|
||||
}
|
||||
let text = payload.text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
|
||||
let sessionKey = payload.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "main"
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: text,
|
||||
sessionKey: sessionKey,
|
||||
thinking: "low",
|
||||
deliver: false,
|
||||
to: nil,
|
||||
channel: .last))
|
||||
|
||||
case "agent.request":
|
||||
guard let json = evt.payloadJSON, let data = json.data(using: .utf8) else {
|
||||
return
|
||||
}
|
||||
guard let link = try? JSONDecoder().decode(AgentDeepLink.self, from: data) else {
|
||||
return
|
||||
}
|
||||
|
||||
let message = link.message.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !message.isEmpty else { return }
|
||||
guard message.count <= 20000 else { return }
|
||||
|
||||
let sessionKey = link.sessionKey?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? "node-\(nodeId)"
|
||||
let thinking = link.thinking?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let to = link.to?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let channel = GatewayAgentChannel(raw: link.channel)
|
||||
|
||||
_ = await GatewayConnection.shared.sendAgent(GatewayAgentInvocation(
|
||||
message: message,
|
||||
sessionKey: sessionKey,
|
||||
thinking: thinking,
|
||||
deliver: link.deliver,
|
||||
to: to,
|
||||
channel: channel))
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRequest(nodeId: String, req: BridgeRPCRequest) async -> BridgeRPCResponse {
|
||||
let allowed: Set<String> = ["chat.history", "chat.send", "health"]
|
||||
guard allowed.contains(req.method) else {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "FORBIDDEN", message: "Method not allowed"))
|
||||
}
|
||||
|
||||
let params: [String: ClawdbotProtocol.AnyCodable]?
|
||||
if let json = req.paramsJSON?.trimmingCharacters(in: .whitespacesAndNewlines), !json.isEmpty {
|
||||
guard let data = json.data(using: .utf8) else {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "INVALID_REQUEST", message: "paramsJSON not UTF-8"))
|
||||
}
|
||||
do {
|
||||
params = try JSONDecoder().decode([String: ClawdbotProtocol.AnyCodable].self, from: data)
|
||||
} catch {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "INVALID_REQUEST", message: error.localizedDescription))
|
||||
}
|
||||
} else {
|
||||
params = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try await GatewayConnection.shared.request(method: req.method, params: params, timeoutMs: 30000)
|
||||
guard let json = String(data: data, encoding: .utf8) else {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: "Response not UTF-8"))
|
||||
}
|
||||
return BridgeRPCResponse(id: req.id, ok: true, payloadJSON: json)
|
||||
} catch {
|
||||
return BridgeRPCResponse(
|
||||
id: req.id,
|
||||
ok: false,
|
||||
error: BridgeRPCError(code: "UNAVAILABLE", message: error.localizedDescription))
|
||||
}
|
||||
}
|
||||
|
||||
private func ensureGatewayPushTask() {
|
||||
if self.gatewayPushTask != nil { return }
|
||||
self.gatewayPushTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
do {
|
||||
try await GatewayConnection.shared.refresh()
|
||||
} catch {
|
||||
// We'll still forward events once the gateway comes up.
|
||||
}
|
||||
let stream = await GatewayConnection.shared.subscribe()
|
||||
for await push in stream {
|
||||
if Task.isCancelled { return }
|
||||
await self.forwardGatewayPush(push)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopGatewayPushTaskIfIdle() {
|
||||
guard self.connections.isEmpty else { return }
|
||||
self.gatewayPushTask?.cancel()
|
||||
self.gatewayPushTask = nil
|
||||
}
|
||||
|
||||
private func forwardGatewayPush(_ push: GatewayPush) async {
|
||||
let subscribedNodes = self.chatSubscriptions.keys.filter { self.connections[$0] != nil }
|
||||
guard !subscribedNodes.isEmpty else { return }
|
||||
|
||||
switch push {
|
||||
case let .snapshot(hello):
|
||||
let payloadJSON = (try? JSONEncoder().encode(hello.snapshot.health))
|
||||
.flatMap { String(data: $0, encoding: .utf8) }
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
|
||||
}
|
||||
case let .event(evt):
|
||||
switch evt.event {
|
||||
case "health":
|
||||
guard let payload = evt.payload else { return }
|
||||
let payloadJSON = (try? JSONEncoder().encode(payload))
|
||||
.flatMap { String(data: $0, encoding: .utf8) }
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "health", payloadJSON: payloadJSON)
|
||||
}
|
||||
case "tick":
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "tick", payloadJSON: nil)
|
||||
}
|
||||
case "chat":
|
||||
guard let payload = evt.payload else { return }
|
||||
let payloadData = try? JSONEncoder().encode(payload)
|
||||
let payloadJSON = payloadData.flatMap { String(data: $0, encoding: .utf8) }
|
||||
|
||||
struct MinimalChat: Codable { var sessionKey: String }
|
||||
let sessionKey = payloadData.flatMap { try? JSONDecoder().decode(MinimalChat.self, from: $0) }?
|
||||
.sessionKey
|
||||
if let sessionKey {
|
||||
for nodeId in subscribedNodes {
|
||||
guard self.chatSubscriptions[nodeId]?.contains(sessionKey) == true else { continue }
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
|
||||
}
|
||||
} else {
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "chat", payloadJSON: payloadJSON)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
case .seqGap:
|
||||
for nodeId in subscribedNodes {
|
||||
await self.connections[nodeId]?.sendServerEvent(event: "seqGap", payloadJSON: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func beaconPresence(nodeId: String, reason: String) async {
|
||||
let paired = await self.store?.find(nodeId: nodeId)
|
||||
let host = paired?.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
?? nodeId
|
||||
let version = paired?.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let platform = paired?.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let ip = await self.connections[nodeId]?.remoteAddress()
|
||||
|
||||
var tags: [String] = ["node", "ios"]
|
||||
if let platform { tags.append(platform) }
|
||||
|
||||
let summary = [
|
||||
"Node: \(host)\(ip.map { " (\($0))" } ?? "")",
|
||||
platform.map { "platform \($0)" },
|
||||
version.map { "app \($0)" },
|
||||
"mode node",
|
||||
"reason \(reason)",
|
||||
].compactMap(\.self).joined(separator: " · ")
|
||||
|
||||
var params: [String: ClawdbotProtocol.AnyCodable] = [
|
||||
"text": ClawdbotProtocol.AnyCodable(summary),
|
||||
"instanceId": ClawdbotProtocol.AnyCodable(nodeId),
|
||||
"host": ClawdbotProtocol.AnyCodable(host),
|
||||
"mode": ClawdbotProtocol.AnyCodable("node"),
|
||||
"reason": ClawdbotProtocol.AnyCodable(reason),
|
||||
"tags": ClawdbotProtocol.AnyCodable(tags),
|
||||
]
|
||||
if let ip { params["ip"] = ClawdbotProtocol.AnyCodable(ip) }
|
||||
if let version { params["version"] = ClawdbotProtocol.AnyCodable(version) }
|
||||
await GatewayConnection.shared.sendSystemEvent(params)
|
||||
}
|
||||
|
||||
private func startPresenceTask(nodeId: String) {
|
||||
self.presenceTasks[nodeId]?.cancel()
|
||||
self.presenceTasks[nodeId] = Task.detached { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(nanoseconds: 180 * 1_000_000_000)
|
||||
if Task.isCancelled { return }
|
||||
await self?.beaconPresence(nodeId: nodeId, reason: "periodic")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func stopPresenceTask(nodeId: String) {
|
||||
self.presenceTasks[nodeId]?.cancel()
|
||||
self.presenceTasks.removeValue(forKey: nodeId)
|
||||
}
|
||||
|
||||
private func authorize(hello: BridgeHello) async -> BridgeConnectionHandler.AuthResult {
|
||||
let nodeId = hello.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if nodeId.isEmpty {
|
||||
return .error(code: "INVALID_REQUEST", message: "nodeId required")
|
||||
}
|
||||
guard let store = self.store else {
|
||||
return .error(code: "UNAVAILABLE", message: "store unavailable")
|
||||
}
|
||||
guard let paired = await store.find(nodeId: nodeId) else {
|
||||
return .notPaired
|
||||
}
|
||||
guard let token = hello.token, token == paired.token else {
|
||||
return .unauthorized
|
||||
}
|
||||
|
||||
do {
|
||||
var updated = paired
|
||||
let name = hello.displayName?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let platform = hello.platform?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let version = hello.version?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let deviceFamily = hello.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let modelIdentifier = hello.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
|
||||
if updated.displayName != name { updated.displayName = name }
|
||||
if updated.platform != platform { updated.platform = platform }
|
||||
if updated.version != version { updated.version = version }
|
||||
if updated.deviceFamily != deviceFamily { updated.deviceFamily = deviceFamily }
|
||||
if updated.modelIdentifier != modelIdentifier { updated.modelIdentifier = modelIdentifier }
|
||||
|
||||
if updated != paired {
|
||||
try await store.upsert(updated)
|
||||
} else {
|
||||
try await store.touchSeen(nodeId: nodeId)
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return .ok
|
||||
}
|
||||
|
||||
private func pair(request: BridgePairRequest) async -> BridgeConnectionHandler.PairResult {
|
||||
let nodeId = request.nodeId.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if nodeId.isEmpty {
|
||||
return .error(code: "INVALID_REQUEST", message: "nodeId required")
|
||||
}
|
||||
guard let store = self.store else {
|
||||
return .error(code: "UNAVAILABLE", message: "store unavailable")
|
||||
}
|
||||
let existing = await store.find(nodeId: nodeId)
|
||||
|
||||
let approved = await BridgePairingApprover.approve(request: request, isRepair: existing != nil)
|
||||
if !approved {
|
||||
return .rejected
|
||||
}
|
||||
|
||||
let token = UUID().uuidString.replacingOccurrences(of: "-", with: "")
|
||||
let nowMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let node = PairedNode(
|
||||
nodeId: nodeId,
|
||||
displayName: request.displayName,
|
||||
platform: request.platform,
|
||||
version: request.version,
|
||||
deviceFamily: request.deviceFamily,
|
||||
modelIdentifier: request.modelIdentifier,
|
||||
token: token,
|
||||
createdAtMs: nowMs,
|
||||
lastSeenAtMs: nowMs)
|
||||
do {
|
||||
try await store.upsert(node)
|
||||
return .ok(token: token)
|
||||
} catch {
|
||||
return .error(code: "UNAVAILABLE", message: "failed to persist pairing")
|
||||
}
|
||||
}
|
||||
|
||||
private static func defaultStoreURL() throws -> URL {
|
||||
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
guard let base else {
|
||||
throw NSError(
|
||||
domain: "Bridge",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Application Support unavailable"])
|
||||
}
|
||||
return base
|
||||
.appendingPathComponent("Clawdbot", isDirectory: true)
|
||||
.appendingPathComponent("bridge", isDirectory: true)
|
||||
.appendingPathComponent("paired-nodes.json", isDirectory: false)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum BridgePairingApprover {
|
||||
static func approve(request: BridgePairRequest, isRepair: Bool) async -> Bool {
|
||||
await withCheckedContinuation { cont in
|
||||
let name = request.displayName ?? request.nodeId
|
||||
let remote = request.remoteAddress?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
|
||||
let alert = NSAlert()
|
||||
alert.messageText = isRepair ? "Re-pair Clawdbot Node?" : "Pair Clawdbot Node?"
|
||||
alert.informativeText = """
|
||||
Node: \(name)
|
||||
IP: \(remote ?? "unknown")
|
||||
Platform: \(request.platform ?? "unknown")
|
||||
Version: \(request.version ?? "unknown")
|
||||
"""
|
||||
alert.addButton(withTitle: "Approve")
|
||||
alert.addButton(withTitle: "Reject")
|
||||
if #available(macOS 11.0, *), alert.buttons.indices.contains(1) {
|
||||
alert.buttons[1].hasDestructiveAction = true
|
||||
}
|
||||
let resp = alert.runModal()
|
||||
cont.resume(returning: resp == .alertFirstButtonReturn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
extension BridgeServer {
|
||||
func exerciseForTesting() async {
|
||||
let conn = NWConnection(to: .hostPort(host: "127.0.0.1", port: 22), using: .tcp)
|
||||
let handler = BridgeConnectionHandler(connection: conn, logger: self.logger)
|
||||
self.connections["node-1"] = handler
|
||||
self.nodeInfoById["node-1"] = BridgeNodeInfo(
|
||||
nodeId: "node-1",
|
||||
displayName: "Node One",
|
||||
platform: "macOS",
|
||||
version: "1.0.0",
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: "MacBookPro18,1",
|
||||
remoteAddress: "127.0.0.1",
|
||||
caps: ["chat", "voice"])
|
||||
|
||||
_ = self.connectedNodeIds()
|
||||
_ = self.connectedNodes()
|
||||
|
||||
self.handleListenerState(.ready)
|
||||
self.handleListenerState(.failed(NWError.posix(.ECONNREFUSED)))
|
||||
self.handleListenerState(.waiting(NWError.posix(.ETIMEDOUT)))
|
||||
self.handleListenerState(.cancelled)
|
||||
self.handleListenerState(.setup)
|
||||
|
||||
let subscribe = BridgeEventFrame(event: "chat.subscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||
await self.handleEvent(nodeId: "node-1", evt: subscribe)
|
||||
|
||||
let unsubscribe = BridgeEventFrame(event: "chat.unsubscribe", payloadJSON: "{\"sessionKey\":\"main\"}")
|
||||
await self.handleEvent(nodeId: "node-1", evt: unsubscribe)
|
||||
|
||||
let invalid = BridgeRPCRequest(id: "req-1", method: "invalid.method", paramsJSON: nil)
|
||||
_ = await self.handleRequest(nodeId: "node-1", req: invalid)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,17 @@ struct WebSocketSessionBox: @unchecked Sendable {
|
||||
let session: any WebSocketSessioning
|
||||
}
|
||||
|
||||
struct GatewayConnectOptions: Sendable {
|
||||
var role: String
|
||||
var scopes: [String]
|
||||
var caps: [String]
|
||||
var commands: [String]
|
||||
var permissions: [String: Bool]
|
||||
var clientId: String
|
||||
var clientMode: String
|
||||
var clientDisplayName: String?
|
||||
}
|
||||
|
||||
// Avoid ambiguity with the app's own AnyCodable type.
|
||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||
|
||||
@@ -81,19 +92,25 @@ actor GatewayChannelActor {
|
||||
private var tickTask: Task<Void, Never>?
|
||||
private let defaultRequestTimeoutMs: Double = 15000
|
||||
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
|
||||
private let connectOptions: GatewayConnectOptions?
|
||||
private let disconnectHandler: (@Sendable (String) async -> Void)?
|
||||
|
||||
init(
|
||||
url: URL,
|
||||
token: String?,
|
||||
password: String? = nil,
|
||||
session: WebSocketSessionBox? = nil,
|
||||
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil)
|
||||
pushHandler: (@Sendable (GatewayPush) async -> Void)? = nil,
|
||||
connectOptions: GatewayConnectOptions? = nil,
|
||||
disconnectHandler: (@Sendable (String) async -> Void)? = nil)
|
||||
{
|
||||
self.url = url
|
||||
self.token = token
|
||||
self.password = password
|
||||
self.session = session?.session ?? URLSession(configuration: .default)
|
||||
self.pushHandler = pushHandler
|
||||
self.connectOptions = connectOptions
|
||||
self.disconnectHandler = disconnectHandler
|
||||
Task { [weak self] in
|
||||
await self?.startWatchdog()
|
||||
}
|
||||
@@ -178,6 +195,7 @@ actor GatewayChannelActor {
|
||||
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||
self.connected = false
|
||||
self.task?.cancel(with: .goingAway, reason: nil)
|
||||
await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)")
|
||||
let waiters = self.connectWaiters
|
||||
self.connectWaiters.removeAll()
|
||||
for waiter in waiters {
|
||||
@@ -202,9 +220,18 @@ actor GatewayChannelActor {
|
||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||
let clientDisplayName = InstanceIdentity.displayName
|
||||
let clientId = "clawdbot-macos"
|
||||
let clientMode = "ui"
|
||||
let options = self.connectOptions ?? GatewayConnectOptions(
|
||||
role: "operator",
|
||||
scopes: ["operator.admin", "operator.approvals", "operator.pairing"],
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: [:],
|
||||
clientId: "clawdbot-macos",
|
||||
clientMode: "ui",
|
||||
clientDisplayName: InstanceIdentity.displayName)
|
||||
let clientDisplayName = options.clientDisplayName ?? InstanceIdentity.displayName
|
||||
let clientId = options.clientId
|
||||
let clientMode = options.clientMode
|
||||
|
||||
let reqId = UUID().uuidString
|
||||
var client: [String: ProtoAnyCodable] = [
|
||||
@@ -224,12 +251,18 @@ actor GatewayChannelActor {
|
||||
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||
"client": ProtoAnyCodable(client),
|
||||
"caps": ProtoAnyCodable([] as [String]),
|
||||
"caps": ProtoAnyCodable(options.caps),
|
||||
"locale": ProtoAnyCodable(primaryLocale),
|
||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||
"role": ProtoAnyCodable("operator"),
|
||||
"scopes": ProtoAnyCodable(["operator.admin", "operator.approvals", "operator.pairing"]),
|
||||
"role": ProtoAnyCodable(options.role),
|
||||
"scopes": ProtoAnyCodable(options.scopes),
|
||||
]
|
||||
if !options.commands.isEmpty {
|
||||
params["commands"] = ProtoAnyCodable(options.commands)
|
||||
}
|
||||
if !options.permissions.isEmpty {
|
||||
params["permissions"] = ProtoAnyCodable(options.permissions)
|
||||
}
|
||||
if let token = self.token {
|
||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||
} else if let password = self.password {
|
||||
@@ -237,13 +270,13 @@ actor GatewayChannelActor {
|
||||
}
|
||||
let identity = DeviceIdentityStore.loadOrCreate()
|
||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||
let scopes = "operator.admin,operator.approvals,operator.pairing"
|
||||
let scopes = options.scopes.joined(separator: ",")
|
||||
let payload = [
|
||||
"v1",
|
||||
identity.deviceId,
|
||||
clientId,
|
||||
clientMode,
|
||||
"operator",
|
||||
options.role,
|
||||
scopes,
|
||||
String(signedAtMs),
|
||||
self.token ?? "",
|
||||
@@ -344,6 +377,7 @@ actor GatewayChannelActor {
|
||||
let wrapped = self.wrap(err, context: "gateway receive")
|
||||
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
|
||||
self.connected = false
|
||||
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
|
||||
await self.failPending(wrapped)
|
||||
await self.scheduleReconnect()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,13 @@ enum GatewayEndpointState: Sendable, Equatable {
|
||||
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
|
||||
actor GatewayEndpointStore {
|
||||
static let shared = GatewayEndpointStore()
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let supportedBindModes: Set<String> = [
|
||||
"loopback",
|
||||
"tailnet",
|
||||
"lan",
|
||||
"auto",
|
||||
"custom",
|
||||
]
|
||||
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
||||
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
private enum EnvOverrideWarningKind: Sendable {
|
||||
@@ -60,9 +66,11 @@ actor GatewayEndpointStore {
|
||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
customBindHost: customBindHost,
|
||||
tailscaleIP: tailscaleIP)
|
||||
},
|
||||
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
||||
@@ -250,10 +258,14 @@ actor GatewayEndpointStore {
|
||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||
root: ClawdbotConfigFile.loadDict(),
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: ClawdbotConfigFile.loadDict())
|
||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||
root: ClawdbotConfigFile.loadDict(),
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let host = GatewayEndpointStore.resolveLocalGatewayHost(bindMode: bind, tailscaleIP: nil)
|
||||
let host = GatewayEndpointStore.resolveLocalGatewayHost(
|
||||
bindMode: bind,
|
||||
customBindHost: customBindHost,
|
||||
tailscaleIP: nil)
|
||||
let token = deps.token()
|
||||
let password = deps.password()
|
||||
switch initialMode {
|
||||
@@ -417,7 +429,10 @@ actor GatewayEndpointStore {
|
||||
|
||||
let token = self.deps.token()
|
||||
let password = self.deps.password()
|
||||
let url = URL(string: "ws://127.0.0.1:\(Int(forwarded))")!
|
||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||
root: ClawdbotConfigFile.loadDict(),
|
||||
env: ProcessInfo.processInfo.environment)
|
||||
let url = URL(string: "\(scheme)://127.0.0.1:\(Int(forwarded))")!
|
||||
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||
return (url, token, password)
|
||||
} catch let err as CancellationError {
|
||||
@@ -487,6 +502,16 @@ actor GatewayEndpointStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveGatewayCustomBindHost(root: [String: Any]) -> String? {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let customBindHost = gateway["customBindHost"] as? String
|
||||
{
|
||||
let trimmed = customBindHost.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return trimmed.isEmpty ? nil : trimmed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveGatewayScheme(
|
||||
root: [String: Any],
|
||||
env: [String: String]) -> String
|
||||
@@ -507,11 +532,14 @@ actor GatewayEndpointStore {
|
||||
|
||||
private static func resolveLocalGatewayHost(
|
||||
bindMode: String?,
|
||||
customBindHost: String?,
|
||||
tailscaleIP: String?) -> String
|
||||
{
|
||||
switch bindMode {
|
||||
case "tailnet", "auto":
|
||||
tailscaleIP ?? "127.0.0.1"
|
||||
case "custom":
|
||||
customBindHost ?? "127.0.0.1"
|
||||
default:
|
||||
"127.0.0.1"
|
||||
}
|
||||
@@ -586,7 +614,10 @@ extension GatewayEndpointStore {
|
||||
bindMode: String?,
|
||||
tailscaleIP: String?) -> String
|
||||
{
|
||||
self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP)
|
||||
self.resolveLocalGatewayHost(
|
||||
bindMode: bindMode,
|
||||
customBindHost: nil,
|
||||
tailscaleIP: tailscaleIP)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -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 }
|
||||
|
||||
105
apps/macos/Sources/Clawdbot/NodeMode/GatewayTLSPinning.swift
Normal file
105
apps/macos/Sources/Clawdbot/NodeMode/GatewayTLSPinning.swift
Normal file
@@ -0,0 +1,105 @@
|
||||
import CryptoKit
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
struct GatewayTLSParams: Sendable {
|
||||
let required: Bool
|
||||
let expectedFingerprint: String?
|
||||
let allowTOFU: Bool
|
||||
let storeKey: String?
|
||||
}
|
||||
|
||||
enum GatewayTLSStore {
|
||||
private static let suiteName = "com.clawdbot.shared"
|
||||
private static let keyPrefix = "gateway.tls."
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
static func loadFingerprint(stableID: String) -> String? {
|
||||
let key = self.keyPrefix + stableID
|
||||
let raw = self.defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw?.isEmpty == false ? raw : nil
|
||||
}
|
||||
|
||||
static func saveFingerprint(_ value: String, stableID: String) {
|
||||
let key = self.keyPrefix + stableID
|
||||
self.defaults.set(value, forKey: key)
|
||||
}
|
||||
}
|
||||
|
||||
final class GatewayTLSPinningSession: NSObject, WebSocketSessioning, URLSessionDelegate {
|
||||
private let params: GatewayTLSParams
|
||||
private lazy var session: URLSession = {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.waitsForConnectivity = true
|
||||
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
|
||||
}()
|
||||
|
||||
init(params: GatewayTLSParams) {
|
||||
self.params = params
|
||||
super.init()
|
||||
}
|
||||
|
||||
func makeWebSocketTask(url: URL) -> WebSocketTaskBox {
|
||||
let task = self.session.webSocketTask(with: url)
|
||||
task.maximumMessageSize = 16 * 1024 * 1024
|
||||
return WebSocketTaskBox(task: task)
|
||||
}
|
||||
|
||||
func urlSession(
|
||||
_ session: URLSession,
|
||||
didReceive challenge: URLAuthenticationChallenge,
|
||||
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void
|
||||
) {
|
||||
guard challenge.protectionSpace.authenticationMethod == NSURLAuthenticationMethodServerTrust,
|
||||
let trust = challenge.protectionSpace.serverTrust
|
||||
else {
|
||||
completionHandler(.performDefaultHandling, nil)
|
||||
return
|
||||
}
|
||||
|
||||
let expected = params.expectedFingerprint.map(normalizeFingerprint)
|
||||
if let fingerprint = certificateFingerprint(trust) {
|
||||
if let expected {
|
||||
if fingerprint == expected {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
return
|
||||
}
|
||||
if params.allowTOFU {
|
||||
if let storeKey = params.storeKey {
|
||||
GatewayTLSStore.saveFingerprint(fingerprint, stableID: storeKey)
|
||||
}
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let ok = SecTrustEvaluateWithError(trust, nil)
|
||||
if ok || !params.required {
|
||||
completionHandler(.useCredential, URLCredential(trust: trust))
|
||||
} else {
|
||||
completionHandler(.cancelAuthenticationChallenge, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func certificateFingerprint(_ trust: SecTrust) -> String? {
|
||||
let count = SecTrustGetCertificateCount(trust)
|
||||
guard count > 0, let cert = SecTrustGetCertificateAtIndex(trust, 0) else { return nil }
|
||||
let data = SecCertificateCopyData(cert) as Data
|
||||
return sha256Hex(data)
|
||||
}
|
||||
|
||||
private func sha256Hex(_ data: Data) -> String {
|
||||
let digest = SHA256.hash(data: data)
|
||||
return digest.map { String(format: "%02x", $0) }.joined()
|
||||
}
|
||||
|
||||
private func normalizeFingerprint(_ raw: String) -> String {
|
||||
raw.lowercased().filter(\.isHexDigit)
|
||||
}
|
||||
@@ -1,238 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
|
||||
actor MacNodeBridgePairingClient {
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private var lineBuffer = Data()
|
||||
|
||||
func pairAndHello(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
silent: Bool,
|
||||
tls: MacNodeBridgeTLSParams? = nil,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
do {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
silent: silent,
|
||||
tls: tls,
|
||||
onStatus: onStatus)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
return try await self.pairAndHelloOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
silent: silent,
|
||||
tls: nil,
|
||||
onStatus: onStatus)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func pairAndHelloOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
silent: Bool,
|
||||
tls: MacNodeBridgeTLSParams?,
|
||||
onStatus: (@Sendable (String) -> Void)? = nil) async throws -> String
|
||||
{
|
||||
self.lineBuffer = Data()
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-client")
|
||||
defer { connection.cancel() }
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: 8,
|
||||
onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "connect timed out",
|
||||
])
|
||||
},
|
||||
operation: {
|
||||
try await self.startAndWaitForReady(connection, queue: queue)
|
||||
})
|
||||
|
||||
onStatus?("Authenticating…")
|
||||
try await self.send(hello, over: connection)
|
||||
|
||||
let first = try await AsyncTimeout.withTimeout(
|
||||
seconds: 10,
|
||||
onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "hello timed out",
|
||||
])
|
||||
},
|
||||
operation: { () -> ReceivedFrame in
|
||||
guard let frame = try await self.receiveFrame(over: connection) else {
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Bridge closed connection during hello",
|
||||
])
|
||||
}
|
||||
return frame
|
||||
})
|
||||
|
||||
switch first.base.type {
|
||||
case "hello-ok":
|
||||
return hello.token ?? ""
|
||||
|
||||
case "error":
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: first.data)
|
||||
if err.code != "NOT_PAIRED", err.code != "UNAUTHORIZED" {
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||
])
|
||||
}
|
||||
|
||||
onStatus?("Requesting approval…")
|
||||
try await self.send(
|
||||
BridgePairRequest(
|
||||
nodeId: hello.nodeId,
|
||||
displayName: hello.displayName,
|
||||
platform: hello.platform,
|
||||
version: hello.version,
|
||||
coreVersion: hello.coreVersion,
|
||||
uiVersion: hello.uiVersion,
|
||||
deviceFamily: hello.deviceFamily,
|
||||
modelIdentifier: hello.modelIdentifier,
|
||||
caps: hello.caps,
|
||||
commands: hello.commands,
|
||||
silent: silent),
|
||||
over: connection)
|
||||
|
||||
onStatus?("Waiting for approval…")
|
||||
let ok = try await AsyncTimeout.withTimeout(
|
||||
seconds: 60,
|
||||
onTimeout: {
|
||||
NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "pairing approval timed out",
|
||||
])
|
||||
},
|
||||
operation: {
|
||||
while let next = try await self.receiveFrame(over: connection) {
|
||||
switch next.base.type {
|
||||
case "pair-ok":
|
||||
return try self.decoder.decode(BridgePairOk.self, from: next.data)
|
||||
case "error":
|
||||
let e = try self.decoder.decode(BridgeErrorFrame.self, from: next.data)
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(e.code): \(e.message)",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Pairing failed: bridge closed connection",
|
||||
])
|
||||
})
|
||||
|
||||
return ok.token
|
||||
|
||||
default:
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable, over connection: NWConnection) async throws {
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private struct ReceivedFrame {
|
||||
var base: BridgeBaseFrame
|
||||
var data: Data
|
||||
}
|
||||
|
||||
private func receiveFrame(over connection: NWConnection) async throws -> ReceivedFrame? {
|
||||
guard let lineData = try await self.receiveLineData(over: connection) else {
|
||||
return nil
|
||||
}
|
||||
let base = try self.decoder.decode(BridgeBaseFrame.self, from: lineData)
|
||||
return ReceivedFrame(base: base, data: lineData)
|
||||
}
|
||||
|
||||
private func receiveChunk(over connection: NWConnection) async throws -> Data {
|
||||
try await withCheckedThrowingContinuation(isolation: nil) { (cont: CheckedContinuation<Data, Error>) in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
cont.resume(returning: Data())
|
||||
return
|
||||
}
|
||||
cont.resume(returning: data ?? Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLineData(over connection: NWConnection) async throws -> Data? {
|
||||
while true {
|
||||
if let idx = self.lineBuffer.firstIndex(of: 0x0A) {
|
||||
let line = self.lineBuffer.prefix(upTo: idx)
|
||||
self.lineBuffer.removeSubrange(...idx)
|
||||
return Data(line)
|
||||
}
|
||||
|
||||
let chunk = try await self.receiveChunk(over: connection)
|
||||
if chunk.isEmpty { return nil }
|
||||
self.lineBuffer.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
if let tlsOptions = makeMacNodeTLSOptions(tls) {
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
private func startAndWaitForReady(
|
||||
_ connection: NWConnection,
|
||||
queue: DispatchQueue) async throws
|
||||
{
|
||||
let states = AsyncStream<NWConnection.State> { continuation in
|
||||
connection.stateUpdateHandler = { state in
|
||||
continuation.yield(state)
|
||||
if case .ready = state { continuation.finish() }
|
||||
if case .failed = state { continuation.finish() }
|
||||
if case .cancelled = state { continuation.finish() }
|
||||
}
|
||||
}
|
||||
connection.start(queue: queue)
|
||||
for await state in states {
|
||||
switch state {
|
||||
case .ready:
|
||||
return
|
||||
case let .failed(err):
|
||||
throw err
|
||||
case .cancelled:
|
||||
throw NSError(domain: "Bridge", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Bridge connection cancelled",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,519 +0,0 @@
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
actor MacNodeBridgeSession {
|
||||
private struct TimeoutError: LocalizedError {
|
||||
var message: String
|
||||
var errorDescription: String? { self.message }
|
||||
}
|
||||
|
||||
enum State: Sendable, Equatable {
|
||||
case idle
|
||||
case connecting
|
||||
case connected(serverName: String)
|
||||
case failed(message: String)
|
||||
}
|
||||
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "node.bridge-session")
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
private let clock = ContinuousClock()
|
||||
private var disconnectHandler: (@Sendable (String) async -> Void)?
|
||||
|
||||
private var connection: NWConnection?
|
||||
private var queue: DispatchQueue?
|
||||
private var buffer = Data()
|
||||
private var pendingRPC: [String: CheckedContinuation<BridgeRPCResponse, Error>] = [:]
|
||||
private var serverEventSubscribers: [UUID: AsyncStream<BridgeEventFrame>.Continuation] = [:]
|
||||
private var invokeTasks: [UUID: Task<Void, Never>] = [:]
|
||||
private var pingTask: Task<Void, Never>?
|
||||
private var lastPongAt: ContinuousClock.Instant?
|
||||
|
||||
private(set) var state: State = .idle
|
||||
|
||||
func connect(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: MacNodeBridgeTLSParams? = nil,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onDisconnected: (@Sendable (String) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)
|
||||
async throws
|
||||
{
|
||||
await self.disconnect()
|
||||
self.disconnectHandler = onDisconnected
|
||||
self.state = .connecting
|
||||
do {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: tls,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
} catch {
|
||||
if let tls, !tls.required {
|
||||
try await self.connectOnce(
|
||||
endpoint: endpoint,
|
||||
hello: hello,
|
||||
tls: nil,
|
||||
onConnected: onConnected,
|
||||
onInvoke: onInvoke)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private func connectOnce(
|
||||
endpoint: NWEndpoint,
|
||||
hello: BridgeHello,
|
||||
tls: MacNodeBridgeTLSParams?,
|
||||
onConnected: (@Sendable (String, String?) async -> Void)? = nil,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse) async throws
|
||||
{
|
||||
let params = self.makeParameters(tls: tls)
|
||||
let connection = NWConnection(to: endpoint, using: params)
|
||||
let queue = DispatchQueue(label: "com.clawdbot.macos.bridge-session")
|
||||
self.connection = connection
|
||||
self.queue = queue
|
||||
|
||||
let stateStream = Self.makeStateStream(for: connection)
|
||||
connection.start(queue: queue)
|
||||
|
||||
try await Self.waitForReady(stateStream, timeoutSeconds: 6)
|
||||
connection.stateUpdateHandler = { [weak self] state in
|
||||
guard let self else { return }
|
||||
Task { await self.handleConnectionState(state) }
|
||||
}
|
||||
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: 6,
|
||||
onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
},
|
||||
operation: {
|
||||
try await self.send(hello)
|
||||
})
|
||||
|
||||
guard let line = try await AsyncTimeout.withTimeout(
|
||||
seconds: 6,
|
||||
onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
},
|
||||
operation: {
|
||||
try await self.receiveLine()
|
||||
}),
|
||||
let data = line.data(using: .utf8),
|
||||
let base = try? self.decoder.decode(BridgeBaseFrame.self, from: data)
|
||||
else {
|
||||
self.logger.error("node bridge hello failed (unexpected response)")
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 1, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
|
||||
if base.type == "hello-ok" {
|
||||
let ok = try self.decoder.decode(BridgeHelloOk.self, from: data)
|
||||
self.state = .connected(serverName: ok.serverName)
|
||||
self.startPingLoop()
|
||||
let mainKey = ok.mainSessionKey?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
await onConnected?(ok.serverName, mainKey?.isEmpty == false ? mainKey : nil)
|
||||
} else if base.type == "error" {
|
||||
let err = try self.decoder.decode(BridgeErrorFrame.self, from: data)
|
||||
self.state = .failed(message: "\(err.code): \(err.message)")
|
||||
self.logger.error("node bridge hello error: \(err.code, privacy: .public)")
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 2, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(err.code): \(err.message)",
|
||||
])
|
||||
} else {
|
||||
self.state = .failed(message: "Unexpected bridge response")
|
||||
self.logger.error("node bridge hello failed (unexpected frame)")
|
||||
await self.disconnect()
|
||||
throw NSError(domain: "Bridge", code: 3, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Unexpected bridge response",
|
||||
])
|
||||
}
|
||||
|
||||
do {
|
||||
while true {
|
||||
guard let next = try await self.receiveLine() else { break }
|
||||
guard let nextData = next.data(using: .utf8) else { continue }
|
||||
guard let nextBase = try? self.decoder.decode(BridgeBaseFrame.self, from: nextData) else { continue }
|
||||
|
||||
switch nextBase.type {
|
||||
case "res":
|
||||
let res = try self.decoder.decode(BridgeRPCResponse.self, from: nextData)
|
||||
if let cont = self.pendingRPC.removeValue(forKey: res.id) {
|
||||
cont.resume(returning: res)
|
||||
}
|
||||
|
||||
case "event":
|
||||
let evt = try self.decoder.decode(BridgeEventFrame.self, from: nextData)
|
||||
self.broadcastServerEvent(evt)
|
||||
|
||||
case "ping":
|
||||
let ping = try self.decoder.decode(BridgePing.self, from: nextData)
|
||||
try await self.send(BridgePong(type: "pong", id: ping.id))
|
||||
|
||||
case "pong":
|
||||
let pong = try self.decoder.decode(BridgePong.self, from: nextData)
|
||||
self.notePong(pong)
|
||||
|
||||
case "invoke":
|
||||
let req = try self.decoder.decode(BridgeInvokeRequest.self, from: nextData)
|
||||
let taskID = UUID()
|
||||
let task = Task { [weak self] in
|
||||
let res = await onInvoke(req)
|
||||
guard let self else { return }
|
||||
await self.sendInvokeResponse(res, taskID: taskID)
|
||||
}
|
||||
self.invokeTasks[taskID] = task
|
||||
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
await self.handleDisconnect(reason: "connection closed")
|
||||
} catch {
|
||||
self.logger.error(
|
||||
"node bridge receive failed: \(error.localizedDescription, privacy: .public)")
|
||||
await self.handleDisconnect(reason: "receive failed")
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func sendEvent(event: String, payloadJSON: String?) async throws {
|
||||
try await self.send(BridgeEventFrame(type: "event", event: event, payloadJSON: payloadJSON))
|
||||
}
|
||||
|
||||
func request(method: String, paramsJSON: String?, timeoutSeconds: Int = 15) async throws -> Data {
|
||||
guard self.connection != nil else {
|
||||
throw NSError(domain: "Bridge", code: 11, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
])
|
||||
}
|
||||
|
||||
let id = UUID().uuidString
|
||||
let req = BridgeRPCRequest(type: "req", id: id, method: method, paramsJSON: paramsJSON)
|
||||
|
||||
let timeoutTask = Task {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds) * 1_000_000_000)
|
||||
await self.timeoutRPC(id: id)
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
let res: BridgeRPCResponse = try await withCheckedThrowingContinuation { cont in
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.beginRPC(id: id, request: req, continuation: cont)
|
||||
}
|
||||
}
|
||||
|
||||
if res.ok {
|
||||
let payload = res.payloadJSON ?? ""
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
throw NSError(domain: "Bridge", code: 12, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Bridge response not UTF-8",
|
||||
])
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
let code = res.error?.code ?? "UNAVAILABLE"
|
||||
let message = res.error?.message ?? "request failed"
|
||||
throw NSError(domain: "Bridge", code: 13, userInfo: [
|
||||
NSLocalizedDescriptionKey: "\(code): \(message)",
|
||||
])
|
||||
}
|
||||
|
||||
func subscribeServerEvents(bufferingNewest: Int = 200) -> AsyncStream<BridgeEventFrame> {
|
||||
let id = UUID()
|
||||
let session = self
|
||||
return AsyncStream(bufferingPolicy: .bufferingNewest(bufferingNewest)) { continuation in
|
||||
self.serverEventSubscribers[id] = continuation
|
||||
continuation.onTermination = { @Sendable _ in
|
||||
Task { await session.removeServerEventSubscriber(id) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
self.pingTask?.cancel()
|
||||
self.pingTask = nil
|
||||
self.lastPongAt = nil
|
||||
self.disconnectHandler = nil
|
||||
self.cancelInvokeTasks()
|
||||
|
||||
self.connection?.cancel()
|
||||
self.connection = nil
|
||||
self.queue = nil
|
||||
self.buffer = Data()
|
||||
|
||||
let pending = self.pendingRPC.values
|
||||
self.pendingRPC.removeAll()
|
||||
for cont in pending {
|
||||
cont.resume(throwing: NSError(domain: "Bridge", code: 14, userInfo: [
|
||||
NSLocalizedDescriptionKey: "UNAVAILABLE: connection closed",
|
||||
]))
|
||||
}
|
||||
|
||||
for (_, cont) in self.serverEventSubscribers {
|
||||
cont.finish()
|
||||
}
|
||||
self.serverEventSubscribers.removeAll()
|
||||
|
||||
self.state = .idle
|
||||
}
|
||||
|
||||
private func beginRPC(
|
||||
id: String,
|
||||
request: BridgeRPCRequest,
|
||||
continuation: CheckedContinuation<BridgeRPCResponse, Error>) async
|
||||
{
|
||||
self.pendingRPC[id] = continuation
|
||||
do {
|
||||
try await self.send(request)
|
||||
} catch {
|
||||
await self.failRPC(id: id, error: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func makeParameters(tls: MacNodeBridgeTLSParams?) -> NWParameters {
|
||||
let tcpOptions = NWProtocolTCP.Options()
|
||||
tcpOptions.enableKeepalive = true
|
||||
tcpOptions.keepaliveIdle = 30
|
||||
tcpOptions.keepaliveInterval = 15
|
||||
tcpOptions.keepaliveCount = 3
|
||||
|
||||
if let tlsOptions = makeMacNodeTLSOptions(tls) {
|
||||
let params = NWParameters(tls: tlsOptions, tcp: tcpOptions)
|
||||
params.includePeerToPeer = true
|
||||
return params
|
||||
}
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
params.defaultProtocolStack.transportProtocol = tcpOptions
|
||||
return params
|
||||
}
|
||||
|
||||
private func failRPC(id: String, error: Error) async {
|
||||
if let cont = self.pendingRPC.removeValue(forKey: id) {
|
||||
cont.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func timeoutRPC(id: String) async {
|
||||
if let cont = self.pendingRPC.removeValue(forKey: id) {
|
||||
cont.resume(throwing: TimeoutError(message: "request timed out"))
|
||||
}
|
||||
}
|
||||
|
||||
private func removeServerEventSubscriber(_ id: UUID) {
|
||||
self.serverEventSubscribers[id] = nil
|
||||
}
|
||||
|
||||
private func broadcastServerEvent(_ evt: BridgeEventFrame) {
|
||||
for (_, cont) in self.serverEventSubscribers {
|
||||
cont.yield(evt)
|
||||
}
|
||||
}
|
||||
|
||||
private func send(_ obj: some Encodable) async throws {
|
||||
guard let connection = self.connection else {
|
||||
throw NSError(domain: "Bridge", code: 15, userInfo: [
|
||||
NSLocalizedDescriptionKey: "not connected",
|
||||
])
|
||||
}
|
||||
let data = try self.encoder.encode(obj)
|
||||
var line = Data()
|
||||
line.append(data)
|
||||
line.append(0x0A)
|
||||
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in
|
||||
connection.send(content: line, completion: .contentProcessed { err in
|
||||
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveLine() async throws -> String? {
|
||||
while true {
|
||||
if let idx = self.buffer.firstIndex(of: 0x0A) {
|
||||
let line = self.buffer.prefix(upTo: idx)
|
||||
self.buffer.removeSubrange(...idx)
|
||||
return String(data: line, encoding: .utf8)
|
||||
}
|
||||
let chunk = try await self.receiveChunk()
|
||||
if chunk.isEmpty { return nil }
|
||||
self.buffer.append(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveChunk() async throws -> Data {
|
||||
guard let connection else { return Data() }
|
||||
return try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Data, Error>) in
|
||||
connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { data, _, isComplete, error in
|
||||
if let error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
cont.resume(returning: Data())
|
||||
return
|
||||
}
|
||||
cont.resume(returning: data ?? Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func startPingLoop() {
|
||||
self.pingTask?.cancel()
|
||||
self.lastPongAt = self.clock.now
|
||||
self.logger.debug("node bridge ping loop started")
|
||||
self.pingTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.runPingLoop()
|
||||
}
|
||||
}
|
||||
|
||||
private func runPingLoop() async {
|
||||
let interval: Duration = .seconds(15)
|
||||
let timeout: Duration = .seconds(45)
|
||||
|
||||
while !Task.isCancelled {
|
||||
try? await Task.sleep(for: interval)
|
||||
|
||||
guard self.connection != nil else { return }
|
||||
|
||||
if let last = self.lastPongAt {
|
||||
let now = self.clock.now
|
||||
if now > last.advanced(by: timeout) {
|
||||
let age = last.duration(to: now)
|
||||
let ageDescription = String(describing: age)
|
||||
let message =
|
||||
"Node bridge heartbeat timed out; disconnecting " +
|
||||
"(age: \(ageDescription, privacy: .public))."
|
||||
self.logger.warning(message)
|
||||
await self.handleDisconnect(reason: "ping timeout")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
let id = UUID().uuidString
|
||||
do {
|
||||
try await self.send(BridgePing(type: "ping", id: id))
|
||||
} catch {
|
||||
let errorDescription = String(describing: error)
|
||||
let message =
|
||||
"Node bridge ping send failed; disconnecting " +
|
||||
"(error: \(errorDescription, privacy: .public))."
|
||||
self.logger.warning(message)
|
||||
await self.handleDisconnect(reason: "ping send failed")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func notePong(_ pong: BridgePong) {
|
||||
_ = pong
|
||||
self.lastPongAt = self.clock.now
|
||||
}
|
||||
|
||||
private func handleConnectionState(_ state: NWConnection.State) async {
|
||||
switch state {
|
||||
case let .failed(error):
|
||||
let errorDescription = String(describing: error)
|
||||
let message =
|
||||
"Node bridge connection failed; disconnecting " +
|
||||
"(error: \(errorDescription, privacy: .public))."
|
||||
self.logger.warning(message)
|
||||
await self.handleDisconnect(reason: "connection failed")
|
||||
case .cancelled:
|
||||
self.logger.warning("Node bridge connection cancelled; disconnecting.")
|
||||
await self.handleDisconnect(reason: "connection cancelled")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDisconnect(reason: String) async {
|
||||
self.logger.info("node bridge disconnect reason=\(reason, privacy: .public)")
|
||||
if let handler = self.disconnectHandler {
|
||||
await handler(reason)
|
||||
}
|
||||
await self.disconnect()
|
||||
}
|
||||
|
||||
private func logInvokeSendFailure(_ error: Error) {
|
||||
self.logger.error(
|
||||
"node bridge invoke response send failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
private func sendInvokeResponse(_ response: BridgeInvokeResponse, taskID: UUID) async {
|
||||
defer { self.invokeTasks[taskID] = nil }
|
||||
if Task.isCancelled { return }
|
||||
do {
|
||||
try await self.send(response)
|
||||
} catch {
|
||||
self.logInvokeSendFailure(error)
|
||||
}
|
||||
}
|
||||
|
||||
private func cancelInvokeTasks() {
|
||||
for task in self.invokeTasks.values {
|
||||
task.cancel()
|
||||
}
|
||||
self.invokeTasks.removeAll()
|
||||
}
|
||||
|
||||
private static func makeStateStream(
|
||||
for connection: NWConnection) -> AsyncStream<NWConnection.State>
|
||||
{
|
||||
AsyncStream { continuation in
|
||||
connection.stateUpdateHandler = { state in
|
||||
continuation.yield(state)
|
||||
switch state {
|
||||
case .ready, .failed, .cancelled:
|
||||
continuation.finish()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func waitForReady(
|
||||
_ stream: AsyncStream<NWConnection.State>,
|
||||
timeoutSeconds: Double) async throws
|
||||
{
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: timeoutSeconds,
|
||||
onTimeout: {
|
||||
TimeoutError(message: "operation timed out")
|
||||
},
|
||||
operation: {
|
||||
for await state in stream {
|
||||
switch state {
|
||||
case .ready:
|
||||
return
|
||||
case let .failed(err):
|
||||
throw err
|
||||
case .cancelled:
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 21, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection closed",
|
||||
])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
150
apps/macos/Sources/Clawdbot/NodeMode/MacNodeGatewaySession.swift
Normal file
150
apps/macos/Sources/Clawdbot/NodeMode/MacNodeGatewaySession.swift
Normal file
@@ -0,0 +1,150 @@
|
||||
import ClawdbotKit
|
||||
import ClawdbotProtocol
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
private struct NodeInvokeRequestPayload: Codable, Sendable {
|
||||
var id: String
|
||||
var nodeId: String
|
||||
var command: String
|
||||
var paramsJSON: String?
|
||||
var timeoutMs: Int?
|
||||
var idempotencyKey: String?
|
||||
}
|
||||
|
||||
actor MacNodeGatewaySession {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "node.gateway")
|
||||
private let decoder = JSONDecoder()
|
||||
private let encoder = JSONEncoder()
|
||||
private var channel: GatewayChannelActor?
|
||||
private var activeURL: URL?
|
||||
private var activeToken: String?
|
||||
private var activePassword: String?
|
||||
private var connectOptions: GatewayConnectOptions?
|
||||
private var onConnected: (@Sendable () async -> Void)?
|
||||
private var onDisconnected: (@Sendable (String) async -> Void)?
|
||||
private var onInvoke: (@Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse)?
|
||||
|
||||
func connect(
|
||||
url: URL,
|
||||
token: String?,
|
||||
password: String?,
|
||||
connectOptions: GatewayConnectOptions,
|
||||
sessionBox: WebSocketSessionBox?,
|
||||
onConnected: @escaping @Sendable () async -> Void,
|
||||
onDisconnected: @escaping @Sendable (String) async -> Void,
|
||||
onInvoke: @escaping @Sendable (BridgeInvokeRequest) async -> BridgeInvokeResponse
|
||||
) async throws {
|
||||
let shouldReconnect = self.activeURL != url ||
|
||||
self.activeToken != token ||
|
||||
self.activePassword != password ||
|
||||
self.channel == nil
|
||||
|
||||
self.connectOptions = connectOptions
|
||||
self.onConnected = onConnected
|
||||
self.onDisconnected = onDisconnected
|
||||
self.onInvoke = onInvoke
|
||||
|
||||
if shouldReconnect {
|
||||
if let existing = self.channel {
|
||||
await existing.shutdown()
|
||||
}
|
||||
let channel = GatewayChannelActor(
|
||||
url: url,
|
||||
token: token,
|
||||
password: password,
|
||||
session: sessionBox,
|
||||
pushHandler: { [weak self] push in
|
||||
await self?.handlePush(push)
|
||||
},
|
||||
connectOptions: connectOptions,
|
||||
disconnectHandler: { [weak self] reason in
|
||||
await self?.onDisconnected?(reason)
|
||||
})
|
||||
self.channel = channel
|
||||
self.activeURL = url
|
||||
self.activeToken = token
|
||||
self.activePassword = password
|
||||
}
|
||||
|
||||
guard let channel = self.channel else {
|
||||
throw NSError(domain: "Gateway", code: 0, userInfo: [
|
||||
NSLocalizedDescriptionKey: "gateway channel unavailable",
|
||||
])
|
||||
}
|
||||
|
||||
do {
|
||||
try await channel.connect()
|
||||
await onConnected()
|
||||
} catch {
|
||||
await onDisconnected(error.localizedDescription)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect() async {
|
||||
await self.channel?.shutdown()
|
||||
self.channel = nil
|
||||
self.activeURL = nil
|
||||
self.activeToken = nil
|
||||
self.activePassword = nil
|
||||
}
|
||||
|
||||
func sendEvent(event: String, payloadJSON: String?) async {
|
||||
guard let channel = self.channel else { return }
|
||||
let params: [String: ClawdbotProtocol.AnyCodable] = [
|
||||
"event": ClawdbotProtocol.AnyCodable(event),
|
||||
"payloadJSON": ClawdbotProtocol.AnyCodable(payloadJSON ?? NSNull()),
|
||||
]
|
||||
do {
|
||||
_ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000)
|
||||
} catch {
|
||||
self.logger.error("node event failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handlePush(_ push: GatewayPush) async {
|
||||
switch push {
|
||||
case let .event(evt):
|
||||
await self.handleEvent(evt)
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func handleEvent(_ evt: EventFrame) async {
|
||||
guard evt.event == "node.invoke.request" else { return }
|
||||
guard let payload = evt.payload else { return }
|
||||
do {
|
||||
let data = try self.encoder.encode(payload)
|
||||
let request = try self.decoder.decode(NodeInvokeRequestPayload.self, from: data)
|
||||
guard let onInvoke else { return }
|
||||
let req = BridgeInvokeRequest(id: request.id, command: request.command, paramsJSON: request.paramsJSON)
|
||||
let response = await onInvoke(req)
|
||||
await self.sendInvokeResult(request: request, response: response)
|
||||
} catch {
|
||||
self.logger.error("node invoke decode failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func sendInvokeResult(request: NodeInvokeRequestPayload, response: BridgeInvokeResponse) async {
|
||||
guard let channel = self.channel else { return }
|
||||
var params: [String: ClawdbotProtocol.AnyCodable] = [
|
||||
"id": ClawdbotProtocol.AnyCodable(request.id),
|
||||
"nodeId": ClawdbotProtocol.AnyCodable(request.nodeId),
|
||||
"ok": ClawdbotProtocol.AnyCodable(response.ok),
|
||||
"payloadJSON": ClawdbotProtocol.AnyCodable(response.payloadJSON ?? NSNull()),
|
||||
]
|
||||
if let error = response.error {
|
||||
params["error"] = ClawdbotProtocol.AnyCodable([
|
||||
"code": ClawdbotProtocol.AnyCodable(error.code.rawValue),
|
||||
"message": ClawdbotProtocol.AnyCodable(error.message),
|
||||
])
|
||||
}
|
||||
do {
|
||||
_ = try await channel.request(method: "node.invoke.result", params: params, timeoutMs: 15000)
|
||||
} catch {
|
||||
self.logger.error("node invoke result failed: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import ClawdbotDiscovery
|
||||
import ClawdbotKit
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
|
||||
private struct BridgeTarget {
|
||||
let endpoint: NWEndpoint
|
||||
let stableID: String
|
||||
let tls: MacNodeBridgeTLSParams?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MacNodeModeCoordinator {
|
||||
static let shared = MacNodeModeCoordinator()
|
||||
@@ -17,8 +9,7 @@ final class MacNodeModeCoordinator {
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "mac-node")
|
||||
private var task: Task<Void, Never>?
|
||||
private let runtime = MacNodeRuntime()
|
||||
private let session = MacNodeBridgeSession()
|
||||
private var tunnel: RemotePortTunnel?
|
||||
private let session = MacNodeGatewaySession()
|
||||
|
||||
func start() {
|
||||
guard self.task == nil else { return }
|
||||
@@ -31,12 +22,10 @@ final class MacNodeModeCoordinator {
|
||||
self.task?.cancel()
|
||||
self.task = nil
|
||||
Task { await self.session.disconnect() }
|
||||
self.tunnel?.terminate()
|
||||
self.tunnel = nil
|
||||
}
|
||||
|
||||
func setPreferredBridgeStableID(_ stableID: String?) {
|
||||
BridgeDiscoveryPreferences.setPreferredStableID(stableID)
|
||||
func setPreferredGatewayStableID(_ stableID: String?) {
|
||||
GatewayDiscoveryPreferences.setPreferredStableID(stableID)
|
||||
Task { await self.session.disconnect() }
|
||||
}
|
||||
|
||||
@@ -44,6 +33,7 @@ final class MacNodeModeCoordinator {
|
||||
var retryDelay: UInt64 = 1_000_000_000
|
||||
var lastCameraEnabled: Bool?
|
||||
let defaults = UserDefaults.standard
|
||||
|
||||
while !Task.isCancelled {
|
||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
@@ -59,34 +49,42 @@ final class MacNodeModeCoordinator {
|
||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
||||
}
|
||||
|
||||
guard let target = await self.resolveBridgeEndpoint(timeoutSeconds: 5) else {
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
continue
|
||||
}
|
||||
|
||||
retryDelay = 1_000_000_000
|
||||
do {
|
||||
let hello = await self.makeHello()
|
||||
self.logger.info(
|
||||
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)")
|
||||
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||
let caps = self.currentCaps()
|
||||
let commands = self.currentCommands(caps: caps)
|
||||
let permissions = await self.currentPermissions()
|
||||
let connectOptions = GatewayConnectOptions(
|
||||
role: "node",
|
||||
scopes: [],
|
||||
caps: caps,
|
||||
commands: commands,
|
||||
permissions: permissions,
|
||||
clientId: "clawdbot-macos",
|
||||
clientMode: "node",
|
||||
clientDisplayName: InstanceIdentity.displayName)
|
||||
let sessionBox = self.buildSessionBox(url: config.url)
|
||||
|
||||
try await self.session.connect(
|
||||
endpoint: target.endpoint,
|
||||
hello: hello,
|
||||
tls: target.tls,
|
||||
onConnected: { [weak self] serverName, mainSessionKey in
|
||||
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
||||
if let mainSessionKey {
|
||||
await self?.runtime.updateMainSessionKey(mainSessionKey)
|
||||
}
|
||||
await self?.runtime.setEventSender { [weak self] event, payload in
|
||||
url: config.url,
|
||||
token: config.token,
|
||||
password: config.password,
|
||||
connectOptions: connectOptions,
|
||||
sessionBox: sessionBox,
|
||||
onConnected: { [weak self] in
|
||||
guard let self else { return }
|
||||
self.logger.info("mac node connected to gateway")
|
||||
let mainSessionKey = await GatewayConnection.shared.mainSessionKey()
|
||||
await self.runtime.updateMainSessionKey(mainSessionKey)
|
||||
await self.runtime.setEventSender { [weak self] event, payload in
|
||||
guard let self else { return }
|
||||
try? await self.session.sendEvent(event: event, payloadJSON: payload)
|
||||
await self.session.sendEvent(event: event, payloadJSON: payload)
|
||||
}
|
||||
},
|
||||
onDisconnected: { [weak self] reason in
|
||||
await self?.runtime.setEventSender(nil)
|
||||
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
||||
guard let self else { return }
|
||||
await self.runtime.setEventSender(nil)
|
||||
self.logger.error("mac node disconnected: \(reason, privacy: .public)")
|
||||
},
|
||||
onInvoke: { [weak self] req in
|
||||
guard let self else {
|
||||
@@ -97,43 +95,17 @@ final class MacNodeModeCoordinator {
|
||||
}
|
||||
return await self.runtime.handleInvoke(req)
|
||||
})
|
||||
|
||||
retryDelay = 1_000_000_000
|
||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||
} catch {
|
||||
if await self.tryPair(target: target, error: error) {
|
||||
continue
|
||||
}
|
||||
self.logger.error(
|
||||
"mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
||||
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
|
||||
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
|
||||
retryDelay = min(retryDelay * 2, 10_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func makeHello() async -> BridgeHello {
|
||||
let token = MacNodeTokenStore.loadToken()
|
||||
let caps = self.currentCaps()
|
||||
let commands = self.currentCommands(caps: caps)
|
||||
let permissions = await self.currentPermissions()
|
||||
let uiVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
|
||||
let liveGatewayVersion = await GatewayConnection.shared.cachedGatewayVersion()
|
||||
let fallbackGatewayVersion = GatewayProcessManager.shared.environmentStatus.gatewayVersion
|
||||
let coreVersion = (liveGatewayVersion ?? fallbackGatewayVersion)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return BridgeHello(
|
||||
nodeId: Self.nodeId(),
|
||||
displayName: InstanceIdentity.displayName,
|
||||
token: token,
|
||||
platform: "macos",
|
||||
version: uiVersion,
|
||||
coreVersion: coreVersion?.isEmpty == false ? coreVersion : nil,
|
||||
uiVersion: uiVersion,
|
||||
deviceFamily: "Mac",
|
||||
modelIdentifier: InstanceIdentity.modelIdentifier,
|
||||
caps: caps,
|
||||
commands: commands,
|
||||
permissions: permissions)
|
||||
}
|
||||
|
||||
private func currentCaps() -> [String] {
|
||||
var caps: [String] = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
|
||||
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
|
||||
@@ -182,370 +154,18 @@ final class MacNodeModeCoordinator {
|
||||
return commands
|
||||
}
|
||||
|
||||
private func tryPair(target: BridgeTarget, error: Error) async -> Bool {
|
||||
let text = error.localizedDescription.uppercased()
|
||||
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
||||
|
||||
do {
|
||||
let shouldSilent = await MainActor.run {
|
||||
AppStateStore.shared.connectionMode == .remote
|
||||
}
|
||||
let hello = await self.makeHello()
|
||||
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
||||
endpoint: target.endpoint,
|
||||
hello: hello,
|
||||
silent: shouldSilent,
|
||||
tls: target.tls,
|
||||
onStatus: { [weak self] status in
|
||||
self?.logger.info("mac node pairing: \(status, privacy: .public)")
|
||||
})
|
||||
if !token.isEmpty {
|
||||
MacNodeTokenStore.saveToken(token)
|
||||
}
|
||||
return true
|
||||
} catch {
|
||||
self.logger.error("mac node pairing failed: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func nodeId() -> String {
|
||||
"mac-\(InstanceIdentity.instanceId)"
|
||||
}
|
||||
|
||||
private func resolveLoopbackBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
guard let port = Self.loopbackBridgePort(),
|
||||
let endpointPort = NWEndpoint.Port(rawValue: port)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: endpointPort)
|
||||
let reachable = await Self.probeEndpoint(endpoint, timeoutSeconds: timeoutSeconds)
|
||||
guard reachable else { return nil }
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
}
|
||||
|
||||
static func loopbackBridgePort() -> UInt16? {
|
||||
if let raw = ProcessInfo.processInfo.environment["CLAWDBOT_BRIDGE_PORT"],
|
||||
let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)),
|
||||
parsed > 0,
|
||||
parsed <= Int(UInt16.max)
|
||||
{
|
||||
return UInt16(parsed)
|
||||
}
|
||||
return 18790
|
||||
}
|
||||
|
||||
static func remoteBridgePort() -> Int {
|
||||
let fallback = Int(Self.loopbackBridgePort() ?? 18790)
|
||||
let settings = CommandResolver.connectionSettings()
|
||||
let sshHost = CommandResolver.parseSSHTarget(settings.target)?.host ?? ""
|
||||
let base =
|
||||
ClawdbotConfigFile.remoteGatewayPort(matchingHost: sshHost) ??
|
||||
GatewayEnvironment.gatewayPort()
|
||||
guard base > 0 else { return fallback }
|
||||
return Self.derivePort(base: base, offset: 1, fallback: fallback)
|
||||
}
|
||||
|
||||
private static func derivePort(base: Int, offset: Int, fallback: Int) -> Int {
|
||||
let derived = base + offset
|
||||
guard derived > 0, derived <= Int(UInt16.max) else { return fallback }
|
||||
return derived
|
||||
}
|
||||
|
||||
static func probeEndpoint(_ endpoint: NWEndpoint, timeoutSeconds: Double) async -> Bool {
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
let stream = Self.makeStateStream(for: connection)
|
||||
connection.start(queue: DispatchQueue(label: "com.clawdbot.macos.bridge-loopback-probe"))
|
||||
do {
|
||||
try await Self.waitForReady(stream, timeoutSeconds: timeoutSeconds)
|
||||
connection.cancel()
|
||||
return true
|
||||
} catch {
|
||||
connection.cancel()
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private static func makeStateStream(
|
||||
for connection: NWConnection) -> AsyncStream<NWConnection.State>
|
||||
{
|
||||
AsyncStream { continuation in
|
||||
connection.stateUpdateHandler = { state in
|
||||
continuation.yield(state)
|
||||
switch state {
|
||||
case .ready, .failed, .cancelled:
|
||||
continuation.finish()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func waitForReady(
|
||||
_ stream: AsyncStream<NWConnection.State>,
|
||||
timeoutSeconds: Double) async throws
|
||||
{
|
||||
try await AsyncTimeout.withTimeout(
|
||||
seconds: timeoutSeconds,
|
||||
onTimeout: {
|
||||
NSError(domain: "Bridge", code: 22, userInfo: [
|
||||
NSLocalizedDescriptionKey: "operation timed out",
|
||||
])
|
||||
},
|
||||
operation: {
|
||||
for await state in stream {
|
||||
switch state {
|
||||
case .ready:
|
||||
return
|
||||
case let .failed(err):
|
||||
throw err
|
||||
case .cancelled:
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection cancelled",
|
||||
])
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw NSError(domain: "Bridge", code: 21, userInfo: [
|
||||
NSLocalizedDescriptionKey: "Connection closed",
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
private func resolveBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
let mode = await MainActor.run(body: { AppStateStore.shared.connectionMode })
|
||||
if mode == .remote {
|
||||
do {
|
||||
if let tunnel = self.tunnel,
|
||||
tunnel.process.isRunning,
|
||||
let localPort = tunnel.localPort
|
||||
{
|
||||
let healthy = await self.bridgeTunnelHealthy(localPort: localPort, timeoutSeconds: 1.0)
|
||||
if healthy, let port = NWEndpoint.Port(rawValue: localPort) {
|
||||
self.logger.info(
|
||||
"reusing mac node bridge tunnel localPort=\(localPort, privacy: .public)")
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
}
|
||||
self.logger.error(
|
||||
"mac node bridge tunnel unhealthy localPort=\(localPort, privacy: .public); restarting")
|
||||
tunnel.terminate()
|
||||
self.tunnel = nil
|
||||
}
|
||||
|
||||
let remotePort = Self.remoteBridgePort()
|
||||
let preferredLocalPort = Self.loopbackBridgePort()
|
||||
if let preferredLocalPort {
|
||||
self.logger.info(
|
||||
"mac node bridge tunnel starting " +
|
||||
"preferredLocalPort=\(preferredLocalPort, privacy: .public) " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
} else {
|
||||
self.logger.info(
|
||||
"mac node bridge tunnel starting " +
|
||||
"preferredLocalPort=none " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
}
|
||||
self.tunnel = try await RemotePortTunnel.create(
|
||||
remotePort: remotePort,
|
||||
preferredLocalPort: preferredLocalPort,
|
||||
allowRemoteUrlOverride: false,
|
||||
allowRandomLocalPort: true)
|
||||
if let localPort = self.tunnel?.localPort,
|
||||
let port = NWEndpoint.Port(rawValue: localPort)
|
||||
{
|
||||
self.logger.info(
|
||||
"mac node bridge tunnel ready " +
|
||||
"localPort=\(localPort, privacy: .public) " +
|
||||
"remotePort=\(remotePort, privacy: .public)")
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let tlsParams = Self.resolveManualTLSParams(stableID: stableID)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
}
|
||||
} catch {
|
||||
self.logger.error("mac node bridge tunnel failed: \(error.localizedDescription, privacy: .public)")
|
||||
self.tunnel?.terminate()
|
||||
self.tunnel = nil
|
||||
}
|
||||
} else if let tunnel = self.tunnel {
|
||||
tunnel.terminate()
|
||||
self.tunnel = nil
|
||||
}
|
||||
if mode == .local, let target = await self.resolveLoopbackBridgeEndpoint(timeoutSeconds: 0.4) {
|
||||
return target
|
||||
}
|
||||
return await Self.discoverBridgeEndpoint(timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private static func handleBridgeDisconnect(reason: String) async {
|
||||
guard reason.localizedCaseInsensitiveContains("ping") else { return }
|
||||
let coordinator = MacNodeModeCoordinator.shared
|
||||
coordinator.logger.error(
|
||||
"mac node bridge disconnected (\(reason, privacy: .public)); resetting tunnel")
|
||||
coordinator.tunnel?.terminate()
|
||||
coordinator.tunnel = nil
|
||||
}
|
||||
|
||||
private func bridgeTunnelHealthy(localPort: UInt16, timeoutSeconds: Double) async -> Bool {
|
||||
guard let port = NWEndpoint.Port(rawValue: localPort) else { return false }
|
||||
return await Self.probeEndpoint(.hostPort(host: "127.0.0.1", port: port), timeoutSeconds: timeoutSeconds)
|
||||
}
|
||||
|
||||
private static func discoverBridgeEndpoint(timeoutSeconds: Double) async -> BridgeTarget? {
|
||||
final class DiscoveryState: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var resolved = false
|
||||
var browsers: [NWBrowser] = []
|
||||
var continuation: CheckedContinuation<BridgeTarget?, Never>?
|
||||
|
||||
func finish(_ target: BridgeTarget?) {
|
||||
self.lock.lock()
|
||||
defer { lock.unlock() }
|
||||
if self.resolved { return }
|
||||
self.resolved = true
|
||||
for browser in self.browsers {
|
||||
browser.cancel()
|
||||
}
|
||||
self.continuation?.resume(returning: target)
|
||||
self.continuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
return await withCheckedContinuation { cont in
|
||||
let state = DiscoveryState()
|
||||
state.continuation = cont
|
||||
|
||||
let params = NWParameters.tcp
|
||||
params.includePeerToPeer = true
|
||||
|
||||
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
||||
let browser = NWBrowser(
|
||||
for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
|
||||
using: params)
|
||||
browser.browseResultsChangedHandler = { results, _ in
|
||||
let preferred = BridgeDiscoveryPreferences.preferredStableID()
|
||||
if let preferred,
|
||||
let match = results.first(where: {
|
||||
if case .service = $0.endpoint {
|
||||
return BridgeEndpointID.stableID($0.endpoint) == preferred
|
||||
}
|
||||
return false
|
||||
})
|
||||
{
|
||||
state.finish(Self.targetFromResult(match))
|
||||
return
|
||||
}
|
||||
|
||||
if let result = results.first(where: { if case .service = $0.endpoint { true } else { false } }) {
|
||||
state.finish(Self.targetFromResult(result))
|
||||
}
|
||||
}
|
||||
browser.stateUpdateHandler = { browserState in
|
||||
if case .failed = browserState {
|
||||
state.finish(nil)
|
||||
}
|
||||
}
|
||||
state.browsers.append(browser)
|
||||
browser.start(queue: DispatchQueue(label: "com.clawdbot.macos.bridge-discovery.\(domain)"))
|
||||
}
|
||||
|
||||
Task {
|
||||
try? await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
|
||||
state.finish(nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private nonisolated static func targetFromResult(_ result: NWBrowser.Result) -> BridgeTarget? {
|
||||
let endpoint = result.endpoint
|
||||
guard case .service = endpoint else { return nil }
|
||||
let stableID = BridgeEndpointID.stableID(endpoint)
|
||||
let txt = result.endpoint.txtRecord?.dictionary ?? [:]
|
||||
let tlsEnabled = Self.txtBoolValue(txt, key: "bridgeTls")
|
||||
let tlsFingerprint = Self.txtValue(txt, key: "bridgeTlsSha256")
|
||||
let tlsParams = Self.resolveDiscoveredTLSParams(
|
||||
stableID: stableID,
|
||||
tlsEnabled: tlsEnabled,
|
||||
tlsFingerprintSha256: tlsFingerprint)
|
||||
return BridgeTarget(endpoint: endpoint, stableID: stableID, tls: tlsParams)
|
||||
}
|
||||
|
||||
private nonisolated static func resolveDiscoveredTLSParams(
|
||||
stableID: String,
|
||||
tlsEnabled: Bool,
|
||||
tlsFingerprintSha256: String?) -> MacNodeBridgeTLSParams?
|
||||
{
|
||||
let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID)
|
||||
|
||||
if tlsEnabled || tlsFingerprintSha256 != nil {
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: tlsFingerprintSha256 ?? stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
if let stored {
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private nonisolated static func resolveManualTLSParams(stableID: String) -> MacNodeBridgeTLSParams? {
|
||||
if let stored = MacNodeBridgeTLSStore.loadFingerprint(stableID: stableID) {
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: false,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
return MacNodeBridgeTLSParams(
|
||||
required: false,
|
||||
expectedFingerprint: nil,
|
||||
allowTOFU: true,
|
||||
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
|
||||
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||
let host = url.host ?? "gateway"
|
||||
let port = url.port ?? 443
|
||||
let stableID = "\(host):\(port)"
|
||||
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||
let params = GatewayTLSParams(
|
||||
required: true,
|
||||
expectedFingerprint: stored,
|
||||
allowTOFU: stored == nil,
|
||||
storeKey: stableID)
|
||||
}
|
||||
|
||||
private nonisolated static func txtValue(_ dict: [String: String], key: String) -> String? {
|
||||
let raw = dict[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
return raw.isEmpty ? nil : raw
|
||||
}
|
||||
|
||||
private nonisolated static func txtBoolValue(_ dict: [String: String], key: String) -> Bool {
|
||||
guard let raw = self.txtValue(dict, key: key)?.lowercased() else { return false }
|
||||
return raw == "1" || raw == "true" || raw == "yes"
|
||||
}
|
||||
}
|
||||
|
||||
enum MacNodeTokenStore {
|
||||
private static let suiteName = "com.clawdbot.shared"
|
||||
private static let tokenKey = "mac.node.bridge.token"
|
||||
|
||||
private static var defaults: UserDefaults {
|
||||
UserDefaults(suiteName: suiteName) ?? .standard
|
||||
}
|
||||
|
||||
static func loadToken() -> String? {
|
||||
let raw = self.defaults.string(forKey: self.tokenKey)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return raw?.isEmpty == false ? raw : nil
|
||||
}
|
||||
|
||||
static func saveToken(_ token: String) {
|
||||
self.defaults.set(token, forKey: self.tokenKey)
|
||||
let session = GatewayTLSPinningSession(params: params)
|
||||
return WebSocketSessionBox(session: session)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -486,46 +486,20 @@ actor MacNodeRuntime {
|
||||
return false
|
||||
}()
|
||||
|
||||
var approvedByAsk = false
|
||||
if requiresAsk {
|
||||
let decision = await ExecApprovalsPromptPresenter.prompt(
|
||||
ExecApprovalPromptRequest(
|
||||
command: displayCommand,
|
||||
cwd: params.cwd,
|
||||
let approvedByAsk = params.approved == true
|
||||
if requiresAsk && !approvedByAsk {
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
security: security.rawValue,
|
||||
ask: ask.rawValue,
|
||||
agentId: agentId,
|
||||
resolvedPath: resolution?.resolvedPath))
|
||||
|
||||
switch decision {
|
||||
case .deny:
|
||||
await self.emitExecEvent(
|
||||
"exec.denied",
|
||||
payload: ExecEventPayload(
|
||||
sessionKey: sessionKey,
|
||||
runId: runId,
|
||||
host: "node",
|
||||
command: displayCommand,
|
||||
reason: "user-denied"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: user denied")
|
||||
case .allowAlways:
|
||||
approvedByAsk = true
|
||||
if security == .allowlist {
|
||||
let pattern = resolution?.resolvedPath ??
|
||||
resolution?.rawExecutable ??
|
||||
command.first?.trimmingCharacters(in: .whitespacesAndNewlines) ??
|
||||
""
|
||||
if !pattern.isEmpty {
|
||||
ExecApprovalsStore.addAllowlistEntry(agentId: agentId, pattern: pattern)
|
||||
}
|
||||
}
|
||||
case .allowOnce:
|
||||
approvedByAsk = true
|
||||
}
|
||||
command: displayCommand,
|
||||
reason: "approval-required"))
|
||||
return Self.errorResponse(
|
||||
req,
|
||||
code: .unavailable,
|
||||
message: "SYSTEM_RUN_DENIED: approval required")
|
||||
}
|
||||
|
||||
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
||||
@@ -762,7 +736,7 @@ actor MacNodeRuntime {
|
||||
|
||||
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||
guard let json, let data = json.data(using: .utf8) else {
|
||||
throw NSError(domain: "Bridge", code: 20, userInfo: [
|
||||
throw NSError(domain: "Gateway", code: 20, userInfo: [
|
||||
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||
])
|
||||
}
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -63,7 +63,7 @@ extension OnboardingView {
|
||||
await self.ensureDefaultWorkspace()
|
||||
self.refreshAnthropicOAuthStatus()
|
||||
self.refreshBootstrapStatus()
|
||||
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
|
||||
self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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, _):
|
||||
@@ -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)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite(.serialized)
|
||||
struct BridgeServerTests {
|
||||
@Test func bridgeServerExercisesPaths() async {
|
||||
let server = BridgeServer()
|
||||
await server.exerciseForTesting()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
import Darwin
|
||||
import Foundation
|
||||
import Network
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct MacNodeBridgeDiscoveryTests {
|
||||
@MainActor
|
||||
@Test func loopbackBridgePortDefaultsAndOverrides() {
|
||||
withEnv("CLAWDBOT_BRIDGE_PORT", value: nil) {
|
||||
#expect(MacNodeModeCoordinator.loopbackBridgePort() == 18790)
|
||||
}
|
||||
withEnv("CLAWDBOT_BRIDGE_PORT", value: "19991") {
|
||||
#expect(MacNodeModeCoordinator.loopbackBridgePort() == 19991)
|
||||
}
|
||||
withEnv("CLAWDBOT_BRIDGE_PORT", value: "not-a-port") {
|
||||
#expect(MacNodeModeCoordinator.loopbackBridgePort() == 18790)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func probeEndpointSucceedsForOpenPort() async throws {
|
||||
let listener = try NWListener(using: .tcp, on: .any)
|
||||
listener.newConnectionHandler = { connection in
|
||||
connection.cancel()
|
||||
}
|
||||
listener.start(queue: DispatchQueue(label: "com.clawdbot.tests.bridge-listener"))
|
||||
try await waitForListenerReady(listener, timeoutSeconds: 1.0)
|
||||
|
||||
guard let port = listener.port else {
|
||||
listener.cancel()
|
||||
throw TestError(message: "listener port missing")
|
||||
}
|
||||
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
||||
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.6)
|
||||
listener.cancel()
|
||||
#expect(ok == true)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func probeEndpointFailsForClosedPort() async throws {
|
||||
let port = try reserveEphemeralPort()
|
||||
let endpoint = NWEndpoint.hostPort(host: "127.0.0.1", port: port)
|
||||
let ok = await MacNodeModeCoordinator.probeEndpoint(endpoint, timeoutSeconds: 0.4)
|
||||
#expect(ok == false)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func remoteBridgePortUsesMatchingRemoteUrlPort() {
|
||||
let configPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let prevTarget = defaults.string(forKey: remoteTargetKey)
|
||||
defer {
|
||||
if let prevTarget {
|
||||
defaults.set(prevTarget, forKey: remoteTargetKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: remoteTargetKey)
|
||||
}
|
||||
}
|
||||
|
||||
withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) {
|
||||
withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") {
|
||||
defaults.set("user@bridge.ts.net", forKey: remoteTargetKey)
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://bridge.ts.net:25000",
|
||||
],
|
||||
],
|
||||
])
|
||||
#expect(MacNodeModeCoordinator.remoteBridgePort() == 25001)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func remoteBridgePortFallsBackWhenRemoteUrlHostMismatch() {
|
||||
let configPath = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("clawdbot-config-\(UUID().uuidString)")
|
||||
.appendingPathComponent("clawdbot.json")
|
||||
.path
|
||||
|
||||
let defaults = UserDefaults.standard
|
||||
let prevTarget = defaults.string(forKey: remoteTargetKey)
|
||||
defer {
|
||||
if let prevTarget {
|
||||
defaults.set(prevTarget, forKey: remoteTargetKey)
|
||||
} else {
|
||||
defaults.removeObject(forKey: remoteTargetKey)
|
||||
}
|
||||
}
|
||||
|
||||
withEnv("CLAWDBOT_CONFIG_PATH", value: configPath) {
|
||||
withEnv("CLAWDBOT_GATEWAY_PORT", value: "20000") {
|
||||
defaults.set("user@other.ts.net", forKey: remoteTargetKey)
|
||||
ClawdbotConfigFile.saveDict([
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": "ws://bridge.ts.net:25000",
|
||||
],
|
||||
],
|
||||
])
|
||||
#expect(MacNodeModeCoordinator.remoteBridgePort() == 20001)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct TestError: Error {
|
||||
let message: String
|
||||
}
|
||||
|
||||
private struct ListenerTimeoutError: Error {}
|
||||
|
||||
private func waitForListenerReady(_ listener: NWListener, timeoutSeconds: Double) async throws {
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
final class ListenerState: @unchecked Sendable {
|
||||
let lock = NSLock()
|
||||
var finished = false
|
||||
}
|
||||
let state = ListenerState()
|
||||
let finish: @Sendable (Result<Void, Error>) -> Void = { result in
|
||||
state.lock.lock()
|
||||
defer { state.lock.unlock() }
|
||||
guard !state.finished else { return }
|
||||
state.finished = true
|
||||
cont.resume(with: result)
|
||||
}
|
||||
|
||||
listener.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
finish(.success(()))
|
||||
case let .failed(err):
|
||||
finish(.failure(err))
|
||||
case .cancelled:
|
||||
finish(.failure(ListenerTimeoutError()))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
group.addTask {
|
||||
try await Task.sleep(nanoseconds: UInt64(timeoutSeconds * 1_000_000_000))
|
||||
throw ListenerTimeoutError()
|
||||
}
|
||||
_ = try await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
private func withEnv(_ key: String, value: String?, _ body: () -> Void) {
|
||||
let existing = getenv(key).map { String(cString: $0) }
|
||||
if let value {
|
||||
setenv(key, value, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
defer {
|
||||
if let existing {
|
||||
setenv(key, existing, 1)
|
||||
} else {
|
||||
unsetenv(key)
|
||||
}
|
||||
}
|
||||
body()
|
||||
}
|
||||
|
||||
private func reserveEphemeralPort() throws -> NWEndpoint.Port {
|
||||
let fd = socket(AF_INET, SOCK_STREAM, 0)
|
||||
if fd < 0 {
|
||||
throw TestError(message: "socket failed")
|
||||
}
|
||||
defer { close(fd) }
|
||||
|
||||
var addr = sockaddr_in()
|
||||
addr.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
||||
addr.sin_family = sa_family_t(AF_INET)
|
||||
addr.sin_port = in_port_t(0)
|
||||
addr.sin_addr = in_addr(s_addr: inet_addr("127.0.0.1"))
|
||||
|
||||
let bindResult = withUnsafePointer(to: &addr) { pointer -> Int32 in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
||||
}
|
||||
}
|
||||
if bindResult != 0 {
|
||||
throw TestError(message: "bind failed")
|
||||
}
|
||||
|
||||
var resolved = sockaddr_in()
|
||||
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
|
||||
let nameResult = withUnsafeMutablePointer(to: &resolved) { pointer -> Int32 in
|
||||
pointer.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||
getsockname(fd, $0, &length)
|
||||
}
|
||||
}
|
||||
if nameResult != 0 {
|
||||
throw TestError(message: "getsockname failed")
|
||||
}
|
||||
|
||||
let port = UInt16(bigEndian: resolved.sin_port)
|
||||
guard let endpointPort = NWEndpoint.Port(rawValue: port), endpointPort.rawValue != 0 else {
|
||||
throw TestError(message: "ephemeral port missing")
|
||||
}
|
||||
return endpointPort
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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 + ".")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -425,7 +425,11 @@ export function createExecTool(
|
||||
applyPathPrepend(env, defaultPathPrepend);
|
||||
|
||||
if (host === "node") {
|
||||
if (security === "deny") {
|
||||
const approvals = resolveExecApprovals(defaults?.agentId);
|
||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||
const askFallback = approvals.agent.askFallback;
|
||||
if (hostSecurity === "deny") {
|
||||
throw new Error("exec denied: host=node security=deny");
|
||||
}
|
||||
const boundNode = defaults?.node?.trim();
|
||||
@@ -465,6 +469,79 @@ export function createExecTool(
|
||||
if (nodeEnv) {
|
||||
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
|
||||
}
|
||||
const resolution = resolveCommandResolution(params.command, workdir, env);
|
||||
const allowlistMatch =
|
||||
hostSecurity === "allowlist" ? matchAllowlist(approvals.allowlist, resolution) : null;
|
||||
const requiresAsk =
|
||||
hostAsk === "always" ||
|
||||
(hostAsk === "on-miss" && hostSecurity === "allowlist" && !allowlistMatch);
|
||||
|
||||
let approvedByAsk = false;
|
||||
if (requiresAsk) {
|
||||
const decisionResult = (await callGatewayTool("exec.approval.request", {}, {
|
||||
command: params.command,
|
||||
cwd: workdir,
|
||||
host: "node",
|
||||
security: hostSecurity,
|
||||
ask: hostAsk,
|
||||
agentId: defaults?.agentId,
|
||||
resolvedPath: resolution?.resolvedPath ?? null,
|
||||
sessionKey: defaults?.sessionKey ?? null,
|
||||
timeoutMs: 120_000,
|
||||
})) as { decision?: string } | null;
|
||||
const decision =
|
||||
decisionResult && typeof decisionResult === "object"
|
||||
? decisionResult.decision ?? null
|
||||
: null;
|
||||
|
||||
if (decision === "deny") {
|
||||
throw new Error("exec denied: user denied");
|
||||
}
|
||||
if (!decision) {
|
||||
if (askFallback === "full") {
|
||||
approvedByAsk = true;
|
||||
} else if (askFallback === "allowlist") {
|
||||
if (!allowlistMatch) {
|
||||
throw new Error(
|
||||
"exec denied: approval required (approval UI not available)",
|
||||
);
|
||||
}
|
||||
approvedByAsk = true;
|
||||
} else {
|
||||
throw new Error("exec denied: approval required (approval UI not available)");
|
||||
}
|
||||
}
|
||||
if (decision === "allow-once") {
|
||||
approvedByAsk = true;
|
||||
}
|
||||
if (decision === "allow-always") {
|
||||
approvedByAsk = true;
|
||||
if (hostSecurity === "allowlist") {
|
||||
const pattern =
|
||||
resolution?.resolvedPath ??
|
||||
resolution?.rawExecutable ??
|
||||
params.command.split(/\s+/).shift() ??
|
||||
"";
|
||||
if (pattern) {
|
||||
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hostSecurity === "allowlist" && !allowlistMatch && !approvedByAsk) {
|
||||
throw new Error("exec denied: allowlist miss");
|
||||
}
|
||||
|
||||
if (allowlistMatch) {
|
||||
recordAllowlistUse(
|
||||
approvals.file,
|
||||
defaults?.agentId,
|
||||
allowlistMatch,
|
||||
params.command,
|
||||
resolution?.resolvedPath,
|
||||
);
|
||||
}
|
||||
const invokeParams: Record<string, unknown> = {
|
||||
nodeId,
|
||||
command: "system.run",
|
||||
@@ -476,6 +553,7 @@ export function createExecTool(
|
||||
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
||||
agentId: defaults?.agentId,
|
||||
sessionKey: defaults?.sessionKey,
|
||||
approved: approvedByAsk,
|
||||
},
|
||||
idempotencyKey: crypto.randomUUID(),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -122,7 +122,7 @@ export function registerDnsCli(program: Command) {
|
||||
console.log(
|
||||
JSON.stringify(
|
||||
{
|
||||
bridge: { bind: "tailnet" },
|
||||
gateway: { bind: "auto" },
|
||||
discovery: { wideArea: { enabled: true } },
|
||||
},
|
||||
null,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,17 +30,17 @@ export function registerNodeCli(program: Command) {
|
||||
node
|
||||
.command("start")
|
||||
.description("Start the headless node host (foreground)")
|
||||
.option("--host <host>", "Gateway bridge host")
|
||||
.option("--port <port>", "Gateway bridge port")
|
||||
.option("--tls", "Use TLS for the bridge connection", false)
|
||||
.option("--host <host>", "Gateway host")
|
||||
.option("--port <port>", "Gateway port")
|
||||
.option("--tls", "Use TLS for the gateway connection", false)
|
||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
||||
.option("--node-id <id>", "Override node id (clears pairing token)")
|
||||
.option("--node-id <id>", "Override node id")
|
||||
.option("--display-name <name>", "Override node display name")
|
||||
.action(async (opts) => {
|
||||
const existing = await loadNodeHostConfig();
|
||||
const host =
|
||||
(opts.host as string | undefined)?.trim() || existing?.gateway?.host || "127.0.0.1";
|
||||
const port = parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18790);
|
||||
const port = parsePortWithFallback(opts.port, existing?.gateway?.port ?? 18789);
|
||||
await runNodeHost({
|
||||
gatewayHost: host,
|
||||
gatewayPort: port,
|
||||
@@ -63,11 +63,11 @@ export function registerNodeCli(program: Command) {
|
||||
cmd
|
||||
.command("install")
|
||||
.description("Install the node service (launchd/systemd/schtasks)")
|
||||
.option("--host <host>", "Gateway bridge host")
|
||||
.option("--port <port>", "Gateway bridge port")
|
||||
.option("--tls", "Use TLS for the bridge connection", false)
|
||||
.option("--host <host>", "Gateway host")
|
||||
.option("--port <port>", "Gateway port")
|
||||
.option("--tls", "Use TLS for the gateway connection", false)
|
||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
||||
.option("--node-id <id>", "Override node id (clears pairing token)")
|
||||
.option("--node-id <id>", "Override node id")
|
||||
.option("--display-name <name>", "Override node display name")
|
||||
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
|
||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
||||
|
||||
@@ -34,7 +34,7 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
|
||||
.version(ctx.programVersion)
|
||||
.option(
|
||||
"--dev",
|
||||
"Dev profile: isolate state under ~/.clawdbot-dev, default gateway port 19001, and shift derived ports (bridge/browser/canvas)",
|
||||
"Dev profile: isolate state under ~/.clawdbot-dev, default gateway port 19001, and shift derived ports (browser/canvas)",
|
||||
)
|
||||
.option(
|
||||
"--profile <name>",
|
||||
|
||||
@@ -107,9 +107,9 @@ export function registerServiceCli(program: Command) {
|
||||
node
|
||||
.command("install")
|
||||
.description("Install the node host service (launchd/systemd/schtasks)")
|
||||
.option("--host <host>", "Gateway bridge host")
|
||||
.option("--port <port>", "Gateway bridge port")
|
||||
.option("--tls", "Use TLS for the bridge connection", false)
|
||||
.option("--host <host>", "Gateway host")
|
||||
.option("--port <port>", "Gateway port")
|
||||
.option("--tls", "Use TLS for the Gateway connection", false)
|
||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
||||
.option("--node-id <id>", "Override node id (clears pairing token)")
|
||||
.option("--display-name <name>", "Override node display name")
|
||||
|
||||
@@ -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: [],
|
||||
},
|
||||
|
||||
@@ -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("");
|
||||
|
||||
@@ -40,11 +40,6 @@ export type GatewayConfigSummary = {
|
||||
remotePasswordConfigured: boolean;
|
||||
tailscaleMode: string | null;
|
||||
};
|
||||
bridge: {
|
||||
enabled: boolean | null;
|
||||
bind: string | null;
|
||||
port: number | null;
|
||||
};
|
||||
discovery: {
|
||||
wideAreaEnabled: boolean | null;
|
||||
};
|
||||
@@ -191,7 +186,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
|
||||
|
||||
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
|
||||
const gateway = (cfg.gateway ?? {}) as Record<string, unknown>;
|
||||
const bridge = (cfg.bridge ?? {}) as Record<string, unknown>;
|
||||
const discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
|
||||
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
|
||||
|
||||
@@ -211,10 +205,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
|
||||
const remotePasswordConfigured =
|
||||
typeof remote.password === "string" ? String(remote.password).trim().length > 0 : false;
|
||||
|
||||
const bridgeEnabled = typeof bridge.enabled === "boolean" ? bridge.enabled : null;
|
||||
const bridgeBind = typeof bridge.bind === "string" ? bridge.bind : null;
|
||||
const bridgePort = parseIntOrNull(bridge.port);
|
||||
|
||||
const wideAreaEnabled = typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;
|
||||
|
||||
return {
|
||||
@@ -245,7 +235,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
|
||||
remotePasswordConfigured,
|
||||
tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null,
|
||||
},
|
||||
bridge: { enabled: bridgeEnabled, bind: bridgeBind, port: bridgePort },
|
||||
discovery: { wideAreaEnabled },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -145,7 +145,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
||||
},
|
||||
{
|
||||
id: "bind-tailnet->auto",
|
||||
describe: "Remap gateway/bridge bind 'tailnet' to 'auto'",
|
||||
describe: "Remap gateway bind 'tailnet' to 'auto'",
|
||||
apply: (raw, changes) => {
|
||||
const migrateBind = (obj: Record<string, unknown> | null | undefined, key: string) => {
|
||||
if (!obj) return;
|
||||
@@ -158,9 +158,6 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
||||
|
||||
const gateway = getRecord(raw.gateway);
|
||||
migrateBind(gateway, "gateway");
|
||||
|
||||
const bridge = getRecord(raw.bridge);
|
||||
migrateBind(bridge, "bridge");
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -41,7 +41,7 @@ export type ChatAbortOps = {
|
||||
) => { sessionKey: string; clientRunId: string } | undefined;
|
||||
agentRunSeq: Map<string, number>;
|
||||
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
|
||||
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
};
|
||||
|
||||
function broadcastChatAborted(
|
||||
@@ -61,7 +61,7 @@ function broadcastChatAborted(
|
||||
stopReason,
|
||||
};
|
||||
ops.broadcast("chat", payload);
|
||||
ops.bridgeSendToSession(sessionKey, "chat", payload);
|
||||
ops.nodeSendToSession(sessionKey, "chat", payload);
|
||||
}
|
||||
|
||||
export function abortChatRunById(
|
||||
|
||||
@@ -40,9 +40,13 @@ export type GatewayClientOptions = {
|
||||
mode?: GatewayClientMode;
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
deviceIdentity?: DeviceIdentity;
|
||||
minProtocol?: number;
|
||||
maxProtocol?: number;
|
||||
tlsFingerprint?: string;
|
||||
onEvent?: (evt: EventFrame) => void;
|
||||
onHelloOk?: (hello: HelloOk) => void;
|
||||
onConnectError?: (err: Error) => void;
|
||||
@@ -81,7 +85,21 @@ export class GatewayClient {
|
||||
if (this.closed) return;
|
||||
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
||||
// Allow node screen snapshots and other large responses.
|
||||
this.ws = new WebSocket(url, { maxPayload: 25 * 1024 * 1024 });
|
||||
const wsOptions: ConstructorParameters<typeof WebSocket>[1] = {
|
||||
maxPayload: 25 * 1024 * 1024,
|
||||
};
|
||||
if (url.startsWith("wss://") && this.opts.tlsFingerprint) {
|
||||
wsOptions.rejectUnauthorized = false;
|
||||
wsOptions.checkServerIdentity = (_host, cert) => {
|
||||
const fingerprint = normalizeFingerprint(
|
||||
typeof cert?.fingerprint256 === "string" ? cert.fingerprint256 : "",
|
||||
);
|
||||
const expected = normalizeFingerprint(this.opts.tlsFingerprint ?? "");
|
||||
if (fingerprint && fingerprint === expected) return undefined;
|
||||
return new Error("gateway tls fingerprint mismatch");
|
||||
};
|
||||
}
|
||||
this.ws = new WebSocket(url, wsOptions);
|
||||
|
||||
this.ws.on("open", () => this.sendConnect());
|
||||
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
|
||||
@@ -149,7 +167,12 @@ export class GatewayClient {
|
||||
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
||||
instanceId: this.opts.instanceId,
|
||||
},
|
||||
caps: [],
|
||||
caps: Array.isArray(this.opts.caps) ? this.opts.caps : [],
|
||||
commands: Array.isArray(this.opts.commands) ? this.opts.commands : undefined,
|
||||
permissions:
|
||||
this.opts.permissions && typeof this.opts.permissions === "object"
|
||||
? this.opts.permissions
|
||||
: undefined,
|
||||
auth,
|
||||
role,
|
||||
scopes,
|
||||
@@ -270,3 +293,7 @@ export class GatewayClient {
|
||||
return p;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeFingerprint(input: string): string {
|
||||
return input.replace(/[^a-fA-F0-9]/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
@@ -191,7 +191,7 @@ async function isPortFree(port: number): Promise<boolean> {
|
||||
}
|
||||
|
||||
async function getFreeGatewayPort(): Promise<number> {
|
||||
// Gateway uses derived ports (bridge/browser/canvas). Avoid flaky collisions by
|
||||
// Gateway uses derived ports (browser/canvas). Avoid flaky collisions by
|
||||
// ensuring the common derived offsets are free too.
|
||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||
const port = await getFreePort();
|
||||
|
||||
@@ -23,7 +23,7 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
* @returns The bind address to use (never null)
|
||||
*/
|
||||
export async function resolveGatewayBindHost(
|
||||
bind: import("../config/config.js").BridgeBindMode | undefined,
|
||||
bind: import("../config/config.js").GatewayBindMode | undefined,
|
||||
customHost?: string,
|
||||
): Promise<string> {
|
||||
const mode = bind ?? "loopback";
|
||||
|
||||
193
src/gateway/node-registry.ts
Normal file
193
src/gateway/node-registry.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { GatewayWsClient } from "./server/ws-types.js";
|
||||
|
||||
export type NodeSession = {
|
||||
nodeId: string;
|
||||
connId: string;
|
||||
client: GatewayWsClient;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
coreVersion?: string;
|
||||
uiVersion?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
remoteIp?: string;
|
||||
caps: string[];
|
||||
commands: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
connectedAtMs: number;
|
||||
};
|
||||
|
||||
type PendingInvoke = {
|
||||
nodeId: string;
|
||||
command: string;
|
||||
resolve: (value: NodeInvokeResult) => void;
|
||||
reject: (err: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
};
|
||||
|
||||
export type NodeInvokeResult = {
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
payloadJSON?: string | null;
|
||||
error?: { code?: string; message?: string } | null;
|
||||
};
|
||||
|
||||
export class NodeRegistry {
|
||||
private nodesById = new Map<string, NodeSession>();
|
||||
private nodesByConn = new Map<string, string>();
|
||||
private pendingInvokes = new Map<string, PendingInvoke>();
|
||||
|
||||
register(client: GatewayWsClient, opts: { remoteIp?: string | undefined }) {
|
||||
const connect = client.connect;
|
||||
const nodeId = connect.device?.id ?? connect.client.id;
|
||||
const caps = Array.isArray(connect.caps) ? connect.caps : [];
|
||||
const commands = Array.isArray((connect as { commands?: string[] }).commands)
|
||||
? (connect as { commands?: string[] }).commands ?? []
|
||||
: [];
|
||||
const permissions =
|
||||
typeof (connect as { permissions?: Record<string, boolean> }).permissions === "object"
|
||||
? ((connect as { permissions?: Record<string, boolean> }).permissions ?? undefined)
|
||||
: undefined;
|
||||
const session: NodeSession = {
|
||||
nodeId,
|
||||
connId: client.connId,
|
||||
client,
|
||||
displayName: connect.client.displayName,
|
||||
platform: connect.client.platform,
|
||||
version: connect.client.version,
|
||||
coreVersion: (connect as { coreVersion?: string }).coreVersion,
|
||||
uiVersion: (connect as { uiVersion?: string }).uiVersion,
|
||||
deviceFamily: connect.client.deviceFamily,
|
||||
modelIdentifier: connect.client.modelIdentifier,
|
||||
remoteIp: opts.remoteIp,
|
||||
caps,
|
||||
commands,
|
||||
permissions,
|
||||
connectedAtMs: Date.now(),
|
||||
};
|
||||
this.nodesById.set(nodeId, session);
|
||||
this.nodesByConn.set(client.connId, nodeId);
|
||||
return session;
|
||||
}
|
||||
|
||||
unregister(connId: string): string | null {
|
||||
const nodeId = this.nodesByConn.get(connId);
|
||||
if (!nodeId) return null;
|
||||
this.nodesByConn.delete(connId);
|
||||
this.nodesById.delete(nodeId);
|
||||
for (const [id, pending] of this.pendingInvokes.entries()) {
|
||||
if (pending.nodeId !== nodeId) continue;
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(new Error(`node disconnected (${pending.command})`));
|
||||
this.pendingInvokes.delete(id);
|
||||
}
|
||||
return nodeId;
|
||||
}
|
||||
|
||||
listConnected(): NodeSession[] {
|
||||
return [...this.nodesById.values()];
|
||||
}
|
||||
|
||||
get(nodeId: string): NodeSession | undefined {
|
||||
return this.nodesById.get(nodeId);
|
||||
}
|
||||
|
||||
async invoke(params: {
|
||||
nodeId: string;
|
||||
command: string;
|
||||
params?: unknown;
|
||||
timeoutMs?: number;
|
||||
idempotencyKey?: string;
|
||||
}): Promise<NodeInvokeResult> {
|
||||
const node = this.nodesById.get(params.nodeId);
|
||||
if (!node) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: "NOT_CONNECTED", message: "node not connected" },
|
||||
};
|
||||
}
|
||||
const requestId = randomUUID();
|
||||
const payload = {
|
||||
id: requestId,
|
||||
nodeId: params.nodeId,
|
||||
command: params.command,
|
||||
paramsJSON:
|
||||
"params" in params && params.params !== undefined ? JSON.stringify(params.params) : null,
|
||||
timeoutMs: params.timeoutMs,
|
||||
idempotencyKey: params.idempotencyKey,
|
||||
};
|
||||
const ok = this.sendEvent(node, "node.invoke.request", payload);
|
||||
if (!ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: "UNAVAILABLE", message: "failed to send invoke to node" },
|
||||
};
|
||||
}
|
||||
const timeoutMs = typeof params.timeoutMs === "number" ? params.timeoutMs : 30_000;
|
||||
return await new Promise<NodeInvokeResult>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingInvokes.delete(requestId);
|
||||
resolve({
|
||||
ok: false,
|
||||
error: { code: "TIMEOUT", message: "node invoke timed out" },
|
||||
});
|
||||
}, timeoutMs);
|
||||
this.pendingInvokes.set(requestId, {
|
||||
nodeId: params.nodeId,
|
||||
command: params.command,
|
||||
resolve,
|
||||
reject,
|
||||
timer,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
handleInvokeResult(params: {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
payloadJSON?: string | null;
|
||||
error?: { code?: string; message?: string } | null;
|
||||
}): boolean {
|
||||
const pending = this.pendingInvokes.get(params.id);
|
||||
if (!pending) return false;
|
||||
clearTimeout(pending.timer);
|
||||
this.pendingInvokes.delete(params.id);
|
||||
pending.resolve({
|
||||
ok: params.ok,
|
||||
payload: params.payload,
|
||||
payloadJSON: params.payloadJSON ?? null,
|
||||
error: params.error ?? null,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
sendEvent(nodeId: string, event: string, payload?: unknown): boolean {
|
||||
const node = this.nodesById.get(nodeId);
|
||||
if (!node) return false;
|
||||
return this.sendEventToSession(node, event, payload);
|
||||
}
|
||||
|
||||
private sendEvent(node: NodeSession, event: string, payload: unknown): boolean {
|
||||
try {
|
||||
node.client.socket.send(
|
||||
JSON.stringify({
|
||||
type: "event",
|
||||
event,
|
||||
payload,
|
||||
}),
|
||||
);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private sendEventToSession(node: NodeSession, event: string, payload: unknown): boolean {
|
||||
return this.sendEvent(node, event, payload);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -93,8 +93,12 @@ import {
|
||||
ModelsListParamsSchema,
|
||||
type NodeDescribeParams,
|
||||
NodeDescribeParamsSchema,
|
||||
type NodeEventParams,
|
||||
NodeEventParamsSchema,
|
||||
type NodeInvokeParams,
|
||||
NodeInvokeParamsSchema,
|
||||
type NodeInvokeResultParams,
|
||||
NodeInvokeResultParamsSchema,
|
||||
type NodeListParams,
|
||||
NodeListParamsSchema,
|
||||
type NodePairApproveParams,
|
||||
@@ -207,6 +211,10 @@ export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(NodeRename
|
||||
export const validateNodeListParams = ajv.compile<NodeListParams>(NodeListParamsSchema);
|
||||
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(NodeDescribeParamsSchema);
|
||||
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(NodeInvokeParamsSchema);
|
||||
export const validateNodeInvokeResultParams = ajv.compile<NodeInvokeResultParams>(
|
||||
NodeInvokeResultParamsSchema,
|
||||
);
|
||||
export const validateNodeEventParams = ajv.compile<NodeEventParams>(NodeEventParamsSchema);
|
||||
export const validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
|
||||
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
|
||||
SessionsResolveParamsSchema,
|
||||
@@ -422,6 +430,8 @@ export type {
|
||||
NodePairVerifyParams,
|
||||
NodeListParams,
|
||||
NodeInvokeParams,
|
||||
NodeInvokeResultParams,
|
||||
NodeEventParams,
|
||||
SessionsListParams,
|
||||
SessionsResolveParams,
|
||||
SessionsPatchParams,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 },
|
||||
);
|
||||
|
||||
@@ -85,7 +85,10 @@ import {
|
||||
} from "./logs-chat.js";
|
||||
import {
|
||||
NodeDescribeParamsSchema,
|
||||
NodeEventParamsSchema,
|
||||
NodeInvokeParamsSchema,
|
||||
NodeInvokeResultParamsSchema,
|
||||
NodeInvokeRequestEventSchema,
|
||||
NodeListParamsSchema,
|
||||
NodePairApproveParamsSchema,
|
||||
NodePairListParamsSchema,
|
||||
@@ -140,6 +143,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
||||
NodeListParams: NodeListParamsSchema,
|
||||
NodeDescribeParams: NodeDescribeParamsSchema,
|
||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
|
||||
NodeEventParams: NodeEventParamsSchema,
|
||||
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
|
||||
SessionsListParams: SessionsListParamsSchema,
|
||||
SessionsResolveParams: SessionsResolveParamsSchema,
|
||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||
|
||||
@@ -79,7 +79,9 @@ import type {
|
||||
} from "./logs-chat.js";
|
||||
import type {
|
||||
NodeDescribeParamsSchema,
|
||||
NodeEventParamsSchema,
|
||||
NodeInvokeParamsSchema,
|
||||
NodeInvokeResultParamsSchema,
|
||||
NodeListParamsSchema,
|
||||
NodePairApproveParamsSchema,
|
||||
NodePairListParamsSchema,
|
||||
@@ -131,6 +133,8 @@ export type NodeRenameParams = Static<typeof NodeRenameParamsSchema>;
|
||||
export type NodeListParams = Static<typeof NodeListParamsSchema>;
|
||||
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
||||
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
||||
export type NodeInvokeResultParams = Static<typeof NodeInvokeResultParamsSchema>;
|
||||
export type NodeEventParams = Static<typeof NodeEventParamsSchema>;
|
||||
export type SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
||||
|
||||
@@ -1,457 +0,0 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { resolveThinkingDefault } from "../agents/model-selection.js";
|
||||
import { resolveAgentTimeoutMs } from "../agents/timeout.js";
|
||||
import { agentCommand } from "../commands/agent.js";
|
||||
import { mergeSessionEntry, updateSessionStore } from "../config/sessions.js";
|
||||
import { registerAgentRunContext } from "../infra/agent-events.js";
|
||||
import { isAcpSessionKey } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import {
|
||||
abortChatRunById,
|
||||
abortChatRunsForSessionKey,
|
||||
isChatStopCommandText,
|
||||
resolveChatRunExpiresAtMs,
|
||||
} from "./chat-abort.js";
|
||||
import { type ChatImageContent, parseMessageWithAttachments } from "./chat-attachments.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
formatValidationErrors,
|
||||
validateChatAbortParams,
|
||||
validateChatInjectParams,
|
||||
validateChatHistoryParams,
|
||||
validateChatSendParams,
|
||||
} from "./protocol/index.js";
|
||||
import type { BridgeMethodHandler } from "./server-bridge-types.js";
|
||||
import { MAX_CHAT_HISTORY_MESSAGES_BYTES } from "./server-constants.js";
|
||||
import {
|
||||
capArrayByJsonBytes,
|
||||
loadSessionEntry,
|
||||
readSessionMessages,
|
||||
resolveSessionModelRef,
|
||||
} from "./session-utils.js";
|
||||
|
||||
export const handleChatBridgeMethods: BridgeMethodHandler = async (ctx, nodeId, method, params) => {
|
||||
switch (method) {
|
||||
case "chat.inject": {
|
||||
if (!validateChatInjectParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid chat.inject params: ${formatValidationErrors(validateChatInjectParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const p = params as {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
const { storePath, entry } = loadSessionEntry(p.sessionKey);
|
||||
const sessionId = entry?.sessionId;
|
||||
if (!sessionId || !storePath) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: ErrorCodes.INVALID_REQUEST, message: "session not found" },
|
||||
};
|
||||
}
|
||||
|
||||
const transcriptPath = entry?.sessionFile
|
||||
? entry.sessionFile
|
||||
: path.join(path.dirname(storePath), `${sessionId}.jsonl`);
|
||||
|
||||
if (!fs.existsSync(transcriptPath)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: ErrorCodes.INVALID_REQUEST, message: "transcript file not found" },
|
||||
};
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const messageId = randomUUID().slice(0, 8);
|
||||
const labelPrefix = p.label ? `[${p.label}]\n\n` : "";
|
||||
const messageBody: Record<string, unknown> = {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: `${labelPrefix}${p.message}` }],
|
||||
timestamp: now,
|
||||
stopReason: "injected",
|
||||
usage: { input: 0, output: 0, totalTokens: 0 },
|
||||
};
|
||||
const transcriptEntry = {
|
||||
type: "message",
|
||||
id: messageId,
|
||||
timestamp: new Date(now).toISOString(),
|
||||
message: messageBody,
|
||||
};
|
||||
|
||||
try {
|
||||
fs.appendFileSync(transcriptPath, `${JSON.stringify(transcriptEntry)}\n`, "utf-8");
|
||||
} catch (err) {
|
||||
const errMessage = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.UNAVAILABLE,
|
||||
message: `failed to write transcript: ${errMessage}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const chatPayload = {
|
||||
runId: `inject-${messageId}`,
|
||||
sessionKey: p.sessionKey,
|
||||
seq: 0,
|
||||
state: "final" as const,
|
||||
message: transcriptEntry.message,
|
||||
};
|
||||
ctx.broadcast("chat", chatPayload);
|
||||
ctx.bridgeSendToSession(p.sessionKey, "chat", chatPayload);
|
||||
|
||||
return { ok: true, payloadJSON: JSON.stringify({ ok: true, messageId }) };
|
||||
}
|
||||
case "chat.history": {
|
||||
if (!validateChatHistoryParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid chat.history params: ${formatValidationErrors(validateChatHistoryParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const { sessionKey, limit } = params as {
|
||||
sessionKey: string;
|
||||
limit?: number;
|
||||
};
|
||||
const { cfg, storePath, entry } = loadSessionEntry(sessionKey);
|
||||
const sessionId = entry?.sessionId;
|
||||
const rawMessages =
|
||||
sessionId && storePath ? readSessionMessages(sessionId, storePath, entry?.sessionFile) : [];
|
||||
const max = typeof limit === "number" ? limit : 200;
|
||||
const sliced = rawMessages.length > max ? rawMessages.slice(-max) : rawMessages;
|
||||
const capped = capArrayByJsonBytes(sliced, MAX_CHAT_HISTORY_MESSAGES_BYTES).items;
|
||||
let thinkingLevel = entry?.thinkingLevel;
|
||||
if (!thinkingLevel) {
|
||||
const configured = cfg.agents?.defaults?.thinkingDefault;
|
||||
if (configured) {
|
||||
thinkingLevel = configured;
|
||||
} else {
|
||||
const { provider, model } = resolveSessionModelRef(cfg, entry);
|
||||
const catalog = await ctx.loadGatewayModelCatalog();
|
||||
thinkingLevel = resolveThinkingDefault({
|
||||
cfg,
|
||||
provider,
|
||||
model,
|
||||
catalog,
|
||||
});
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
sessionKey,
|
||||
sessionId,
|
||||
messages: capped,
|
||||
thinkingLevel,
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "chat.abort": {
|
||||
if (!validateChatAbortParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid chat.abort params: ${formatValidationErrors(validateChatAbortParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const { sessionKey, runId } = params as {
|
||||
sessionKey: string;
|
||||
runId?: string;
|
||||
};
|
||||
const ops = {
|
||||
chatAbortControllers: ctx.chatAbortControllers,
|
||||
chatRunBuffers: ctx.chatRunBuffers,
|
||||
chatDeltaSentAt: ctx.chatDeltaSentAt,
|
||||
chatAbortedRuns: ctx.chatAbortedRuns,
|
||||
removeChatRun: ctx.removeChatRun,
|
||||
agentRunSeq: ctx.agentRunSeq,
|
||||
broadcast: ctx.broadcast,
|
||||
bridgeSendToSession: ctx.bridgeSendToSession,
|
||||
};
|
||||
if (!runId) {
|
||||
const res = abortChatRunsForSessionKey(ops, {
|
||||
sessionKey,
|
||||
stopReason: "rpc",
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
ok: true,
|
||||
aborted: res.aborted,
|
||||
runIds: res.runIds,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const active = ctx.chatAbortControllers.get(runId);
|
||||
if (!active) {
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
ok: true,
|
||||
aborted: false,
|
||||
runIds: [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
if (active.sessionKey !== sessionKey) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "runId does not match sessionKey",
|
||||
},
|
||||
};
|
||||
}
|
||||
const res = abortChatRunById(ops, {
|
||||
runId,
|
||||
sessionKey,
|
||||
stopReason: "rpc",
|
||||
});
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
ok: true,
|
||||
aborted: res.aborted,
|
||||
runIds: res.aborted ? [runId] : [],
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "chat.send": {
|
||||
if (!validateChatSendParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid chat.send params: ${formatValidationErrors(validateChatSendParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const p = params as {
|
||||
sessionKey: string;
|
||||
message: string;
|
||||
thinking?: string;
|
||||
deliver?: boolean;
|
||||
attachments?: Array<{
|
||||
type?: string;
|
||||
mimeType?: string;
|
||||
fileName?: string;
|
||||
content?: unknown;
|
||||
}>;
|
||||
timeoutMs?: number;
|
||||
idempotencyKey: string;
|
||||
};
|
||||
const stopCommand = isChatStopCommandText(p.message);
|
||||
const normalizedAttachments =
|
||||
p.attachments
|
||||
?.map((a) => ({
|
||||
type: typeof a?.type === "string" ? a.type : undefined,
|
||||
mimeType: typeof a?.mimeType === "string" ? a.mimeType : undefined,
|
||||
fileName: typeof a?.fileName === "string" ? a.fileName : undefined,
|
||||
content:
|
||||
typeof a?.content === "string"
|
||||
? a.content
|
||||
: ArrayBuffer.isView(a?.content)
|
||||
? Buffer.from(
|
||||
a.content.buffer,
|
||||
a.content.byteOffset,
|
||||
a.content.byteLength,
|
||||
).toString("base64")
|
||||
: undefined,
|
||||
}))
|
||||
.filter((a) => a.content) ?? [];
|
||||
|
||||
let parsedMessage = p.message;
|
||||
let parsedImages: ChatImageContent[] = [];
|
||||
if (normalizedAttachments.length > 0) {
|
||||
try {
|
||||
const parsed = await parseMessageWithAttachments(p.message, normalizedAttachments, {
|
||||
maxBytes: 5_000_000,
|
||||
log: ctx.logBridge,
|
||||
});
|
||||
parsedMessage = parsed.message;
|
||||
parsedImages = parsed.images;
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey);
|
||||
const timeoutMs = resolveAgentTimeoutMs({
|
||||
cfg,
|
||||
overrideMs: p.timeoutMs,
|
||||
});
|
||||
const now = Date.now();
|
||||
const sessionId = entry?.sessionId ?? randomUUID();
|
||||
const sessionEntry = mergeSessionEntry(entry, {
|
||||
sessionId,
|
||||
updatedAt: now,
|
||||
});
|
||||
const clientRunId = p.idempotencyKey;
|
||||
registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey });
|
||||
|
||||
if (stopCommand) {
|
||||
const res = abortChatRunsForSessionKey(
|
||||
{
|
||||
chatAbortControllers: ctx.chatAbortControllers,
|
||||
chatRunBuffers: ctx.chatRunBuffers,
|
||||
chatDeltaSentAt: ctx.chatDeltaSentAt,
|
||||
chatAbortedRuns: ctx.chatAbortedRuns,
|
||||
removeChatRun: ctx.removeChatRun,
|
||||
agentRunSeq: ctx.agentRunSeq,
|
||||
broadcast: ctx.broadcast,
|
||||
bridgeSendToSession: ctx.bridgeSendToSession,
|
||||
},
|
||||
{ sessionKey: p.sessionKey, stopReason: "stop" },
|
||||
);
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
ok: true,
|
||||
aborted: res.aborted,
|
||||
runIds: res.runIds,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const cached = ctx.dedupe.get(`chat:${clientRunId}`);
|
||||
if (cached) {
|
||||
if (cached.ok) {
|
||||
return { ok: true, payloadJSON: JSON.stringify(cached.payload) };
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: cached.error ?? {
|
||||
code: ErrorCodes.UNAVAILABLE,
|
||||
message: "request failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const activeExisting = ctx.chatAbortControllers.get(clientRunId);
|
||||
if (activeExisting) {
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
runId: clientRunId,
|
||||
status: "in_flight",
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const abortController = new AbortController();
|
||||
ctx.chatAbortControllers.set(clientRunId, {
|
||||
controller: abortController,
|
||||
sessionId,
|
||||
sessionKey: p.sessionKey,
|
||||
startedAtMs: now,
|
||||
expiresAtMs: resolveChatRunExpiresAtMs({ now, timeoutMs }),
|
||||
});
|
||||
ctx.addChatRun(clientRunId, {
|
||||
sessionKey: p.sessionKey,
|
||||
clientRunId,
|
||||
});
|
||||
|
||||
if (storePath) {
|
||||
await updateSessionStore(storePath, (store) => {
|
||||
store[canonicalKey] = sessionEntry;
|
||||
});
|
||||
}
|
||||
|
||||
const ackPayload = {
|
||||
runId: clientRunId,
|
||||
status: "started" as const,
|
||||
};
|
||||
const lane = isAcpSessionKey(p.sessionKey) ? p.sessionKey : undefined;
|
||||
void agentCommand(
|
||||
{
|
||||
message: parsedMessage,
|
||||
images: parsedImages.length > 0 ? parsedImages : undefined,
|
||||
sessionId,
|
||||
sessionKey: p.sessionKey,
|
||||
runId: clientRunId,
|
||||
thinking: p.thinking,
|
||||
deliver: p.deliver,
|
||||
timeout: Math.ceil(timeoutMs / 1000).toString(),
|
||||
messageChannel: `node(${nodeId})`,
|
||||
abortSignal: abortController.signal,
|
||||
lane,
|
||||
},
|
||||
defaultRuntime,
|
||||
ctx.deps,
|
||||
)
|
||||
.then(() => {
|
||||
ctx.dedupe.set(`chat:${clientRunId}`, {
|
||||
ts: Date.now(),
|
||||
ok: true,
|
||||
payload: { runId: clientRunId, status: "ok" as const },
|
||||
});
|
||||
})
|
||||
.catch((err) => {
|
||||
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||
ctx.dedupe.set(`chat:${clientRunId}`, {
|
||||
ts: Date.now(),
|
||||
ok: false,
|
||||
payload: {
|
||||
runId: clientRunId,
|
||||
status: "error" as const,
|
||||
summary: String(err),
|
||||
},
|
||||
error,
|
||||
});
|
||||
})
|
||||
.finally(() => {
|
||||
ctx.chatAbortControllers.delete(clientRunId);
|
||||
});
|
||||
|
||||
return { ok: true, payloadJSON: JSON.stringify(ackPayload) };
|
||||
} catch (err) {
|
||||
const error = errorShape(ErrorCodes.UNAVAILABLE, String(err));
|
||||
const payload = {
|
||||
runId: clientRunId,
|
||||
status: "error" as const,
|
||||
summary: String(err),
|
||||
};
|
||||
ctx.dedupe.set(`chat:${clientRunId}`, {
|
||||
ts: Date.now(),
|
||||
ok: false,
|
||||
payload,
|
||||
error,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
error: error ?? {
|
||||
code: ErrorCodes.UNAVAILABLE,
|
||||
message: String(err),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -1,270 +0,0 @@
|
||||
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js";
|
||||
import {
|
||||
CONFIG_PATH_CLAWDBOT,
|
||||
loadConfig,
|
||||
parseConfigJson5,
|
||||
readConfigFileSnapshot,
|
||||
resolveConfigSnapshotHash,
|
||||
validateConfigObject,
|
||||
writeConfigFile,
|
||||
} from "../config/config.js";
|
||||
import { applyLegacyMigrations } from "../config/legacy.js";
|
||||
import { applyMergePatch } from "../config/merge-patch.js";
|
||||
import { buildConfigSchema } from "../config/schema.js";
|
||||
import { listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { loadClawdbotPlugins } from "../plugins/loader.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
formatValidationErrors,
|
||||
validateConfigGetParams,
|
||||
validateConfigPatchParams,
|
||||
validateConfigSchemaParams,
|
||||
validateConfigSetParams,
|
||||
} from "./protocol/index.js";
|
||||
import type { BridgeMethodHandler } from "./server-bridge-types.js";
|
||||
|
||||
function resolveBaseHash(params: unknown): string | null {
|
||||
const raw = (params as { baseHash?: unknown })?.baseHash;
|
||||
if (typeof raw !== "string") return null;
|
||||
const trimmed = raw.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
|
||||
function requireConfigBaseHash(
|
||||
params: unknown,
|
||||
snapshot: Awaited<ReturnType<typeof readConfigFileSnapshot>>,
|
||||
): { ok: true } | { ok: false; error: { code: string; message: string } } {
|
||||
if (!snapshot.exists) return { ok: true };
|
||||
const snapshotHash = resolveConfigSnapshotHash(snapshot);
|
||||
if (!snapshotHash) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "config base hash unavailable; re-run config.get and retry",
|
||||
},
|
||||
};
|
||||
}
|
||||
const baseHash = resolveBaseHash(params);
|
||||
if (!baseHash) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "config base hash required; re-run config.get and retry",
|
||||
},
|
||||
};
|
||||
}
|
||||
if (baseHash !== snapshotHash) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "config changed since last load; re-run config.get and retry",
|
||||
},
|
||||
};
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export const handleConfigBridgeMethods: BridgeMethodHandler = async (
|
||||
_ctx,
|
||||
_nodeId,
|
||||
method,
|
||||
params,
|
||||
) => {
|
||||
switch (method) {
|
||||
case "config.get": {
|
||||
if (!validateConfigGetParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid config.get params: ${formatValidationErrors(validateConfigGetParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
return { ok: true, payloadJSON: JSON.stringify(snapshot) };
|
||||
}
|
||||
case "config.schema": {
|
||||
if (!validateConfigSchemaParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid config.schema params: ${formatValidationErrors(validateConfigSchemaParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const cfg = loadConfig();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
|
||||
const pluginRegistry = loadClawdbotPlugins({
|
||||
config: cfg,
|
||||
workspaceDir,
|
||||
logger: {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
},
|
||||
});
|
||||
const schema = buildConfigSchema({
|
||||
plugins: pluginRegistry.plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
name: plugin.name,
|
||||
description: plugin.description,
|
||||
configUiHints: plugin.configUiHints,
|
||||
configSchema: plugin.configJsonSchema,
|
||||
})),
|
||||
channels: listChannelPlugins().map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.meta.label,
|
||||
description: entry.meta.blurb,
|
||||
configSchema: entry.configSchema?.schema,
|
||||
configUiHints: entry.configSchema?.uiHints,
|
||||
})),
|
||||
});
|
||||
return { ok: true, payloadJSON: JSON.stringify(schema) };
|
||||
}
|
||||
case "config.set": {
|
||||
if (!validateConfigSetParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid config.set params: ${formatValidationErrors(validateConfigSetParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const guard = requireConfigBaseHash(params, snapshot);
|
||||
if (!guard.ok) {
|
||||
return { ok: false, error: guard.error };
|
||||
}
|
||||
const rawValue = (params as { raw?: unknown }).raw;
|
||||
if (typeof rawValue !== "string") {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid config.set params: raw (string) required",
|
||||
},
|
||||
};
|
||||
}
|
||||
const parsedRes = parseConfigJson5(rawValue);
|
||||
if (!parsedRes.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: parsedRes.error,
|
||||
},
|
||||
};
|
||||
}
|
||||
const validated = validateConfigObject(parsedRes.parsed);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid config",
|
||||
details: { issues: validated.issues },
|
||||
},
|
||||
};
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
ok: true,
|
||||
path: CONFIG_PATH_CLAWDBOT,
|
||||
config: validated.config,
|
||||
}),
|
||||
};
|
||||
}
|
||||
case "config.patch": {
|
||||
if (!validateConfigPatchParams(params)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: `invalid config.patch params: ${formatValidationErrors(validateConfigPatchParams.errors)}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
const snapshot = await readConfigFileSnapshot();
|
||||
const guard = requireConfigBaseHash(params, snapshot);
|
||||
if (!guard.ok) {
|
||||
return { ok: false, error: guard.error };
|
||||
}
|
||||
if (!snapshot.valid) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid config; fix before patching",
|
||||
},
|
||||
};
|
||||
}
|
||||
const rawValue = (params as { raw?: unknown }).raw;
|
||||
if (typeof rawValue !== "string") {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid config.patch params: raw (string) required",
|
||||
},
|
||||
};
|
||||
}
|
||||
const parsedRes = parseConfigJson5(rawValue);
|
||||
if (!parsedRes.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: parsedRes.error,
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
!parsedRes.parsed ||
|
||||
typeof parsedRes.parsed !== "object" ||
|
||||
Array.isArray(parsedRes.parsed)
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "config.patch raw must be an object",
|
||||
},
|
||||
};
|
||||
}
|
||||
const merged = applyMergePatch(snapshot.config, parsedRes.parsed);
|
||||
const migrated = applyLegacyMigrations(merged);
|
||||
const resolved = migrated.next ?? merged;
|
||||
const validated = validateConfigObject(resolved);
|
||||
if (!validated.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: ErrorCodes.INVALID_REQUEST,
|
||||
message: "invalid config",
|
||||
details: { issues: validated.issues },
|
||||
},
|
||||
};
|
||||
}
|
||||
await writeConfigFile(validated.config);
|
||||
return {
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({
|
||||
ok: true,
|
||||
path: CONFIG_PATH_CLAWDBOT,
|
||||
config: validated.config,
|
||||
}),
|
||||
};
|
||||
}
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -1,246 +0,0 @@
|
||||
import type { ModelCatalogEntry } from "../agents/model-catalog.js";
|
||||
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
|
||||
import { startCanvasHost } from "../canvas-host/server.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import type { HealthSummary } from "../commands/health.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { deriveDefaultBridgePort, deriveDefaultCanvasHostPort } from "../config/port-defaults.js";
|
||||
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
||||
import { loadBridgeTlsRuntime } from "../infra/bridge/server/tls.js";
|
||||
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
||||
import { createBridgeHandlers } from "./server-bridge.js";
|
||||
import {
|
||||
type BridgeListConnectedFn,
|
||||
type BridgeSendEventFn,
|
||||
createBridgeSubscriptionManager,
|
||||
} from "./server-bridge-subscriptions.js";
|
||||
import type { ChatRunEntry } from "./server-chat.js";
|
||||
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
|
||||
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
|
||||
import { startGatewayNodeBridge } from "./server-node-bridge.js";
|
||||
import type { DedupeEntry } from "./server-shared.js";
|
||||
|
||||
export type GatewayBridgeRuntime = {
|
||||
bridge: import("../infra/bridge/server.js").NodeBridgeServer | null;
|
||||
bridgeHost: string | null;
|
||||
bridgePort: number;
|
||||
canvasHostServer: CanvasHostServer | null;
|
||||
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||
bonjourStop: (() => Promise<void>) | null;
|
||||
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
|
||||
broadcastVoiceWakeChanged: (triggers: string[]) => void;
|
||||
};
|
||||
|
||||
export async function startGatewayBridgeRuntime(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
port: number;
|
||||
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
|
||||
canvasHostEnabled: boolean;
|
||||
canvasHost: CanvasHostHandler | null;
|
||||
canvasRuntime: RuntimeEnv;
|
||||
allowCanvasHostInTests?: boolean;
|
||||
machineDisplayName: string;
|
||||
deps: CliDeps;
|
||||
broadcast: (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
opts?: {
|
||||
dropIfSlow?: boolean;
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => void;
|
||||
dedupe: Map<string, DedupeEntry>;
|
||||
agentRunSeq: Map<string, number>;
|
||||
chatRunState: { abortedRuns: Map<string, number> };
|
||||
chatRunBuffers: Map<string, string>;
|
||||
chatDeltaSentAt: Map<string, number>;
|
||||
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
|
||||
removeChatRun: (
|
||||
sessionId: string,
|
||||
clientRunId: string,
|
||||
sessionKey?: string,
|
||||
) => ChatRunEntry | undefined;
|
||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||
getHealthCache: () => HealthSummary | null;
|
||||
refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
||||
loadGatewayModelCatalog?: () => Promise<ModelCatalogEntry[]>;
|
||||
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
logCanvas: { warn: (msg: string) => void };
|
||||
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
}): Promise<GatewayBridgeRuntime> {
|
||||
const wideAreaDiscoveryEnabled = params.cfg.discovery?.wideArea?.enabled === true;
|
||||
|
||||
let bridgeEnabled = (() => {
|
||||
if (params.cfg.bridge?.enabled !== undefined) return params.cfg.bridge.enabled === true;
|
||||
return process.env.CLAWDBOT_BRIDGE_ENABLED !== "0";
|
||||
})();
|
||||
|
||||
const bridgePort = (() => {
|
||||
if (typeof params.cfg.bridge?.port === "number" && params.cfg.bridge.port > 0) {
|
||||
return params.cfg.bridge.port;
|
||||
}
|
||||
if (process.env.CLAWDBOT_BRIDGE_PORT !== undefined) {
|
||||
const parsed = Number.parseInt(process.env.CLAWDBOT_BRIDGE_PORT, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : deriveDefaultBridgePort(params.port);
|
||||
}
|
||||
return deriveDefaultBridgePort(params.port);
|
||||
})();
|
||||
|
||||
const bridgeHost = (() => {
|
||||
// Back-compat: allow an env var override when no bind policy is configured.
|
||||
if (params.cfg.bridge?.bind === undefined) {
|
||||
const env = process.env.CLAWDBOT_BRIDGE_HOST?.trim();
|
||||
if (env) return env;
|
||||
}
|
||||
|
||||
const bind = params.cfg.bridge?.bind ?? (wideAreaDiscoveryEnabled ? "auto" : "lan");
|
||||
if (bind === "loopback") return "127.0.0.1";
|
||||
if (bind === "lan") return "0.0.0.0";
|
||||
|
||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
||||
if (bind === "auto") {
|
||||
return tailnetIPv4 ?? tailnetIPv6 ?? "0.0.0.0";
|
||||
}
|
||||
if (bind === "custom") {
|
||||
// For bridge, customBindHost is not currently supported on GatewayConfig.
|
||||
// This will fall back to "0.0.0.0" until we add customBindHost to BridgeConfig.
|
||||
return "0.0.0.0";
|
||||
}
|
||||
return "0.0.0.0";
|
||||
})();
|
||||
|
||||
const bridgeTls = bridgeEnabled
|
||||
? await loadBridgeTlsRuntime(params.cfg.bridge?.tls, params.logBridge)
|
||||
: { enabled: false, required: false };
|
||||
if (bridgeTls.required && !bridgeTls.enabled) {
|
||||
params.logBridge.warn(bridgeTls.error ?? "bridge tls: failed to enable; bridge disabled");
|
||||
bridgeEnabled = false;
|
||||
}
|
||||
|
||||
const canvasHostPort = (() => {
|
||||
if (process.env.CLAWDBOT_CANVAS_HOST_PORT !== undefined) {
|
||||
const parsed = Number.parseInt(process.env.CLAWDBOT_CANVAS_HOST_PORT, 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) return parsed;
|
||||
return deriveDefaultCanvasHostPort(params.port);
|
||||
}
|
||||
const configured = params.cfg.canvasHost?.port;
|
||||
if (typeof configured === "number" && configured > 0) return configured;
|
||||
return deriveDefaultCanvasHostPort(params.port);
|
||||
})();
|
||||
|
||||
let canvasHostServer: CanvasHostServer | null = null;
|
||||
if (params.canvasHostEnabled && bridgeEnabled && bridgeHost) {
|
||||
try {
|
||||
const started = await startCanvasHost({
|
||||
runtime: params.canvasRuntime,
|
||||
rootDir: params.cfg.canvasHost?.root,
|
||||
port: canvasHostPort,
|
||||
listenHost: bridgeHost,
|
||||
allowInTests: params.allowCanvasHostInTests,
|
||||
liveReload: params.cfg.canvasHost?.liveReload,
|
||||
handler: params.canvasHost ?? undefined,
|
||||
ownsHandler: params.canvasHost ? false : undefined,
|
||||
});
|
||||
if (started.port > 0) {
|
||||
canvasHostServer = started;
|
||||
}
|
||||
} catch (err) {
|
||||
params.logCanvas.warn(`failed to start on ${bridgeHost}:${canvasHostPort}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
let bridge: NodeBridgeServer | null = null;
|
||||
const bridgeSubscriptions = createBridgeSubscriptionManager();
|
||||
const bridgeSubscribe = bridgeSubscriptions.subscribe;
|
||||
const bridgeUnsubscribe = bridgeSubscriptions.unsubscribe;
|
||||
const bridgeUnsubscribeAll = bridgeSubscriptions.unsubscribeAll;
|
||||
const bridgeSendEvent: BridgeSendEventFn = (opts) => {
|
||||
bridge?.sendEvent(opts);
|
||||
};
|
||||
const bridgeListConnected: BridgeListConnectedFn = () => bridge?.listConnected() ?? [];
|
||||
const bridgeSendToSession = (sessionKey: string, event: string, payload: unknown) =>
|
||||
bridgeSubscriptions.sendToSession(sessionKey, event, payload, bridgeSendEvent);
|
||||
const bridgeSendToAllSubscribed = (event: string, payload: unknown) =>
|
||||
bridgeSubscriptions.sendToAllSubscribed(event, payload, bridgeSendEvent);
|
||||
const bridgeSendToAllConnected = (event: string, payload: unknown) =>
|
||||
bridgeSubscriptions.sendToAllConnected(event, payload, bridgeListConnected, bridgeSendEvent);
|
||||
|
||||
const broadcastVoiceWakeChanged = (triggers: string[]) => {
|
||||
const payload = { triggers };
|
||||
params.broadcast("voicewake.changed", payload, { dropIfSlow: true });
|
||||
bridgeSendToAllConnected("voicewake.changed", payload);
|
||||
};
|
||||
|
||||
const { handleBridgeRequest, handleBridgeEvent } = createBridgeHandlers({
|
||||
deps: params.deps,
|
||||
broadcast: params.broadcast,
|
||||
bridgeSendToSession,
|
||||
bridgeSubscribe,
|
||||
bridgeUnsubscribe,
|
||||
broadcastVoiceWakeChanged,
|
||||
addChatRun: params.addChatRun,
|
||||
removeChatRun: params.removeChatRun,
|
||||
chatAbortControllers: params.chatAbortControllers,
|
||||
chatAbortedRuns: params.chatRunState.abortedRuns,
|
||||
chatRunBuffers: params.chatRunBuffers,
|
||||
chatDeltaSentAt: params.chatDeltaSentAt,
|
||||
dedupe: params.dedupe,
|
||||
agentRunSeq: params.agentRunSeq,
|
||||
getHealthCache: params.getHealthCache,
|
||||
refreshHealthSnapshot: params.refreshGatewayHealthSnapshot,
|
||||
loadGatewayModelCatalog: params.loadGatewayModelCatalog ?? loadGatewayModelCatalog,
|
||||
logBridge: params.logBridge,
|
||||
});
|
||||
|
||||
const canvasHostPortForBridge = canvasHostServer?.port;
|
||||
const canvasHostHostForBridge =
|
||||
canvasHostServer && bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::"
|
||||
? bridgeHost
|
||||
: undefined;
|
||||
|
||||
const bridgeRuntime = await startGatewayNodeBridge({
|
||||
cfg: params.cfg,
|
||||
bridgeEnabled,
|
||||
bridgePort,
|
||||
bridgeHost,
|
||||
bridgeTls: bridgeTls.enabled ? bridgeTls : undefined,
|
||||
machineDisplayName: params.machineDisplayName,
|
||||
canvasHostPort: canvasHostPortForBridge,
|
||||
canvasHostHost: canvasHostHostForBridge,
|
||||
broadcast: params.broadcast,
|
||||
bridgeUnsubscribeAll,
|
||||
handleBridgeRequest,
|
||||
handleBridgeEvent,
|
||||
logBridge: params.logBridge,
|
||||
});
|
||||
bridge = bridgeRuntime.bridge;
|
||||
|
||||
const discovery = await startGatewayDiscovery({
|
||||
machineDisplayName: params.machineDisplayName,
|
||||
port: params.port,
|
||||
gatewayTls: params.gatewayTls,
|
||||
bridgePort: bridge?.port,
|
||||
bridgeTls: bridgeTls.enabled
|
||||
? { enabled: true, fingerprintSha256: bridgeTls.fingerprintSha256 }
|
||||
: undefined,
|
||||
canvasPort: canvasHostPortForBridge,
|
||||
wideAreaDiscoveryEnabled,
|
||||
logDiscovery: params.logDiscovery,
|
||||
});
|
||||
|
||||
return {
|
||||
bridge,
|
||||
bridgeHost,
|
||||
bridgePort,
|
||||
canvasHostServer,
|
||||
nodePresenceTimers: bridgeRuntime.nodePresenceTimers,
|
||||
bonjourStop: discovery.bonjourStop,
|
||||
bridgeSendToSession,
|
||||
bridgeSendToAllSubscribed,
|
||||
broadcastVoiceWakeChanged,
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { ErrorCodes } from "./protocol/index.js";
|
||||
import { handleBridgeEvent as handleBridgeEventImpl } from "./server-bridge-events.js";
|
||||
import { handleChatBridgeMethods } from "./server-bridge-methods-chat.js";
|
||||
import { handleConfigBridgeMethods } from "./server-bridge-methods-config.js";
|
||||
import { handleSessionsBridgeMethods } from "./server-bridge-methods-sessions.js";
|
||||
import { handleSystemBridgeMethods } from "./server-bridge-methods-system.js";
|
||||
import type {
|
||||
BridgeEvent,
|
||||
BridgeHandlersContext,
|
||||
BridgeRequest,
|
||||
BridgeResponse,
|
||||
} from "./server-bridge-types.js";
|
||||
|
||||
export type { BridgeHandlersContext } from "./server-bridge-types.js";
|
||||
|
||||
export function createBridgeHandlers(ctx: BridgeHandlersContext) {
|
||||
const handleBridgeRequest = async (
|
||||
nodeId: string,
|
||||
req: BridgeRequest,
|
||||
): Promise<BridgeResponse> => {
|
||||
const method = req.method.trim();
|
||||
|
||||
const parseParams = (): Record<string, unknown> => {
|
||||
const raw = typeof req.paramsJSON === "string" ? req.paramsJSON : "";
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return {};
|
||||
const parsed = JSON.parse(trimmed) as unknown;
|
||||
return typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
};
|
||||
|
||||
try {
|
||||
const params = parseParams();
|
||||
const response =
|
||||
(await handleSystemBridgeMethods(ctx, nodeId, method, params)) ??
|
||||
(await handleConfigBridgeMethods(ctx, nodeId, method, params)) ??
|
||||
(await handleSessionsBridgeMethods(ctx, nodeId, method, params)) ??
|
||||
(await handleChatBridgeMethods(ctx, nodeId, method, params));
|
||||
if (response) return response;
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: "FORBIDDEN",
|
||||
message: "Method not allowed",
|
||||
details: { method },
|
||||
},
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: ErrorCodes.INVALID_REQUEST, message: String(err) },
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const handleBridgeEvent = async (nodeId: string, evt: BridgeEvent) => {
|
||||
await handleBridgeEventImpl(ctx, nodeId, evt);
|
||||
};
|
||||
|
||||
return { handleBridgeRequest, handleBridgeEvent };
|
||||
}
|
||||
@@ -94,11 +94,11 @@ export type ChatEventBroadcast = (
|
||||
opts?: { dropIfSlow?: boolean },
|
||||
) => void;
|
||||
|
||||
export type BridgeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
|
||||
export type NodeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
|
||||
|
||||
export type AgentEventHandlerOptions = {
|
||||
broadcast: ChatEventBroadcast;
|
||||
bridgeSendToSession: BridgeSendToSession;
|
||||
nodeSendToSession: NodeSendToSession;
|
||||
agentRunSeq: Map<string, number>;
|
||||
chatRunState: ChatRunState;
|
||||
resolveSessionKeyForRun: (runId: string) => string | undefined;
|
||||
@@ -107,7 +107,7 @@ export type AgentEventHandlerOptions = {
|
||||
|
||||
export function createAgentEventHandler({
|
||||
broadcast,
|
||||
bridgeSendToSession,
|
||||
nodeSendToSession,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
resolveSessionKeyForRun,
|
||||
@@ -131,7 +131,7 @@ export function createAgentEventHandler({
|
||||
},
|
||||
};
|
||||
broadcast("chat", payload, { dropIfSlow: true });
|
||||
bridgeSendToSession(sessionKey, "chat", payload);
|
||||
nodeSendToSession(sessionKey, "chat", payload);
|
||||
};
|
||||
|
||||
const emitChatFinal = (
|
||||
@@ -159,7 +159,7 @@ export function createAgentEventHandler({
|
||||
: undefined,
|
||||
};
|
||||
broadcast("chat", payload);
|
||||
bridgeSendToSession(sessionKey, "chat", payload);
|
||||
nodeSendToSession(sessionKey, "chat", payload);
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
@@ -170,7 +170,7 @@ export function createAgentEventHandler({
|
||||
errorMessage: error ? formatForLog(error) : undefined,
|
||||
};
|
||||
broadcast("chat", payload);
|
||||
bridgeSendToSession(sessionKey, "chat", payload);
|
||||
nodeSendToSession(sessionKey, "chat", payload);
|
||||
};
|
||||
|
||||
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
|
||||
@@ -222,7 +222,7 @@ export function createAgentEventHandler({
|
||||
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
|
||||
|
||||
if (sessionKey) {
|
||||
bridgeSendToSession(sessionKey, "agent", agentPayload);
|
||||
nodeSendToSession(sessionKey, "agent", agentPayload);
|
||||
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
|
||||
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { WebSocketServer } from "ws";
|
||||
import type { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
|
||||
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
||||
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
||||
import type { PluginServicesHandle } from "../plugins/services.js";
|
||||
|
||||
export function createGatewayCloseHandler(params: {
|
||||
@@ -11,7 +10,6 @@ export function createGatewayCloseHandler(params: {
|
||||
tailscaleCleanup: (() => Promise<void>) | null;
|
||||
canvasHost: CanvasHostHandler | null;
|
||||
canvasHostServer: CanvasHostServer | null;
|
||||
bridge: NodeBridgeServer | null;
|
||||
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
|
||||
pluginServices: PluginServicesHandle | null;
|
||||
cron: { stop: () => void };
|
||||
@@ -61,13 +59,6 @@ export function createGatewayCloseHandler(params: {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (params.bridge) {
|
||||
try {
|
||||
await params.bridge.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
for (const plugin of listChannelPlugins()) {
|
||||
await params.stopChannel(plugin.id);
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -20,7 +20,7 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => void;
|
||||
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
|
||||
nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
|
||||
getPresenceVersion: () => number;
|
||||
getHealthVersion: () => number;
|
||||
refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
||||
@@ -36,7 +36,7 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
sessionKey?: string,
|
||||
) => ChatRunEntry | undefined;
|
||||
agentRunSeq: Map<string, number>;
|
||||
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
}): {
|
||||
tickInterval: ReturnType<typeof setInterval>;
|
||||
healthInterval: ReturnType<typeof setInterval>;
|
||||
@@ -49,14 +49,14 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
health: params.getHealthVersion(),
|
||||
},
|
||||
});
|
||||
params.bridgeSendToAllSubscribed("health", snap);
|
||||
params.nodeSendToAllSubscribed("health", snap);
|
||||
});
|
||||
|
||||
// periodic keepalive
|
||||
const tickInterval = setInterval(() => {
|
||||
const payload = { ts: Date.now() };
|
||||
params.broadcast("tick", payload, { dropIfSlow: true });
|
||||
params.bridgeSendToAllSubscribed("tick", payload);
|
||||
params.nodeSendToAllSubscribed("tick", payload);
|
||||
}, TICK_INTERVAL_MS);
|
||||
|
||||
// periodic health refresh to keep cached snapshot warm
|
||||
@@ -95,7 +95,7 @@ export function startGatewayMaintenanceTimers(params: {
|
||||
removeChatRun: params.removeChatRun,
|
||||
agentRunSeq: params.agentRunSeq,
|
||||
broadcast: params.broadcast,
|
||||
bridgeSendToSession: params.bridgeSendToSession,
|
||||
nodeSendToSession: params.nodeSendToSession,
|
||||
},
|
||||
{ runId, sessionKey: entry.sessionKey, stopReason: "timeout" },
|
||||
);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
},
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -6,11 +6,14 @@ import {
|
||||
requestNodePairing,
|
||||
verifyNodeToken,
|
||||
} from "../../infra/node-pairing.js";
|
||||
import { listDevicePairing } from "../../infra/device-pairing.js";
|
||||
import {
|
||||
ErrorCodes,
|
||||
errorShape,
|
||||
validateNodeDescribeParams,
|
||||
validateNodeEventParams,
|
||||
validateNodeInvokeParams,
|
||||
validateNodeInvokeResultParams,
|
||||
validateNodeListParams,
|
||||
validateNodePairApproveParams,
|
||||
validateNodePairListParams,
|
||||
@@ -201,9 +204,29 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const list = await listNodePairing();
|
||||
const pairedById = new Map(list.paired.map((n) => [n.nodeId, n]));
|
||||
const connected = context.bridge?.listConnected?.() ?? [];
|
||||
const list = await listDevicePairing();
|
||||
const pairedById = new Map(
|
||||
list.paired
|
||||
.filter((entry) => entry.role === "node")
|
||||
.map((entry) => [
|
||||
entry.deviceId,
|
||||
{
|
||||
nodeId: entry.deviceId,
|
||||
displayName: entry.displayName,
|
||||
platform: entry.platform,
|
||||
version: undefined,
|
||||
coreVersion: undefined,
|
||||
uiVersion: undefined,
|
||||
deviceFamily: undefined,
|
||||
modelIdentifier: undefined,
|
||||
remoteIp: entry.remoteIp,
|
||||
caps: [],
|
||||
commands: [],
|
||||
permissions: undefined,
|
||||
},
|
||||
]),
|
||||
);
|
||||
const connected = context.nodeRegistry.listConnected();
|
||||
const connectedById = new Map(connected.map((n) => [n.nodeId, n]));
|
||||
const nodeIds = new Set<string>([...pairedById.keys(), ...connectedById.keys()]);
|
||||
|
||||
@@ -260,9 +283,9 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const list = await listNodePairing();
|
||||
const paired = list.paired.find((n) => n.nodeId === id);
|
||||
const connected = context.bridge?.listConnected?.() ?? [];
|
||||
const list = await listDevicePairing();
|
||||
const paired = list.paired.find((n) => n.deviceId === id && n.role === "node");
|
||||
const connected = context.nodeRegistry.listConnected();
|
||||
const live = connected.find((n) => n.nodeId === id);
|
||||
|
||||
if (!paired && !live) {
|
||||
@@ -270,8 +293,8 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
return;
|
||||
}
|
||||
|
||||
const caps = uniqueSortedStrings([...(live?.caps ?? paired?.caps ?? [])]);
|
||||
const commands = uniqueSortedStrings([...(live?.commands ?? paired?.commands ?? [])]);
|
||||
const caps = uniqueSortedStrings([...(live?.caps ?? [])]);
|
||||
const commands = uniqueSortedStrings([...(live?.commands ?? [])]);
|
||||
|
||||
respond(
|
||||
true,
|
||||
@@ -280,15 +303,15 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
nodeId: id,
|
||||
displayName: live?.displayName ?? paired?.displayName,
|
||||
platform: live?.platform ?? paired?.platform,
|
||||
version: live?.version ?? paired?.version,
|
||||
coreVersion: live?.coreVersion ?? paired?.coreVersion,
|
||||
uiVersion: live?.uiVersion ?? paired?.uiVersion,
|
||||
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
|
||||
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
|
||||
version: live?.version,
|
||||
coreVersion: live?.coreVersion,
|
||||
uiVersion: live?.uiVersion,
|
||||
deviceFamily: live?.deviceFamily,
|
||||
modelIdentifier: live?.modelIdentifier,
|
||||
remoteIp: live?.remoteIp ?? paired?.remoteIp,
|
||||
caps,
|
||||
commands,
|
||||
permissions: live?.permissions ?? paired?.permissions,
|
||||
permissions: live?.permissions,
|
||||
paired: Boolean(paired),
|
||||
connected: Boolean(live),
|
||||
},
|
||||
@@ -305,11 +328,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
});
|
||||
return;
|
||||
}
|
||||
const bridge = context.bridge;
|
||||
if (!bridge) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
|
||||
return;
|
||||
}
|
||||
const p = params as {
|
||||
nodeId: string;
|
||||
command: string;
|
||||
@@ -329,12 +347,12 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
}
|
||||
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const paramsJSON = "params" in p && p.params !== undefined ? JSON.stringify(p.params) : null;
|
||||
const res = await bridge.invoke({
|
||||
const res = await context.nodeRegistry.invoke({
|
||||
nodeId,
|
||||
command,
|
||||
paramsJSON,
|
||||
params: p.params,
|
||||
timeoutMs: p.timeoutMs,
|
||||
idempotencyKey: p.idempotencyKey,
|
||||
});
|
||||
if (!res.ok) {
|
||||
respond(
|
||||
@@ -346,7 +364,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
return;
|
||||
}
|
||||
const payload = safeParseJson(res.payloadJSON ?? null);
|
||||
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
|
||||
respond(
|
||||
true,
|
||||
{
|
||||
@@ -360,4 +378,85 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
||||
);
|
||||
});
|
||||
},
|
||||
"node.invoke.result": async ({ params, respond, context }) => {
|
||||
if (!validateNodeInvokeResultParams(params)) {
|
||||
respondInvalidParams({
|
||||
respond,
|
||||
method: "node.invoke.result",
|
||||
validator: validateNodeInvokeResultParams,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const p = params as {
|
||||
id: string;
|
||||
nodeId: string;
|
||||
ok: boolean;
|
||||
payload?: unknown;
|
||||
payloadJSON?: string | null;
|
||||
error?: { code?: string; message?: string } | null;
|
||||
};
|
||||
const ok = context.nodeRegistry.handleInvokeResult({
|
||||
id: p.id,
|
||||
nodeId: p.nodeId,
|
||||
ok: p.ok,
|
||||
payload: p.payload,
|
||||
payloadJSON: p.payloadJSON ?? null,
|
||||
error: p.error ?? null,
|
||||
});
|
||||
if (!ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, "unknown invoke id"));
|
||||
return;
|
||||
}
|
||||
respond(true, { ok: true }, undefined);
|
||||
},
|
||||
"node.event": async ({ params, respond, context }) => {
|
||||
if (!validateNodeEventParams(params)) {
|
||||
respondInvalidParams({
|
||||
respond,
|
||||
method: "node.event",
|
||||
validator: validateNodeEventParams,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const p = params as { event: string; payload?: unknown; payloadJSON?: string | null };
|
||||
const payloadJSON =
|
||||
typeof p.payloadJSON === "string"
|
||||
? p.payloadJSON
|
||||
: p.payload !== undefined
|
||||
? JSON.stringify(p.payload)
|
||||
: null;
|
||||
await respondUnavailableOnThrow(respond, async () => {
|
||||
const { handleNodeEvent } = await import("../server-node-events.js");
|
||||
const nodeContext = {
|
||||
deps: context.deps,
|
||||
broadcast: context.broadcast,
|
||||
nodeSendToSession: context.nodeSendToSession,
|
||||
nodeSubscribe: context.nodeSubscribe,
|
||||
nodeUnsubscribe: context.nodeUnsubscribe,
|
||||
broadcastVoiceWakeChanged: context.broadcastVoiceWakeChanged,
|
||||
addChatRun: context.addChatRun,
|
||||
removeChatRun: context.removeChatRun,
|
||||
chatAbortControllers: context.chatAbortControllers,
|
||||
chatAbortedRuns: context.chatAbortedRuns,
|
||||
chatRunBuffers: context.chatRunBuffers,
|
||||
chatDeltaSentAt: context.chatDeltaSentAt,
|
||||
dedupe: context.dedupe,
|
||||
agentRunSeq: context.agentRunSeq,
|
||||
getHealthCache: context.getHealthCache,
|
||||
refreshHealthSnapshot: context.refreshHealthSnapshot,
|
||||
loadGatewayModelCatalog: context.loadGatewayModelCatalog,
|
||||
logGateway: { warn: context.logGateway.warn },
|
||||
};
|
||||
await handleNodeEvent(
|
||||
nodeContext,
|
||||
"node",
|
||||
{
|
||||
type: "event",
|
||||
event: p.event,
|
||||
payloadJSON,
|
||||
},
|
||||
);
|
||||
respond(true, { ok: true }, undefined);
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -2,9 +2,9 @@ import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
|
||||
import type { createDefaultDeps } from "../../cli/deps.js";
|
||||
import type { HealthSummary } from "../../commands/health.js";
|
||||
import type { CronService } from "../../cron/service.js";
|
||||
import type { startNodeBridgeServer } from "../../infra/bridge/server.js";
|
||||
import type { WizardSession } from "../../wizard/session.js";
|
||||
import type { ChatAbortControllerEntry } from "../chat-abort.js";
|
||||
import type { NodeRegistry } from "../node-registry.js";
|
||||
import type { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js";
|
||||
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
|
||||
import type { DedupeEntry } from "../server-shared.js";
|
||||
@@ -39,9 +39,13 @@ export type GatewayRequestContext = {
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => void;
|
||||
bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null;
|
||||
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
|
||||
nodeSubscribe: (nodeId: string, sessionKey: string) => void;
|
||||
nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
|
||||
nodeUnsubscribeAll: (nodeId: string) => void;
|
||||
hasConnectedMobileNode: () => boolean;
|
||||
nodeRegistry: NodeRegistry;
|
||||
agentRunSeq: Map<string, number>;
|
||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||
chatAbortedRuns: Map<string, number>;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
||||
import { startNodeBridgeServer } from "../infra/bridge/server.js";
|
||||
import type { BridgeTlsRuntime } from "../infra/bridge/server/tls.js";
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { bumpSkillsSnapshotVersion } from "../agents/skills/refresh.js";
|
||||
import { recordRemoteNodeInfo, refreshRemoteNodeBins } from "../infra/skills-remote.js";
|
||||
import { listSystemPresence, upsertPresence } from "../infra/system-presence.js";
|
||||
import { loadVoiceWakeConfig } from "../infra/voicewake.js";
|
||||
import { isLoopbackAddress } from "./net.js";
|
||||
import {
|
||||
getHealthVersion,
|
||||
getPresenceVersion,
|
||||
incrementPresenceVersion,
|
||||
} from "./server/health-state.js";
|
||||
import type { BridgeEvent, BridgeRequest, BridgeResponse } from "./server-bridge-types.js";
|
||||
|
||||
export type GatewayNodeBridgeRuntime = {
|
||||
bridge: NodeBridgeServer | null;
|
||||
nodePresenceTimers: Map<string, ReturnType<typeof setInterval>>;
|
||||
};
|
||||
|
||||
export async function startGatewayNodeBridge(params: {
|
||||
cfg: ClawdbotConfig;
|
||||
bridgeEnabled: boolean;
|
||||
bridgePort: number;
|
||||
bridgeHost: string | null;
|
||||
bridgeTls?: BridgeTlsRuntime;
|
||||
machineDisplayName: string;
|
||||
canvasHostPort?: number;
|
||||
canvasHostHost?: string;
|
||||
broadcast: (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
opts?: {
|
||||
dropIfSlow?: boolean;
|
||||
stateVersion?: { presence?: number; health?: number };
|
||||
},
|
||||
) => void;
|
||||
bridgeUnsubscribeAll: (nodeId: string) => void;
|
||||
handleBridgeRequest: (nodeId: string, req: BridgeRequest) => Promise<BridgeResponse>;
|
||||
handleBridgeEvent: (nodeId: string, evt: BridgeEvent) => Promise<void> | void;
|
||||
logBridge: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||
}): Promise<GatewayNodeBridgeRuntime> {
|
||||
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
|
||||
const formatVersionLabel = (raw: string): string => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) return raw;
|
||||
if (trimmed.toLowerCase().startsWith("v")) return trimmed;
|
||||
return /^\d/.test(trimmed) ? `v${trimmed}` : trimmed;
|
||||
};
|
||||
|
||||
const resolveNodeVersionLabel = (node: {
|
||||
coreVersion?: string;
|
||||
uiVersion?: string;
|
||||
}): string | null => {
|
||||
const core = node.coreVersion?.trim();
|
||||
const ui = node.uiVersion?.trim();
|
||||
const parts: string[] = [];
|
||||
if (core) parts.push(`core ${formatVersionLabel(core)}`);
|
||||
if (ui) parts.push(`ui ${formatVersionLabel(ui)}`);
|
||||
return parts.length > 0 ? parts.join(" · ") : null;
|
||||
};
|
||||
|
||||
const stopNodePresenceTimer = (nodeId: string) => {
|
||||
const timer = nodePresenceTimers.get(nodeId);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
}
|
||||
nodePresenceTimers.delete(nodeId);
|
||||
};
|
||||
|
||||
const beaconNodePresence = (
|
||||
node: {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
remoteIp?: string;
|
||||
version?: string;
|
||||
coreVersion?: string;
|
||||
uiVersion?: string;
|
||||
platform?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
},
|
||||
reason: string,
|
||||
) => {
|
||||
const host = node.displayName?.trim() || node.nodeId;
|
||||
const rawIp = node.remoteIp?.trim();
|
||||
const ip = rawIp && !isLoopbackAddress(rawIp) ? rawIp : undefined;
|
||||
const version = resolveNodeVersionLabel(node) ?? node.version?.trim() ?? "unknown";
|
||||
const platform = node.platform?.trim() || undefined;
|
||||
const deviceFamily = node.deviceFamily?.trim() || undefined;
|
||||
const modelIdentifier = node.modelIdentifier?.trim() || undefined;
|
||||
const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason ${reason}`;
|
||||
upsertPresence(node.nodeId, {
|
||||
host,
|
||||
ip,
|
||||
version,
|
||||
platform,
|
||||
deviceFamily,
|
||||
modelIdentifier,
|
||||
mode: "remote",
|
||||
reason,
|
||||
lastInputSeconds: 0,
|
||||
instanceId: node.nodeId,
|
||||
text,
|
||||
});
|
||||
incrementPresenceVersion();
|
||||
params.broadcast(
|
||||
"presence",
|
||||
{ presence: listSystemPresence() },
|
||||
{
|
||||
dropIfSlow: true,
|
||||
stateVersion: {
|
||||
presence: getPresenceVersion(),
|
||||
health: getHealthVersion(),
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const startNodePresenceTimer = (node: { nodeId: string }) => {
|
||||
stopNodePresenceTimer(node.nodeId);
|
||||
nodePresenceTimers.set(
|
||||
node.nodeId,
|
||||
setInterval(() => {
|
||||
beaconNodePresence(node, "periodic");
|
||||
}, 180_000),
|
||||
);
|
||||
};
|
||||
|
||||
if (params.bridgeEnabled && params.bridgePort > 0 && params.bridgeHost) {
|
||||
try {
|
||||
const started = await startNodeBridgeServer({
|
||||
host: params.bridgeHost,
|
||||
port: params.bridgePort,
|
||||
tls: params.bridgeTls?.tlsOptions,
|
||||
serverName: params.machineDisplayName,
|
||||
canvasHostPort: params.canvasHostPort,
|
||||
canvasHostHost: params.canvasHostHost,
|
||||
onRequest: (nodeId, req) => params.handleBridgeRequest(nodeId, req),
|
||||
onAuthenticated: async (node) => {
|
||||
beaconNodePresence(node, "node-connected");
|
||||
startNodePresenceTimer(node);
|
||||
recordRemoteNodeInfo({
|
||||
nodeId: node.nodeId,
|
||||
displayName: node.displayName,
|
||||
platform: node.platform,
|
||||
deviceFamily: node.deviceFamily,
|
||||
commands: node.commands,
|
||||
remoteIp: node.remoteIp,
|
||||
});
|
||||
bumpSkillsSnapshotVersion({ reason: "remote-node" });
|
||||
await refreshRemoteNodeBins({
|
||||
nodeId: node.nodeId,
|
||||
platform: node.platform,
|
||||
deviceFamily: node.deviceFamily,
|
||||
commands: node.commands,
|
||||
cfg: params.cfg,
|
||||
});
|
||||
|
||||
try {
|
||||
const cfg = await loadVoiceWakeConfig();
|
||||
started.sendEvent({
|
||||
nodeId: node.nodeId,
|
||||
event: "voicewake.changed",
|
||||
payloadJSON: JSON.stringify({ triggers: cfg.triggers }),
|
||||
});
|
||||
} catch {
|
||||
// Best-effort only.
|
||||
}
|
||||
},
|
||||
onDisconnected: (node) => {
|
||||
params.bridgeUnsubscribeAll(node.nodeId);
|
||||
stopNodePresenceTimer(node.nodeId);
|
||||
beaconNodePresence(node, "node-disconnected");
|
||||
},
|
||||
onEvent: params.handleBridgeEvent,
|
||||
onPairRequested: (request) => {
|
||||
params.broadcast("node.pair.requested", request, {
|
||||
dropIfSlow: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
if (started.port > 0) {
|
||||
const scheme = params.bridgeTls?.enabled ? "tls" : "tcp";
|
||||
params.logBridge.info(
|
||||
`listening on ${scheme}://${params.bridgeHost}:${started.port} (node)`,
|
||||
);
|
||||
return { bridge: started, nodePresenceTimers };
|
||||
}
|
||||
} catch (err) {
|
||||
params.logBridge.warn(`failed to start: ${String(err)}`);
|
||||
}
|
||||
} else if (params.bridgeEnabled && params.bridgePort > 0 && !params.bridgeHost) {
|
||||
params.logBridge.warn(
|
||||
"bind policy requested tailnet IP, but no tailnet interface was found; refusing to start bridge",
|
||||
);
|
||||
}
|
||||
|
||||
return { bridge: null, nodePresenceTimers };
|
||||
}
|
||||
@@ -5,12 +5,12 @@ import type { ChatAbortControllerEntry } from "./chat-abort.js";
|
||||
import type { ChatRunEntry } from "./server-chat.js";
|
||||
import type { DedupeEntry } from "./server-shared.js";
|
||||
|
||||
export type BridgeHandlersContext = {
|
||||
export type NodeEventContext = {
|
||||
deps: CliDeps;
|
||||
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
|
||||
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
bridgeSubscribe: (nodeId: string, sessionKey: string) => void;
|
||||
bridgeUnsubscribe: (nodeId: string, sessionKey: string) => void;
|
||||
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||
nodeSubscribe: (nodeId: string, sessionKey: string) => void;
|
||||
nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
|
||||
broadcastVoiceWakeChanged: (triggers: string[]) => void;
|
||||
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
|
||||
removeChatRun: (
|
||||
@@ -27,32 +27,10 @@ export type BridgeHandlersContext = {
|
||||
getHealthCache: () => HealthSummary | null;
|
||||
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
||||
loadGatewayModelCatalog: () => Promise<ModelCatalogEntry[]>;
|
||||
logBridge: { warn: (msg: string) => void };
|
||||
logGateway: { warn: (msg: string) => void };
|
||||
};
|
||||
|
||||
export type BridgeRequest = {
|
||||
id: string;
|
||||
method: string;
|
||||
paramsJSON?: string | null;
|
||||
};
|
||||
|
||||
export type BridgeEvent = {
|
||||
export type NodeEvent = {
|
||||
event: string;
|
||||
payloadJSON?: string | null;
|
||||
};
|
||||
|
||||
export type BridgeResponse =
|
||||
| { ok: true; payloadJSON?: string | null }
|
||||
| {
|
||||
ok: false;
|
||||
error: { code: string; message: string; details?: unknown };
|
||||
};
|
||||
|
||||
export type BridgeRequestParams = Record<string, unknown>;
|
||||
|
||||
export type BridgeMethodHandler = (
|
||||
ctx: BridgeHandlersContext,
|
||||
nodeId: string,
|
||||
method: string,
|
||||
params: BridgeRequestParams,
|
||||
) => Promise<BridgeResponse | null>;
|
||||
@@ -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",
|
||||
@@ -7,14 +7,14 @@ import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||
import { normalizeMainKey } from "../routing/session-key.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import type { BridgeEvent, BridgeHandlersContext } from "./server-bridge-types.js";
|
||||
import type { NodeEvent, NodeEventContext } from "./server-node-events-types.js";
|
||||
import { loadSessionEntry } from "./session-utils.js";
|
||||
import { formatForLog } from "./ws-log.js";
|
||||
|
||||
export const handleBridgeEvent = async (
|
||||
ctx: BridgeHandlersContext,
|
||||
export const handleNodeEvent = async (
|
||||
ctx: NodeEventContext,
|
||||
nodeId: string,
|
||||
evt: BridgeEvent,
|
||||
evt: NodeEvent,
|
||||
) => {
|
||||
switch (evt.event) {
|
||||
case "voice.transcript": {
|
||||
@@ -72,7 +72,7 @@ export const handleBridgeEvent = async (
|
||||
defaultRuntime,
|
||||
ctx.deps,
|
||||
).catch((err) => {
|
||||
ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
||||
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -140,7 +140,7 @@ export const handleBridgeEvent = async (
|
||||
defaultRuntime,
|
||||
ctx.deps,
|
||||
).catch((err) => {
|
||||
ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
||||
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -156,7 +156,7 @@ export const handleBridgeEvent = async (
|
||||
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
||||
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
||||
if (!sessionKey) return;
|
||||
ctx.bridgeSubscribe(nodeId, sessionKey);
|
||||
ctx.nodeSubscribe(nodeId, sessionKey);
|
||||
return;
|
||||
}
|
||||
case "chat.unsubscribe": {
|
||||
@@ -171,7 +171,7 @@ export const handleBridgeEvent = async (
|
||||
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
||||
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
||||
if (!sessionKey) return;
|
||||
ctx.bridgeUnsubscribe(nodeId, sessionKey);
|
||||
ctx.nodeUnsubscribe(nodeId, sessionKey);
|
||||
return;
|
||||
}
|
||||
case "exec.started":
|
||||
@@ -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}`);
|
||||
@@ -1,12 +1,12 @@
|
||||
export type BridgeSendEventFn = (opts: {
|
||||
export type NodeSendEventFn = (opts: {
|
||||
nodeId: string;
|
||||
event: string;
|
||||
payloadJSON?: string | null;
|
||||
}) => void;
|
||||
|
||||
export type BridgeListConnectedFn = () => Array<{ nodeId: string }>;
|
||||
export type NodeListConnectedFn = () => Array<{ nodeId: string }>;
|
||||
|
||||
export type BridgeSubscriptionManager = {
|
||||
export type NodeSubscriptionManager = {
|
||||
subscribe: (nodeId: string, sessionKey: string) => void;
|
||||
unsubscribe: (nodeId: string, sessionKey: string) => void;
|
||||
unsubscribeAll: (nodeId: string) => void;
|
||||
@@ -14,25 +14,25 @@ export type BridgeSubscriptionManager = {
|
||||
sessionKey: string,
|
||||
event: string,
|
||||
payload: unknown,
|
||||
sendEvent?: BridgeSendEventFn | null,
|
||||
sendEvent?: NodeSendEventFn | null,
|
||||
) => void;
|
||||
sendToAllSubscribed: (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
sendEvent?: BridgeSendEventFn | null,
|
||||
sendEvent?: NodeSendEventFn | null,
|
||||
) => void;
|
||||
sendToAllConnected: (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
listConnected?: BridgeListConnectedFn | null,
|
||||
sendEvent?: BridgeSendEventFn | null,
|
||||
listConnected?: NodeListConnectedFn | null,
|
||||
sendEvent?: NodeSendEventFn | null,
|
||||
) => void;
|
||||
clear: () => void;
|
||||
};
|
||||
|
||||
export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
||||
const bridgeNodeSubscriptions = new Map<string, Set<string>>();
|
||||
const bridgeSessionSubscribers = new Map<string, Set<string>>();
|
||||
export function createNodeSubscriptionManager(): NodeSubscriptionManager {
|
||||
const nodeSubscriptions = new Map<string, Set<string>>();
|
||||
const sessionSubscribers = new Map<string, Set<string>>();
|
||||
|
||||
const toPayloadJSON = (payload: unknown) => (payload ? JSON.stringify(payload) : null);
|
||||
|
||||
@@ -41,18 +41,18 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedNodeId || !normalizedSessionKey) return;
|
||||
|
||||
let nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
|
||||
let nodeSet = nodeSubscriptions.get(normalizedNodeId);
|
||||
if (!nodeSet) {
|
||||
nodeSet = new Set<string>();
|
||||
bridgeNodeSubscriptions.set(normalizedNodeId, nodeSet);
|
||||
nodeSubscriptions.set(normalizedNodeId, nodeSet);
|
||||
}
|
||||
if (nodeSet.has(normalizedSessionKey)) return;
|
||||
nodeSet.add(normalizedSessionKey);
|
||||
|
||||
let sessionSet = bridgeSessionSubscribers.get(normalizedSessionKey);
|
||||
let sessionSet = sessionSubscribers.get(normalizedSessionKey);
|
||||
if (!sessionSet) {
|
||||
sessionSet = new Set<string>();
|
||||
bridgeSessionSubscribers.set(normalizedSessionKey, sessionSet);
|
||||
sessionSubscribers.set(normalizedSessionKey, sessionSet);
|
||||
}
|
||||
sessionSet.add(normalizedNodeId);
|
||||
};
|
||||
@@ -62,36 +62,36 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedNodeId || !normalizedSessionKey) return;
|
||||
|
||||
const nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
|
||||
const nodeSet = nodeSubscriptions.get(normalizedNodeId);
|
||||
nodeSet?.delete(normalizedSessionKey);
|
||||
if (nodeSet?.size === 0) bridgeNodeSubscriptions.delete(normalizedNodeId);
|
||||
if (nodeSet?.size === 0) nodeSubscriptions.delete(normalizedNodeId);
|
||||
|
||||
const sessionSet = bridgeSessionSubscribers.get(normalizedSessionKey);
|
||||
const sessionSet = sessionSubscribers.get(normalizedSessionKey);
|
||||
sessionSet?.delete(normalizedNodeId);
|
||||
if (sessionSet?.size === 0) bridgeSessionSubscribers.delete(normalizedSessionKey);
|
||||
if (sessionSet?.size === 0) sessionSubscribers.delete(normalizedSessionKey);
|
||||
};
|
||||
|
||||
const unsubscribeAll = (nodeId: string) => {
|
||||
const normalizedNodeId = nodeId.trim();
|
||||
const nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
|
||||
const nodeSet = nodeSubscriptions.get(normalizedNodeId);
|
||||
if (!nodeSet) return;
|
||||
for (const sessionKey of nodeSet) {
|
||||
const sessionSet = bridgeSessionSubscribers.get(sessionKey);
|
||||
const sessionSet = sessionSubscribers.get(sessionKey);
|
||||
sessionSet?.delete(normalizedNodeId);
|
||||
if (sessionSet?.size === 0) bridgeSessionSubscribers.delete(sessionKey);
|
||||
if (sessionSet?.size === 0) sessionSubscribers.delete(sessionKey);
|
||||
}
|
||||
bridgeNodeSubscriptions.delete(normalizedNodeId);
|
||||
nodeSubscriptions.delete(normalizedNodeId);
|
||||
};
|
||||
|
||||
const sendToSession = (
|
||||
sessionKey: string,
|
||||
event: string,
|
||||
payload: unknown,
|
||||
sendEvent?: BridgeSendEventFn | null,
|
||||
sendEvent?: NodeSendEventFn | null,
|
||||
) => {
|
||||
const normalizedSessionKey = sessionKey.trim();
|
||||
if (!normalizedSessionKey || !sendEvent) return;
|
||||
const subs = bridgeSessionSubscribers.get(normalizedSessionKey);
|
||||
const subs = sessionSubscribers.get(normalizedSessionKey);
|
||||
if (!subs || subs.size === 0) return;
|
||||
|
||||
const payloadJSON = toPayloadJSON(payload);
|
||||
@@ -103,11 +103,11 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
||||
const sendToAllSubscribed = (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
sendEvent?: BridgeSendEventFn | null,
|
||||
sendEvent?: NodeSendEventFn | null,
|
||||
) => {
|
||||
if (!sendEvent) return;
|
||||
const payloadJSON = toPayloadJSON(payload);
|
||||
for (const nodeId of bridgeNodeSubscriptions.keys()) {
|
||||
for (const nodeId of nodeSubscriptions.keys()) {
|
||||
sendEvent({ nodeId, event, payloadJSON });
|
||||
}
|
||||
};
|
||||
@@ -115,8 +115,8 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
||||
const sendToAllConnected = (
|
||||
event: string,
|
||||
payload: unknown,
|
||||
listConnected?: BridgeListConnectedFn | null,
|
||||
sendEvent?: BridgeSendEventFn | null,
|
||||
listConnected?: NodeListConnectedFn | null,
|
||||
sendEvent?: NodeSendEventFn | null,
|
||||
) => {
|
||||
if (!sendEvent || !listConnected) return;
|
||||
const payloadJSON = toPayloadJSON(payload);
|
||||
@@ -126,8 +126,8 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
bridgeNodeSubscriptions.clear();
|
||||
bridgeSessionSubscribers.clear();
|
||||
nodeSubscriptions.clear();
|
||||
sessionSubscribers.clear();
|
||||
};
|
||||
|
||||
return {
|
||||
@@ -1,9 +1,4 @@
|
||||
import type {
|
||||
BridgeBindMode,
|
||||
GatewayAuthConfig,
|
||||
GatewayTailscaleConfig,
|
||||
loadConfig,
|
||||
} from "../config/config.js";
|
||||
import type { GatewayAuthConfig, GatewayBindMode, GatewayTailscaleConfig, loadConfig } from "../config/config.js";
|
||||
import {
|
||||
assertGatewayAuthConfigured,
|
||||
type ResolvedGatewayAuth,
|
||||
@@ -29,7 +24,7 @@ export type GatewayRuntimeConfig = {
|
||||
export async function resolveGatewayRuntimeConfig(params: {
|
||||
cfg: ReturnType<typeof loadConfig>;
|
||||
port: number;
|
||||
bind?: BridgeBindMode;
|
||||
bind?: GatewayBindMode;
|
||||
host?: string;
|
||||
controlUiEnabled?: boolean;
|
||||
openAiChatCompletionsEnabled?: boolean;
|
||||
|
||||
@@ -9,7 +9,7 @@ export function attachGatewayWsHandlers(params: {
|
||||
wss: WebSocketServer;
|
||||
clients: Set<GatewayWsClient>;
|
||||
port: number;
|
||||
bridgeHost?: string;
|
||||
gatewayHost?: string;
|
||||
canvasHostEnabled: boolean;
|
||||
canvasHostServerPort?: number;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
@@ -33,7 +33,7 @@ export function attachGatewayWsHandlers(params: {
|
||||
wss: params.wss,
|
||||
clients: params.clients,
|
||||
port: params.port,
|
||||
bridgeHost: params.bridgeHost,
|
||||
gatewayHost: params.gatewayHost,
|
||||
canvasHostEnabled: params.canvasHostEnabled,
|
||||
canvasHostServerPort: params.canvasHostServerPort,
|
||||
resolvedAuth: params.resolvedAuth,
|
||||
|
||||
@@ -20,7 +20,7 @@ import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
||||
import {
|
||||
primeRemoteSkillsCache,
|
||||
refreshRemoteBinsForConnectedNodes,
|
||||
setSkillsRemoteBridge,
|
||||
setSkillsRemoteRegistry,
|
||||
} from "../infra/skills-remote.js";
|
||||
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
|
||||
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
incrementPresenceVersion,
|
||||
refreshGatewayHealthSnapshot,
|
||||
} from "./server/health-state.js";
|
||||
import { startGatewayBridgeRuntime } from "./server-bridge-runtime.js";
|
||||
import { startGatewayDiscovery } from "./server-discovery-runtime.js";
|
||||
import { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
|
||||
import type { startBrowserControlServerIfEnabled } from "./server-browser.js";
|
||||
@@ -48,12 +48,15 @@ import { applyGatewayLaneConcurrency } from "./server-lanes.js";
|
||||
import { startGatewayMaintenanceTimers } from "./server-maintenance.js";
|
||||
import { coreGatewayHandlers } from "./server-methods.js";
|
||||
import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.js";
|
||||
import { hasConnectedMobileNode as hasConnectedMobileNodeFromBridge } from "./server-mobile-nodes.js";
|
||||
import { loadGatewayModelCatalog } from "./server-model-catalog.js";
|
||||
import { NodeRegistry } from "./node-registry.js";
|
||||
import { createNodeSubscriptionManager } from "./server-node-subscriptions.js";
|
||||
import { safeParseJson } from "./server-methods/nodes.helpers.js";
|
||||
import { loadGatewayPlugins } from "./server-plugins.js";
|
||||
import { createGatewayReloadHandlers } from "./server-reload-handlers.js";
|
||||
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
|
||||
import { createGatewayRuntimeState } from "./server-runtime-state.js";
|
||||
import { hasConnectedMobileNode } from "./server-mobile-nodes.js";
|
||||
import { resolveSessionKeyForRun } from "./server-session-key.js";
|
||||
import { startGatewaySidecars } from "./server-startup.js";
|
||||
import { logGatewayStartup } from "./server-startup-log.js";
|
||||
@@ -68,7 +71,6 @@ ensureClawdbotCliOnPath();
|
||||
|
||||
const log = createSubsystemLogger("gateway");
|
||||
const logCanvas = log.child("canvas");
|
||||
const logBridge = log.child("bridge");
|
||||
const logDiscovery = log.child("discovery");
|
||||
const logTailscale = log.child("tailscale");
|
||||
const logChannels = log.child("channels");
|
||||
@@ -93,7 +95,7 @@ export type GatewayServerOptions = {
|
||||
* - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10)
|
||||
* - auto: prefer tailnet, else LAN
|
||||
*/
|
||||
bind?: import("../config/config.js").BridgeBindMode;
|
||||
bind?: import("../config/config.js").GatewayBindMode;
|
||||
/**
|
||||
* Advanced override for the bind host, bypassing bind resolution.
|
||||
* Prefer `bind` unless you really need a specific address.
|
||||
@@ -135,7 +137,7 @@ export async function startGatewayServer(
|
||||
port = 18789,
|
||||
opts: GatewayServerOptions = {},
|
||||
): Promise<GatewayServer> {
|
||||
// Ensure all default port derivations (browser/bridge/canvas) see the actual runtime port.
|
||||
// Ensure all default port derivations (browser/canvas) see the actual runtime port.
|
||||
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
|
||||
|
||||
let configSnapshot = await readConfigFileSnapshot();
|
||||
@@ -261,9 +263,24 @@ export async function startGatewayServer(
|
||||
logPlugins,
|
||||
});
|
||||
let bonjourStop: (() => Promise<void>) | null = null;
|
||||
let bridge: import("../infra/bridge/server.js").NodeBridgeServer | null = null;
|
||||
|
||||
const hasConnectedMobileNode = () => hasConnectedMobileNodeFromBridge(bridge);
|
||||
const nodeRegistry = new NodeRegistry();
|
||||
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||
const nodeSubscriptions = createNodeSubscriptionManager();
|
||||
const nodeSendEvent = (opts: { nodeId: string; event: string; payloadJSON?: string | null }) => {
|
||||
const payload = safeParseJson(opts.payloadJSON ?? null);
|
||||
nodeRegistry.sendEvent(opts.nodeId, opts.event, payload);
|
||||
};
|
||||
const nodeSendToSession = (sessionKey: string, event: string, payload: unknown) =>
|
||||
nodeSubscriptions.sendToSession(sessionKey, event, payload, nodeSendEvent);
|
||||
const nodeSendToAllSubscribed = (event: string, payload: unknown) =>
|
||||
nodeSubscriptions.sendToAllSubscribed(event, payload, nodeSendEvent);
|
||||
const nodeSubscribe = nodeSubscriptions.subscribe;
|
||||
const nodeUnsubscribe = nodeSubscriptions.unsubscribe;
|
||||
const nodeUnsubscribeAll = nodeSubscriptions.unsubscribeAll;
|
||||
const broadcastVoiceWakeChanged = (triggers: string[]) => {
|
||||
broadcast("voicewake.changed", { triggers }, { dropIfSlow: true });
|
||||
};
|
||||
const hasMobileNodeConnected = () => hasConnectedMobileNode(nodeRegistry);
|
||||
applyGatewayLaneConcurrency(cfgAtStart);
|
||||
|
||||
let cronState = buildGatewayCronService({
|
||||
@@ -282,44 +299,18 @@ export async function startGatewayServer(
|
||||
channelManager;
|
||||
|
||||
const machineDisplayName = await getMachineDisplayName();
|
||||
const bridgeRuntime = await startGatewayBridgeRuntime({
|
||||
cfg: cfgAtStart,
|
||||
const discovery = await startGatewayDiscovery({
|
||||
machineDisplayName,
|
||||
port,
|
||||
gatewayTls: gatewayTls.enabled
|
||||
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
|
||||
: undefined,
|
||||
canvasHostEnabled,
|
||||
canvasHost,
|
||||
canvasRuntime,
|
||||
allowCanvasHostInTests: opts.allowCanvasHostInTests,
|
||||
machineDisplayName,
|
||||
deps,
|
||||
broadcast,
|
||||
dedupe,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
chatRunBuffers,
|
||||
chatDeltaSentAt,
|
||||
addChatRun,
|
||||
removeChatRun,
|
||||
chatAbortControllers,
|
||||
getHealthCache,
|
||||
refreshGatewayHealthSnapshot,
|
||||
loadGatewayModelCatalog,
|
||||
logBridge,
|
||||
logCanvas,
|
||||
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
|
||||
logDiscovery,
|
||||
});
|
||||
bridge = bridgeRuntime.bridge;
|
||||
const bridgeHost = bridgeRuntime.bridgeHost;
|
||||
canvasHostServer = bridgeRuntime.canvasHostServer;
|
||||
const nodePresenceTimers = bridgeRuntime.nodePresenceTimers;
|
||||
bonjourStop = bridgeRuntime.bonjourStop;
|
||||
const bridgeSendToSession = bridgeRuntime.bridgeSendToSession;
|
||||
const bridgeSendToAllSubscribed = bridgeRuntime.bridgeSendToAllSubscribed;
|
||||
const broadcastVoiceWakeChanged = bridgeRuntime.broadcastVoiceWakeChanged;
|
||||
bonjourStop = discovery.bonjourStop;
|
||||
|
||||
setSkillsRemoteBridge(bridge);
|
||||
setSkillsRemoteRegistry(nodeRegistry);
|
||||
void primeRemoteSkillsCache();
|
||||
registerSkillsChangeListener(() => {
|
||||
const latest = loadConfig();
|
||||
@@ -328,7 +319,7 @@ export async function startGatewayServer(
|
||||
|
||||
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
|
||||
broadcast,
|
||||
bridgeSendToAllSubscribed,
|
||||
nodeSendToAllSubscribed,
|
||||
getPresenceVersion,
|
||||
getHealthVersion,
|
||||
refreshGatewayHealthSnapshot,
|
||||
@@ -340,13 +331,13 @@ export async function startGatewayServer(
|
||||
chatDeltaSentAt,
|
||||
removeChatRun,
|
||||
agentRunSeq,
|
||||
bridgeSendToSession,
|
||||
nodeSendToSession,
|
||||
});
|
||||
|
||||
const agentUnsub = onAgentEvent(
|
||||
createAgentEventHandler({
|
||||
broadcast,
|
||||
bridgeSendToSession,
|
||||
nodeSendToSession,
|
||||
agentRunSeq,
|
||||
chatRunState,
|
||||
resolveSessionKeyForRun,
|
||||
@@ -369,7 +360,7 @@ export async function startGatewayServer(
|
||||
wss,
|
||||
clients,
|
||||
port,
|
||||
bridgeHost: bridgeHost ?? undefined,
|
||||
gatewayHost: bindHost ?? undefined,
|
||||
canvasHostEnabled: Boolean(canvasHost),
|
||||
canvasHostServerPort: canvasHostServer?.port ?? undefined,
|
||||
resolvedAuth,
|
||||
@@ -395,9 +386,13 @@ export async function startGatewayServer(
|
||||
incrementPresenceVersion,
|
||||
getHealthVersion,
|
||||
broadcast,
|
||||
bridge,
|
||||
bridgeSendToSession,
|
||||
hasConnectedMobileNode,
|
||||
nodeSendToSession,
|
||||
nodeSendToAllSubscribed,
|
||||
nodeSubscribe,
|
||||
nodeUnsubscribe,
|
||||
nodeUnsubscribeAll,
|
||||
hasConnectedMobileNode: hasMobileNodeConnected,
|
||||
nodeRegistry,
|
||||
agentRunSeq,
|
||||
chatAbortControllers,
|
||||
chatAbortedRuns: chatRunState.abortedRuns,
|
||||
@@ -491,7 +486,6 @@ export async function startGatewayServer(
|
||||
tailscaleCleanup,
|
||||
canvasHost,
|
||||
canvasHostServer,
|
||||
bridge,
|
||||
stopChannel,
|
||||
pluginServices,
|
||||
cron,
|
||||
|
||||
@@ -2,10 +2,8 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { WebSocket } from "ws";
|
||||
import {
|
||||
bridgeListConnected,
|
||||
bridgeSendEvent,
|
||||
bridgeStartCalls,
|
||||
connectOk,
|
||||
installGatewayTestHooks,
|
||||
onceMessage,
|
||||
@@ -13,6 +11,7 @@ import {
|
||||
rpcReq,
|
||||
startServerWithClient,
|
||||
} from "./test-helpers.js";
|
||||
import { GATEWAY_CLIENT_MODES } from "../utils/message-channel.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
@@ -116,42 +115,50 @@ describe("gateway server models + voicewake", () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
|
||||
const restoreHome = setTempHome(homeDir);
|
||||
|
||||
bridgeSendEvent.mockClear();
|
||||
bridgeListConnected.mockReturnValue([{ nodeId: "n1" }]);
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
const { server, ws, port } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const startCall = bridgeStartCalls.at(-1);
|
||||
expect(startCall).toBeTruthy();
|
||||
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||
await new Promise<void>((resolve) => nodeWs.once("open", resolve));
|
||||
const firstEventP = onceMessage<{ type: "event"; event: string; payload?: unknown }>(
|
||||
nodeWs,
|
||||
(o) => o.type === "event" && o.event === "voicewake.changed",
|
||||
);
|
||||
await connectOk(nodeWs, {
|
||||
role: "node",
|
||||
client: {
|
||||
id: "n1",
|
||||
version: "1.0.0",
|
||||
platform: "ios",
|
||||
mode: GATEWAY_CLIENT_MODES.NODE,
|
||||
},
|
||||
});
|
||||
|
||||
await startCall?.onAuthenticated?.({ nodeId: "n1" });
|
||||
|
||||
const first = bridgeSendEvent.mock.calls.find(
|
||||
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
|
||||
)?.[0] as { payloadJSON?: string | null } | undefined;
|
||||
expect(first?.payloadJSON).toBeTruthy();
|
||||
const firstPayload = JSON.parse(String(first?.payloadJSON)) as {
|
||||
triggers?: unknown;
|
||||
};
|
||||
expect(firstPayload.triggers).toEqual(["clawd", "claude", "computer"]);
|
||||
|
||||
bridgeSendEvent.mockClear();
|
||||
const first = await firstEventP;
|
||||
expect(first.event).toBe("voicewake.changed");
|
||||
expect((first.payload as { triggers?: unknown } | undefined)?.triggers).toEqual([
|
||||
"clawd",
|
||||
"claude",
|
||||
"computer",
|
||||
]);
|
||||
|
||||
const broadcastP = onceMessage<{ type: "event"; event: string; payload?: unknown }>(
|
||||
nodeWs,
|
||||
(o) => o.type === "event" && o.event === "voicewake.changed",
|
||||
);
|
||||
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
|
||||
triggers: ["clawd", "computer"],
|
||||
});
|
||||
expect(setRes.ok).toBe(true);
|
||||
|
||||
const broadcast = bridgeSendEvent.mock.calls.find(
|
||||
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
|
||||
)?.[0] as { payloadJSON?: string | null } | undefined;
|
||||
expect(broadcast?.payloadJSON).toBeTruthy();
|
||||
const broadcastPayload = JSON.parse(String(broadcast?.payloadJSON)) as {
|
||||
triggers?: unknown;
|
||||
};
|
||||
expect(broadcastPayload.triggers).toEqual(["clawd", "computer"]);
|
||||
const broadcast = await broadcastP;
|
||||
expect(broadcast.event).toBe("voicewake.changed");
|
||||
expect((broadcast.payload as { triggers?: unknown } | undefined)?.triggers).toEqual([
|
||||
"clawd",
|
||||
"computer",
|
||||
]);
|
||||
|
||||
nodeWs.close();
|
||||
ws.close();
|
||||
await server.close();
|
||||
|
||||
@@ -254,36 +261,4 @@ describe("gateway server models + voicewake", () => {
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge RPC supports models.list and validates params", async () => {
|
||||
piSdkMock.enabled = true;
|
||||
piSdkMock.models = [{ id: "gpt-test-a", name: "A", provider: "openai" }];
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws);
|
||||
|
||||
const startCall = bridgeStartCalls.at(-1);
|
||||
expect(startCall).toBeTruthy();
|
||||
|
||||
const okRes = await startCall?.onRequest?.("n1", {
|
||||
id: "1",
|
||||
method: "models.list",
|
||||
paramsJSON: "{}",
|
||||
});
|
||||
expect(okRes?.ok).toBe(true);
|
||||
const okPayload = JSON.parse(String(okRes?.payloadJSON ?? "{}")) as {
|
||||
models?: unknown;
|
||||
};
|
||||
expect(Array.isArray(okPayload.models)).toBe(true);
|
||||
|
||||
const badRes = await startCall?.onRequest?.("n1", {
|
||||
id: "2",
|
||||
method: "models.list",
|
||||
paramsJSON: JSON.stringify({ extra: true }),
|
||||
});
|
||||
expect(badRes?.ok).toBe(false);
|
||||
expect(badRes && "error" in badRes ? badRes.error.code : "").toBe("INVALID_REQUEST");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -1,228 +0,0 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { emitAgentEvent } from "../infra/agent-events.js";
|
||||
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
|
||||
import {
|
||||
agentCommand,
|
||||
bridgeStartCalls,
|
||||
connectOk,
|
||||
getFreePort,
|
||||
installGatewayTestHooks,
|
||||
sessionStoreSaveDelayMs,
|
||||
startGatewayServer,
|
||||
startServerWithClient,
|
||||
testState,
|
||||
writeSessionStore,
|
||||
} from "./test-helpers.js";
|
||||
|
||||
const decodeWsData = (data: unknown): string => {
|
||||
if (typeof data === "string") return data;
|
||||
if (Buffer.isBuffer(data)) return data.toString("utf-8");
|
||||
if (Array.isArray(data)) return Buffer.concat(data).toString("utf-8");
|
||||
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf-8");
|
||||
if (ArrayBuffer.isView(data)) {
|
||||
return Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf-8");
|
||||
}
|
||||
return "";
|
||||
};
|
||||
|
||||
async function _waitFor(condition: () => boolean, timeoutMs = 1500) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (condition()) return;
|
||||
await new Promise((r) => setTimeout(r, 5));
|
||||
}
|
||||
throw new Error("timeout waiting for condition");
|
||||
}
|
||||
|
||||
installGatewayTestHooks();
|
||||
|
||||
describe("gateway server node/bridge", () => {
|
||||
test("bridge voice transcript defaults to main session", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
lastChannel: "whatsapp",
|
||||
lastTo: "+1555",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const bridgeCall = bridgeStartCalls.at(-1);
|
||||
expect(bridgeCall?.onEvent).toBeDefined();
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
const beforeCalls = spy.mock.calls.length;
|
||||
|
||||
await bridgeCall?.onEvent?.("ios-node", {
|
||||
event: "voice.transcript",
|
||||
payloadJSON: JSON.stringify({ text: "hello" }),
|
||||
});
|
||||
|
||||
expect(spy.mock.calls.length).toBe(beforeCalls + 1);
|
||||
const call = spy.mock.calls.at(-1)?.[0] as Record<string, unknown>;
|
||||
expect(call.sessionId).toBe("sess-main");
|
||||
expect(call.sessionKey).toBe("main");
|
||||
expect(call.deliver).toBe(false);
|
||||
expect(call.messageChannel).toBe("node");
|
||||
|
||||
const stored = JSON.parse(await fs.readFile(testState.sessionStorePath, "utf-8")) as Record<
|
||||
string,
|
||||
{ sessionId?: string } | undefined
|
||||
>;
|
||||
expect(stored["agent:main:main"]?.sessionId).toBe("sess-main");
|
||||
expect(stored["node-ios-node"]).toBeUndefined();
|
||||
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge voice transcript triggers chat events for webchat clients", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { server, ws } = await startServerWithClient();
|
||||
await connectOk(ws, {
|
||||
client: {
|
||||
id: GATEWAY_CLIENT_NAMES.WEBCHAT,
|
||||
version: "1.0.0",
|
||||
platform: "test",
|
||||
mode: GATEWAY_CLIENT_MODES.WEBCHAT,
|
||||
},
|
||||
});
|
||||
|
||||
const bridgeCall = bridgeStartCalls.at(-1);
|
||||
expect(bridgeCall?.onEvent).toBeDefined();
|
||||
|
||||
const isVoiceFinalChatEvent = (o: unknown) => {
|
||||
if (!o || typeof o !== "object") return false;
|
||||
const rec = o as Record<string, unknown>;
|
||||
if (rec.type !== "event" || rec.event !== "chat") return false;
|
||||
if (!rec.payload || typeof rec.payload !== "object") return false;
|
||||
const payload = rec.payload as Record<string, unknown>;
|
||||
const runId = typeof payload.runId === "string" ? payload.runId : "";
|
||||
const state = typeof payload.state === "string" ? payload.state : "";
|
||||
return runId.startsWith("voice-") && state === "final";
|
||||
};
|
||||
|
||||
const finalChatP = new Promise<{
|
||||
type: "event";
|
||||
event: string;
|
||||
payload?: unknown;
|
||||
}>((resolve) => {
|
||||
ws.on("message", (data) => {
|
||||
const obj = JSON.parse(decodeWsData(data));
|
||||
if (isVoiceFinalChatEvent(obj)) {
|
||||
resolve(obj as never);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
await bridgeCall?.onEvent?.("ios-node", {
|
||||
event: "voice.transcript",
|
||||
payloadJSON: JSON.stringify({ text: "hello", sessionKey: "main" }),
|
||||
});
|
||||
|
||||
emitAgentEvent({
|
||||
runId: "sess-main",
|
||||
seq: 1,
|
||||
ts: Date.now(),
|
||||
stream: "assistant",
|
||||
data: { text: "hi from agent" },
|
||||
});
|
||||
emitAgentEvent({
|
||||
runId: "sess-main",
|
||||
seq: 2,
|
||||
ts: Date.now(),
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
const evt = await finalChatP;
|
||||
const payload =
|
||||
evt.payload && typeof evt.payload === "object"
|
||||
? (evt.payload as Record<string, unknown>)
|
||||
: {};
|
||||
expect(payload.sessionKey).toBe("main");
|
||||
const message =
|
||||
payload.message && typeof payload.message === "object"
|
||||
? (payload.message as Record<string, unknown>)
|
||||
: {};
|
||||
expect(message.role).toBe("assistant");
|
||||
|
||||
ws.close();
|
||||
await server.close();
|
||||
});
|
||||
|
||||
test("bridge chat.abort cancels while saving the session store", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-"));
|
||||
testState.sessionStorePath = path.join(dir, "sessions.json");
|
||||
await writeSessionStore({
|
||||
entries: {
|
||||
main: {
|
||||
sessionId: "sess-main",
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
sessionStoreSaveDelayMs.value = 120;
|
||||
|
||||
const port = await getFreePort();
|
||||
const server = await startGatewayServer(port);
|
||||
const bridgeCall = bridgeStartCalls.at(-1);
|
||||
expect(bridgeCall?.onRequest).toBeDefined();
|
||||
|
||||
const spy = vi.mocked(agentCommand);
|
||||
spy.mockImplementationOnce(async (opts) => {
|
||||
const signal = (opts as { abortSignal?: AbortSignal }).abortSignal;
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!signal) return resolve();
|
||||
if (signal.aborted) return resolve();
|
||||
signal.addEventListener("abort", () => resolve(), { once: true });
|
||||
});
|
||||
});
|
||||
|
||||
const sendP = bridgeCall?.onRequest?.("ios-node", {
|
||||
id: "send-abort-save-bridge-1",
|
||||
method: "chat.send",
|
||||
paramsJSON: JSON.stringify({
|
||||
sessionKey: "main",
|
||||
message: "hello",
|
||||
idempotencyKey: "idem-abort-save-bridge-1",
|
||||
timeoutMs: 30_000,
|
||||
}),
|
||||
});
|
||||
|
||||
const abortRes = await bridgeCall?.onRequest?.("ios-node", {
|
||||
id: "abort-save-bridge-1",
|
||||
method: "chat.abort",
|
||||
paramsJSON: JSON.stringify({
|
||||
sessionKey: "main",
|
||||
runId: "idem-abort-save-bridge-1",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(abortRes?.ok).toBe(true);
|
||||
|
||||
const sendRes = await sendP;
|
||||
expect(sendRes?.ok).toBe(true);
|
||||
|
||||
await server.close();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,12 @@
|
||||
import type { BridgeTlsConfig } from "../../config/types.gateway.js";
|
||||
import type { GatewayTlsConfig } from "../../config/types.gateway.js";
|
||||
import {
|
||||
type BridgeTlsRuntime,
|
||||
loadBridgeTlsRuntime,
|
||||
} from "../../infra/bridge/server/tls.js";
|
||||
|
||||
export type GatewayTlsRuntime = BridgeTlsRuntime;
|
||||
type GatewayTlsRuntime,
|
||||
loadGatewayTlsRuntime as loadGatewayTlsRuntimeConfig,
|
||||
} from "../../infra/tls/gateway.js";
|
||||
|
||||
export async function loadGatewayTlsRuntime(
|
||||
cfg: BridgeTlsConfig | undefined,
|
||||
cfg: GatewayTlsConfig | undefined,
|
||||
log?: { info?: (msg: string) => void; warn?: (msg: string) => void },
|
||||
): Promise<GatewayTlsRuntime> {
|
||||
return await loadBridgeTlsRuntime(cfg, log);
|
||||
return await loadGatewayTlsRuntimeConfig(cfg, log);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
wss: WebSocketServer;
|
||||
clients: Set<GatewayWsClient>;
|
||||
port: number;
|
||||
bridgeHost?: string;
|
||||
gatewayHost?: string;
|
||||
canvasHostEnabled: boolean;
|
||||
canvasHostServerPort?: number;
|
||||
resolvedAuth: ResolvedGatewayAuth;
|
||||
@@ -46,7 +46,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
wss,
|
||||
clients,
|
||||
port,
|
||||
bridgeHost,
|
||||
gatewayHost,
|
||||
canvasHostEnabled,
|
||||
canvasHostServerPort,
|
||||
resolvedAuth,
|
||||
@@ -76,7 +76,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
|
||||
const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
|
||||
const canvasHostOverride =
|
||||
bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::" ? bridgeHost : undefined;
|
||||
gatewayHost && gatewayHost !== "0.0.0.0" && gatewayHost !== "::" ? gatewayHost : undefined;
|
||||
const canvasHostUrl = resolveCanvasHostUrl({
|
||||
canvasPort: canvasHostPortForWs,
|
||||
hostOverride: canvasHostServerPort ? canvasHostOverride : undefined,
|
||||
@@ -182,6 +182,13 @@ export function attachGatewayWsConnectionHandler(params: {
|
||||
},
|
||||
);
|
||||
}
|
||||
if (client?.connect?.role === "node") {
|
||||
const context = buildRequestContext();
|
||||
const nodeId = context.nodeRegistry.unregister(connId);
|
||||
if (nodeId) {
|
||||
context.nodeUnsubscribeAll(nodeId);
|
||||
}
|
||||
}
|
||||
logWs("out", "close", {
|
||||
connId,
|
||||
code,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -13,34 +13,6 @@ import type { PluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js";
|
||||
|
||||
export type BridgeClientInfo = {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
platform?: string;
|
||||
version?: string;
|
||||
remoteIp?: string;
|
||||
deviceFamily?: string;
|
||||
modelIdentifier?: string;
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
};
|
||||
|
||||
export type BridgeStartOpts = {
|
||||
onAuthenticated?: (node: BridgeClientInfo) => Promise<void> | void;
|
||||
onDisconnected?: (node: BridgeClientInfo) => Promise<void> | void;
|
||||
onPairRequested?: (request: unknown) => Promise<void> | void;
|
||||
onEvent?: (
|
||||
nodeId: string,
|
||||
evt: { event: string; payloadJSON?: string | null },
|
||||
) => Promise<void> | void;
|
||||
onRequest?: (
|
||||
nodeId: string,
|
||||
req: { id: string; method: string; paramsJSON?: string | null },
|
||||
) => Promise<
|
||||
| { ok: true; payloadJSON?: string | null }
|
||||
| { ok: false; error: { code: string; message: string; details?: unknown } }
|
||||
>;
|
||||
};
|
||||
|
||||
type StubChannelOptions = {
|
||||
id: ChannelPlugin["id"];
|
||||
@@ -173,16 +145,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
||||
});
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
bridgeStartCalls: [] as BridgeStartOpts[],
|
||||
bridgeInvoke: vi.fn(async () => ({
|
||||
type: "invoke-res",
|
||||
id: "1",
|
||||
ok: true,
|
||||
payloadJSON: JSON.stringify({ ok: true }),
|
||||
error: null,
|
||||
})),
|
||||
bridgeListConnected: vi.fn(() => [] as BridgeClientInfo[]),
|
||||
bridgeSendEvent: vi.fn(),
|
||||
testTailnetIPv4: { value: undefined as string | undefined },
|
||||
piSdkMock: {
|
||||
enabled: false,
|
||||
@@ -232,10 +194,6 @@ export const setTestConfigRoot = (root: string) => {
|
||||
process.env.CLAWDBOT_CONFIG_PATH = path.join(root, "clawdbot.json");
|
||||
};
|
||||
|
||||
export const bridgeStartCalls = hoisted.bridgeStartCalls;
|
||||
export const bridgeInvoke = hoisted.bridgeInvoke;
|
||||
export const bridgeListConnected = hoisted.bridgeListConnected;
|
||||
export const bridgeSendEvent = hoisted.bridgeSendEvent;
|
||||
export const testTailnetIPv4 = hoisted.testTailnetIPv4;
|
||||
export const piSdkMock = hoisted.piSdkMock;
|
||||
export const cronIsolatedRun = hoisted.cronIsolatedRun;
|
||||
@@ -282,19 +240,6 @@ vi.mock("@mariozechner/pi-coding-agent", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/bridge/server.js", () => ({
|
||||
startNodeBridgeServer: vi.fn(async (opts: BridgeStartOpts) => {
|
||||
bridgeStartCalls.push(opts);
|
||||
return {
|
||||
port: 18790,
|
||||
close: async () => {},
|
||||
listConnected: bridgeListConnected,
|
||||
invoke: bridgeInvoke,
|
||||
sendEvent: bridgeSendEvent,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../cron/isolated-agent.js", () => ({
|
||||
runCronIsolatedAgentTurn: (...args: unknown[]) =>
|
||||
(cronIsolatedRun as (...args: unknown[]) => unknown)(...args),
|
||||
|
||||
@@ -247,6 +247,11 @@ export async function connectReq(
|
||||
modelIdentifier?: string;
|
||||
instanceId?: string;
|
||||
};
|
||||
role?: string;
|
||||
scopes?: string[];
|
||||
caps?: string[];
|
||||
commands?: string[];
|
||||
permissions?: Record<string, boolean>;
|
||||
},
|
||||
): Promise<ConnectResponse> {
|
||||
const { randomUUID } = await import("node:crypto");
|
||||
@@ -265,7 +270,11 @@ export async function connectReq(
|
||||
platform: "test",
|
||||
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||
},
|
||||
caps: [],
|
||||
caps: opts?.caps ?? [],
|
||||
commands: opts?.commands ?? [],
|
||||
permissions: opts?.permissions ?? undefined,
|
||||
role: opts?.role,
|
||||
scopes: opts?.scopes,
|
||||
auth:
|
||||
opts?.token || opts?.password
|
||||
? {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,11 +9,10 @@ export type GatewayBonjourBeacon = {
|
||||
port?: number;
|
||||
lanHost?: string;
|
||||
tailnetDns?: string;
|
||||
bridgePort?: number;
|
||||
gatewayPort?: number;
|
||||
sshPort?: number;
|
||||
bridgeTls?: boolean;
|
||||
bridgeTlsFingerprintSha256?: string;
|
||||
gatewayTls?: boolean;
|
||||
gatewayTlsFingerprintSha256?: string;
|
||||
cliPath?: string;
|
||||
txt?: Record<string, string>;
|
||||
};
|
||||
@@ -165,9 +164,9 @@ function parseDnsSdBrowse(stdout: string): string[] {
|
||||
const instances = new Set<string>();
|
||||
for (const raw of stdout.split("\n")) {
|
||||
const line = raw.trim();
|
||||
if (!line || !line.includes("_clawdbot-bridge._tcp")) continue;
|
||||
if (!line || !line.includes("_clawdbot-gateway._tcp")) continue;
|
||||
if (!line.includes("Add")) continue;
|
||||
const match = line.match(/_clawdbot-bridge\._tcp\.?\s+(.+)$/);
|
||||
const match = line.match(/_clawdbot-gateway\._tcp\.?\s+(.+)$/);
|
||||
if (match?.[1]) {
|
||||
instances.add(decodeDnsSdEscapes(match[1].trim()));
|
||||
}
|
||||
@@ -205,14 +204,13 @@ function parseDnsSdResolve(stdout: string, instanceName: string): GatewayBonjour
|
||||
if (txt.lanHost) beacon.lanHost = txt.lanHost;
|
||||
if (txt.tailnetDns) beacon.tailnetDns = txt.tailnetDns;
|
||||
if (txt.cliPath) beacon.cliPath = txt.cliPath;
|
||||
beacon.bridgePort = parseIntOrNull(txt.bridgePort);
|
||||
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
||||
beacon.sshPort = parseIntOrNull(txt.sshPort);
|
||||
if (txt.bridgeTls) {
|
||||
const raw = txt.bridgeTls.trim().toLowerCase();
|
||||
beacon.bridgeTls = raw === "1" || raw === "true" || raw === "yes";
|
||||
if (txt.gatewayTls) {
|
||||
const raw = txt.gatewayTls.trim().toLowerCase();
|
||||
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
|
||||
}
|
||||
if (txt.bridgeTlsSha256) beacon.bridgeTlsFingerprintSha256 = txt.bridgeTlsSha256;
|
||||
if (txt.gatewayTlsSha256) beacon.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256;
|
||||
|
||||
if (!beacon.displayName) beacon.displayName = decodedInstanceName;
|
||||
return beacon;
|
||||
@@ -223,13 +221,13 @@ async function discoverViaDnsSd(
|
||||
timeoutMs: number,
|
||||
run: typeof runCommandWithTimeout,
|
||||
): Promise<GatewayBonjourBeacon[]> {
|
||||
const browse = await run(["dns-sd", "-B", "_clawdbot-bridge._tcp", domain], {
|
||||
const browse = await run(["dns-sd", "-B", "_clawdbot-gateway._tcp", domain], {
|
||||
timeoutMs,
|
||||
});
|
||||
const instances = parseDnsSdBrowse(browse.stdout);
|
||||
const results: GatewayBonjourBeacon[] = [];
|
||||
for (const instance of instances) {
|
||||
const resolved = await run(["dns-sd", "-L", instance, "_clawdbot-bridge._tcp", domain], {
|
||||
const resolved = await run(["dns-sd", "-L", instance, "_clawdbot-gateway._tcp", domain], {
|
||||
timeoutMs,
|
||||
});
|
||||
const parsed = parseDnsSdResolve(resolved.stdout, instance);
|
||||
@@ -266,7 +264,7 @@ async function discoverWideAreaViaTailnetDns(
|
||||
// Keep scans bounded: this is a fallback and should not block long.
|
||||
ips = ips.slice(0, 40);
|
||||
|
||||
const probeName = `_clawdbot-bridge._tcp.${domain.replace(/\.$/, "")}`;
|
||||
const probeName = `_clawdbot-gateway._tcp.${domain.replace(/\.$/, "")}`;
|
||||
|
||||
const concurrency = 6;
|
||||
let nextIndex = 0;
|
||||
@@ -310,7 +308,7 @@ async function discoverWideAreaViaTailnetDns(
|
||||
if (budget <= 0) break;
|
||||
const ptrName = ptr.trim().replace(/\.$/, "");
|
||||
if (!ptrName) continue;
|
||||
const instanceName = ptrName.replace(/\.?_clawdbot-bridge\._tcp\..*$/, "");
|
||||
const instanceName = ptrName.replace(/\.?_clawdbot-gateway\._tcp\..*$/, "");
|
||||
|
||||
const srv = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "SRV"], {
|
||||
timeoutMs: Math.max(1, Math.min(350, budget)),
|
||||
@@ -343,12 +341,16 @@ async function discoverWideAreaViaTailnetDns(
|
||||
host: srvParsed.host,
|
||||
port: srvParsed.port,
|
||||
txt: Object.keys(txtMap).length ? txtMap : undefined,
|
||||
bridgePort: parseIntOrNull(txtMap.bridgePort),
|
||||
gatewayPort: parseIntOrNull(txtMap.gatewayPort),
|
||||
sshPort: parseIntOrNull(txtMap.sshPort),
|
||||
tailnetDns: txtMap.tailnetDns || undefined,
|
||||
cliPath: txtMap.cliPath || undefined,
|
||||
};
|
||||
if (txtMap.gatewayTls) {
|
||||
const raw = txtMap.gatewayTls.trim().toLowerCase();
|
||||
beacon.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
|
||||
}
|
||||
if (txtMap.gatewayTlsSha256) beacon.gatewayTlsFingerprintSha256 = txtMap.gatewayTlsSha256;
|
||||
|
||||
results.push(beacon);
|
||||
}
|
||||
@@ -363,9 +365,9 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
|
||||
for (const raw of stdout.split("\n")) {
|
||||
const line = raw.trimEnd();
|
||||
if (!line) continue;
|
||||
if (line.startsWith("=") && line.includes("_clawdbot-bridge._tcp")) {
|
||||
if (line.startsWith("=") && line.includes("_clawdbot-gateway._tcp")) {
|
||||
if (current) results.push(current);
|
||||
const marker = " _clawdbot-bridge._tcp";
|
||||
const marker = " _clawdbot-gateway._tcp";
|
||||
const idx = line.indexOf(marker);
|
||||
const left = idx >= 0 ? line.slice(0, idx).trim() : line;
|
||||
const parts = left.split(/\s+/);
|
||||
@@ -400,9 +402,13 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
|
||||
if (txt.lanHost) current.lanHost = txt.lanHost;
|
||||
if (txt.tailnetDns) current.tailnetDns = txt.tailnetDns;
|
||||
if (txt.cliPath) current.cliPath = txt.cliPath;
|
||||
current.bridgePort = parseIntOrNull(txt.bridgePort);
|
||||
current.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
||||
current.sshPort = parseIntOrNull(txt.sshPort);
|
||||
if (txt.gatewayTls) {
|
||||
const raw = txt.gatewayTls.trim().toLowerCase();
|
||||
current.gatewayTls = raw === "1" || raw === "true" || raw === "yes";
|
||||
}
|
||||
if (txt.gatewayTlsSha256) current.gatewayTlsFingerprintSha256 = txt.gatewayTlsSha256;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -415,7 +421,7 @@ async function discoverViaAvahi(
|
||||
timeoutMs: number,
|
||||
run: typeof runCommandWithTimeout,
|
||||
): Promise<GatewayBonjourBeacon[]> {
|
||||
const args = ["avahi-browse", "-rt", "_clawdbot-bridge._tcp"];
|
||||
const args = ["avahi-browse", "-rt", "_clawdbot-gateway._tcp"];
|
||||
if (domain && domain !== "local.") {
|
||||
// avahi-browse wants a plain domain (no trailing dot)
|
||||
args.push("-d", domain.replace(/\.$/, ""));
|
||||
|
||||
@@ -110,24 +110,23 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
tailnetDns: "host.tailnet.ts.net",
|
||||
cliPath: "/opt/homebrew/bin/clawdbot",
|
||||
});
|
||||
|
||||
expect(createService).toHaveBeenCalledTimes(1);
|
||||
const [bridgeCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
|
||||
expect(bridgeCall?.[0]?.type).toBe("clawdbot-bridge");
|
||||
expect(bridgeCall?.[0]?.port).toBe(18790);
|
||||
expect(bridgeCall?.[0]?.domain).toBe("local");
|
||||
expect(bridgeCall?.[0]?.hostname).toBe("test-host");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe("18790");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
|
||||
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
|
||||
expect(gatewayCall?.[0]?.type).toBe("clawdbot-gateway");
|
||||
expect(gatewayCall?.[0]?.port).toBe(18789);
|
||||
expect(gatewayCall?.[0]?.domain).toBe("local");
|
||||
expect(gatewayCall?.[0]?.hostname).toBe("test-host");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayPort).toBe("18789");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
|
||||
"/opt/homebrew/bin/clawdbot",
|
||||
);
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.transport).toBe("bridge");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.transport).toBe("gateway");
|
||||
|
||||
// We don't await `advertise()`, but it should still be called for each service.
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
@@ -166,7 +165,6 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
});
|
||||
|
||||
// 1 service × 2 listeners
|
||||
@@ -209,7 +207,6 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
});
|
||||
|
||||
await started.stop();
|
||||
@@ -248,7 +245,6 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
});
|
||||
|
||||
// initial advertise attempt happens immediately
|
||||
@@ -295,7 +291,6 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
});
|
||||
|
||||
expect(advertise).toHaveBeenCalledTimes(1);
|
||||
@@ -328,14 +323,13 @@ describe("gateway bonjour advertiser", () => {
|
||||
const started = await startGatewayBonjourAdvertiser({
|
||||
gatewayPort: 18789,
|
||||
sshPort: 2222,
|
||||
bridgePort: 18790,
|
||||
});
|
||||
|
||||
const [bridgeCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||
expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdbot)");
|
||||
expect(bridgeCall?.[0]?.domain).toBe("local");
|
||||
expect(bridgeCall?.[0]?.hostname).toBe("Mac");
|
||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
|
||||
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||
expect(gatewayCall?.[0]?.name).toBe("Mac (Clawdbot)");
|
||||
expect(gatewayCall?.[0]?.domain).toBe("local");
|
||||
expect(gatewayCall?.[0]?.hostname).toBe("Mac");
|
||||
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
|
||||
|
||||
await started.stop();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user