refactor: remove bridge protocol
This commit is contained in:
parent
b347d5d9cc
commit
2f8206862a
@ -53,11 +53,11 @@ final class BridgeDiscoveryModel {
|
|||||||
if !self.browsers.isEmpty { return }
|
if !self.browsers.isEmpty { return }
|
||||||
self.appendDebugLog("start()")
|
self.appendDebugLog("start()")
|
||||||
|
|
||||||
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
for domain in ClawdbotBonjour.gatewayServiceDomains {
|
||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.includePeerToPeer = true
|
params.includePeerToPeer = true
|
||||||
let browser = NWBrowser(
|
let browser = NWBrowser(
|
||||||
for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
|
for: .bonjour(type: ClawdbotBonjour.gatewayServiceType, domain: domain),
|
||||||
using: params)
|
using: params)
|
||||||
|
|
||||||
browser.stateUpdateHandler = { [weak self] state in
|
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
|
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.
|
// Avoid ambiguity with the app's own AnyCodable type.
|
||||||
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
private typealias ProtoAnyCodable = ClawdbotProtocol.AnyCodable
|
||||||
|
|
||||||
@ -81,19 +92,25 @@ actor GatewayChannelActor {
|
|||||||
private var tickTask: Task<Void, Never>?
|
private var tickTask: Task<Void, Never>?
|
||||||
private let defaultRequestTimeoutMs: Double = 15000
|
private let defaultRequestTimeoutMs: Double = 15000
|
||||||
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
|
private let pushHandler: (@Sendable (GatewayPush) async -> Void)?
|
||||||
|
private let connectOptions: GatewayConnectOptions?
|
||||||
|
private let disconnectHandler: (@Sendable (String) async -> Void)?
|
||||||
|
|
||||||
init(
|
init(
|
||||||
url: URL,
|
url: URL,
|
||||||
token: String?,
|
token: String?,
|
||||||
password: String? = nil,
|
password: String? = nil,
|
||||||
session: WebSocketSessionBox? = 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.url = url
|
||||||
self.token = token
|
self.token = token
|
||||||
self.password = password
|
self.password = password
|
||||||
self.session = session?.session ?? URLSession(configuration: .default)
|
self.session = session?.session ?? URLSession(configuration: .default)
|
||||||
self.pushHandler = pushHandler
|
self.pushHandler = pushHandler
|
||||||
|
self.connectOptions = connectOptions
|
||||||
|
self.disconnectHandler = disconnectHandler
|
||||||
Task { [weak self] in
|
Task { [weak self] in
|
||||||
await self?.startWatchdog()
|
await self?.startWatchdog()
|
||||||
}
|
}
|
||||||
@ -178,6 +195,7 @@ actor GatewayChannelActor {
|
|||||||
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
let wrapped = self.wrap(error, context: "connect to gateway @ \(self.url.absoluteString)")
|
||||||
self.connected = false
|
self.connected = false
|
||||||
self.task?.cancel(with: .goingAway, reason: nil)
|
self.task?.cancel(with: .goingAway, reason: nil)
|
||||||
|
await self.disconnectHandler?("connect failed: \(wrapped.localizedDescription)")
|
||||||
let waiters = self.connectWaiters
|
let waiters = self.connectWaiters
|
||||||
self.connectWaiters.removeAll()
|
self.connectWaiters.removeAll()
|
||||||
for waiter in waiters {
|
for waiter in waiters {
|
||||||
@ -202,9 +220,18 @@ actor GatewayChannelActor {
|
|||||||
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||||
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier
|
||||||
let clientDisplayName = InstanceIdentity.displayName
|
let options = self.connectOptions ?? GatewayConnectOptions(
|
||||||
let clientId = "clawdbot-macos"
|
role: "operator",
|
||||||
let clientMode = "ui"
|
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
|
let reqId = UUID().uuidString
|
||||||
var client: [String: ProtoAnyCodable] = [
|
var client: [String: ProtoAnyCodable] = [
|
||||||
@ -224,12 +251,18 @@ actor GatewayChannelActor {
|
|||||||
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||||
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
|
||||||
"client": ProtoAnyCodable(client),
|
"client": ProtoAnyCodable(client),
|
||||||
"caps": ProtoAnyCodable([] as [String]),
|
"caps": ProtoAnyCodable(options.caps),
|
||||||
"locale": ProtoAnyCodable(primaryLocale),
|
"locale": ProtoAnyCodable(primaryLocale),
|
||||||
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
"userAgent": ProtoAnyCodable(ProcessInfo.processInfo.operatingSystemVersionString),
|
||||||
"role": ProtoAnyCodable("operator"),
|
"role": ProtoAnyCodable(options.role),
|
||||||
"scopes": ProtoAnyCodable(["operator.admin", "operator.approvals", "operator.pairing"]),
|
"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 {
|
if let token = self.token {
|
||||||
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
params["auth"] = ProtoAnyCodable(["token": ProtoAnyCodable(token)])
|
||||||
} else if let password = self.password {
|
} else if let password = self.password {
|
||||||
@ -237,13 +270,13 @@ actor GatewayChannelActor {
|
|||||||
}
|
}
|
||||||
let identity = DeviceIdentityStore.loadOrCreate()
|
let identity = DeviceIdentityStore.loadOrCreate()
|
||||||
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
let signedAtMs = Int(Date().timeIntervalSince1970 * 1000)
|
||||||
let scopes = "operator.admin,operator.approvals,operator.pairing"
|
let scopes = options.scopes.joined(separator: ",")
|
||||||
let payload = [
|
let payload = [
|
||||||
"v1",
|
"v1",
|
||||||
identity.deviceId,
|
identity.deviceId,
|
||||||
clientId,
|
clientId,
|
||||||
clientMode,
|
clientMode,
|
||||||
"operator",
|
options.role,
|
||||||
scopes,
|
scopes,
|
||||||
String(signedAtMs),
|
String(signedAtMs),
|
||||||
self.token ?? "",
|
self.token ?? "",
|
||||||
@ -344,6 +377,7 @@ actor GatewayChannelActor {
|
|||||||
let wrapped = self.wrap(err, context: "gateway receive")
|
let wrapped = self.wrap(err, context: "gateway receive")
|
||||||
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
|
self.logger.error("gateway ws receive failed \(wrapped.localizedDescription, privacy: .public)")
|
||||||
self.connected = false
|
self.connected = false
|
||||||
|
await self.disconnectHandler?("receive failed: \(wrapped.localizedDescription)")
|
||||||
await self.failPending(wrapped)
|
await self.failPending(wrapped)
|
||||||
await self.scheduleReconnect()
|
await self.scheduleReconnect()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,7 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.discovery.gateways.isEmpty {
|
if self.discovery.gateways.isEmpty {
|
||||||
Text("No bridges found yet.")
|
Text("No gateways found yet.")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
@ -40,7 +40,7 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.font(.callout.weight(.semibold))
|
.font(.callout.weight(.semibold))
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
Text(target ?? "Bridge pairing only")
|
Text(target ?? "Gateway pairing only")
|
||||||
.font(.caption.monospaced())
|
.font(.caption.monospaced())
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
@ -83,7 +83,7 @@ struct GatewayDiscoveryInlineList: View {
|
|||||||
.fill(Color(NSColor.controlBackgroundColor)))
|
.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? {
|
private func suggestedSSHTarget(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> String? {
|
||||||
@ -130,6 +130,6 @@ struct GatewayDiscoveryMenu: View {
|
|||||||
} label: {
|
} label: {
|
||||||
Image(systemName: "dot.radiowaves.left.and.right")
|
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
|
import Foundation
|
||||||
|
|
||||||
enum BridgeDiscoveryPreferences {
|
enum GatewayDiscoveryPreferences {
|
||||||
private static let preferredStableIDKey = "bridge.preferredStableID"
|
private static let preferredStableIDKey = "gateway.preferredStableID"
|
||||||
|
private static let legacyPreferredStableIDKey = "bridge.preferredStableID"
|
||||||
|
|
||||||
static func preferredStableID() -> String? {
|
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)
|
let trimmed = raw?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
return trimmed?.isEmpty == false ? trimmed : nil
|
return trimmed?.isEmpty == false ? trimmed : nil
|
||||||
}
|
}
|
||||||
@ -13,8 +16,10 @@ enum BridgeDiscoveryPreferences {
|
|||||||
let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = stableID?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if let trimmed, !trimmed.isEmpty {
|
if let trimmed, !trimmed.isEmpty {
|
||||||
UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
|
UserDefaults.standard.set(trimmed, forKey: self.preferredStableIDKey)
|
||||||
|
UserDefaults.standard.removeObject(forKey: self.legacyPreferredStableIDKey)
|
||||||
} else {
|
} else {
|
||||||
UserDefaults.standard.removeObject(forKey: self.preferredStableIDKey)
|
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.
|
/// - The endpoint store owns observation + explicit "ensure tunnel" actions.
|
||||||
actor GatewayEndpointStore {
|
actor GatewayEndpointStore {
|
||||||
static let shared = 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 remoteConnectingDetail = "Connecting to remote gateway…"
|
||||||
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||||
private enum EnvOverrideWarningKind: Sendable {
|
private enum EnvOverrideWarningKind: Sendable {
|
||||||
@ -60,9 +66,11 @@ actor GatewayEndpointStore {
|
|||||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
root: root,
|
root: root,
|
||||||
env: ProcessInfo.processInfo.environment)
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: root)
|
||||||
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
let tailscaleIP = await MainActor.run { TailscaleService.shared.tailscaleIP }
|
||||||
return GatewayEndpointStore.resolveLocalGatewayHost(
|
return GatewayEndpointStore.resolveLocalGatewayHost(
|
||||||
bindMode: bind,
|
bindMode: bind,
|
||||||
|
customBindHost: customBindHost,
|
||||||
tailscaleIP: tailscaleIP)
|
tailscaleIP: tailscaleIP)
|
||||||
},
|
},
|
||||||
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
remotePortIfRunning: { await RemoteTunnelManager.shared.controlTunnelPortIfRunning() },
|
||||||
@ -250,10 +258,14 @@ actor GatewayEndpointStore {
|
|||||||
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
let bind = GatewayEndpointStore.resolveGatewayBindMode(
|
||||||
root: ClawdbotConfigFile.loadDict(),
|
root: ClawdbotConfigFile.loadDict(),
|
||||||
env: ProcessInfo.processInfo.environment)
|
env: ProcessInfo.processInfo.environment)
|
||||||
|
let customBindHost = GatewayEndpointStore.resolveGatewayCustomBindHost(root: ClawdbotConfigFile.loadDict())
|
||||||
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
let scheme = GatewayEndpointStore.resolveGatewayScheme(
|
||||||
root: ClawdbotConfigFile.loadDict(),
|
root: ClawdbotConfigFile.loadDict(),
|
||||||
env: ProcessInfo.processInfo.environment)
|
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 token = deps.token()
|
||||||
let password = deps.password()
|
let password = deps.password()
|
||||||
switch initialMode {
|
switch initialMode {
|
||||||
@ -417,7 +429,10 @@ actor GatewayEndpointStore {
|
|||||||
|
|
||||||
let token = self.deps.token()
|
let token = self.deps.token()
|
||||||
let password = self.deps.password()
|
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))
|
self.setState(.ready(mode: .remote, url: url, token: token, password: password))
|
||||||
return (url, token, password)
|
return (url, token, password)
|
||||||
} catch let err as CancellationError {
|
} catch let err as CancellationError {
|
||||||
@ -487,6 +502,16 @@ actor GatewayEndpointStore {
|
|||||||
return nil
|
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(
|
private static func resolveGatewayScheme(
|
||||||
root: [String: Any],
|
root: [String: Any],
|
||||||
env: [String: String]) -> String
|
env: [String: String]) -> String
|
||||||
@ -507,11 +532,14 @@ actor GatewayEndpointStore {
|
|||||||
|
|
||||||
private static func resolveLocalGatewayHost(
|
private static func resolveLocalGatewayHost(
|
||||||
bindMode: String?,
|
bindMode: String?,
|
||||||
|
customBindHost: String?,
|
||||||
tailscaleIP: String?) -> String
|
tailscaleIP: String?) -> String
|
||||||
{
|
{
|
||||||
switch bindMode {
|
switch bindMode {
|
||||||
case "tailnet", "auto":
|
case "tailnet", "auto":
|
||||||
tailscaleIP ?? "127.0.0.1"
|
tailscaleIP ?? "127.0.0.1"
|
||||||
|
case "custom":
|
||||||
|
customBindHost ?? "127.0.0.1"
|
||||||
default:
|
default:
|
||||||
"127.0.0.1"
|
"127.0.0.1"
|
||||||
}
|
}
|
||||||
@ -586,7 +614,10 @@ extension GatewayEndpointStore {
|
|||||||
bindMode: String?,
|
bindMode: String?,
|
||||||
tailscaleIP: String?) -> String
|
tailscaleIP: String?) -> String
|
||||||
{
|
{
|
||||||
self.resolveLocalGatewayHost(bindMode: bindMode, tailscaleIP: tailscaleIP)
|
self.resolveLocalGatewayHost(
|
||||||
|
bindMode: bindMode,
|
||||||
|
customBindHost: nil,
|
||||||
|
tailscaleIP: tailscaleIP)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
@ -716,7 +716,7 @@ extension GeneralSettings {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
private func applyDiscoveredGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||||
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
|
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
||||||
|
|
||||||
let host = gateway.tailnetDns ?? gateway.lanHost
|
let host = gateway.tailnetDns ?? gateway.lanHost
|
||||||
guard let host else { return }
|
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 ClawdbotKit
|
||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
|
||||||
import OSLog
|
import OSLog
|
||||||
|
|
||||||
private struct BridgeTarget {
|
|
||||||
let endpoint: NWEndpoint
|
|
||||||
let stableID: String
|
|
||||||
let tls: MacNodeBridgeTLSParams?
|
|
||||||
}
|
|
||||||
|
|
||||||
@MainActor
|
@MainActor
|
||||||
final class MacNodeModeCoordinator {
|
final class MacNodeModeCoordinator {
|
||||||
static let shared = MacNodeModeCoordinator()
|
static let shared = MacNodeModeCoordinator()
|
||||||
@ -17,8 +9,7 @@ final class MacNodeModeCoordinator {
|
|||||||
private let logger = Logger(subsystem: "com.clawdbot", category: "mac-node")
|
private let logger = Logger(subsystem: "com.clawdbot", category: "mac-node")
|
||||||
private var task: Task<Void, Never>?
|
private var task: Task<Void, Never>?
|
||||||
private let runtime = MacNodeRuntime()
|
private let runtime = MacNodeRuntime()
|
||||||
private let session = MacNodeBridgeSession()
|
private let session = MacNodeGatewaySession()
|
||||||
private var tunnel: RemotePortTunnel?
|
|
||||||
|
|
||||||
func start() {
|
func start() {
|
||||||
guard self.task == nil else { return }
|
guard self.task == nil else { return }
|
||||||
@ -31,12 +22,10 @@ final class MacNodeModeCoordinator {
|
|||||||
self.task?.cancel()
|
self.task?.cancel()
|
||||||
self.task = nil
|
self.task = nil
|
||||||
Task { await self.session.disconnect() }
|
Task { await self.session.disconnect() }
|
||||||
self.tunnel?.terminate()
|
|
||||||
self.tunnel = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func setPreferredBridgeStableID(_ stableID: String?) {
|
func setPreferredGatewayStableID(_ stableID: String?) {
|
||||||
BridgeDiscoveryPreferences.setPreferredStableID(stableID)
|
GatewayDiscoveryPreferences.setPreferredStableID(stableID)
|
||||||
Task { await self.session.disconnect() }
|
Task { await self.session.disconnect() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -44,6 +33,7 @@ final class MacNodeModeCoordinator {
|
|||||||
var retryDelay: UInt64 = 1_000_000_000
|
var retryDelay: UInt64 = 1_000_000_000
|
||||||
var lastCameraEnabled: Bool?
|
var lastCameraEnabled: Bool?
|
||||||
let defaults = UserDefaults.standard
|
let defaults = UserDefaults.standard
|
||||||
|
|
||||||
while !Task.isCancelled {
|
while !Task.isCancelled {
|
||||||
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
if await MainActor.run(body: { AppStateStore.shared.isPaused }) {
|
||||||
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
@ -59,34 +49,42 @@ final class MacNodeModeCoordinator {
|
|||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
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 {
|
do {
|
||||||
let hello = await self.makeHello()
|
let config = try await GatewayEndpointStore.shared.requireConfig()
|
||||||
self.logger.info(
|
let caps = self.currentCaps()
|
||||||
"mac node bridge connecting endpoint=\(target.endpoint, privacy: .public)")
|
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(
|
try await self.session.connect(
|
||||||
endpoint: target.endpoint,
|
url: config.url,
|
||||||
hello: hello,
|
token: config.token,
|
||||||
tls: target.tls,
|
password: config.password,
|
||||||
onConnected: { [weak self] serverName, mainSessionKey in
|
connectOptions: connectOptions,
|
||||||
self?.logger.info("mac node connected to \(serverName, privacy: .public)")
|
sessionBox: sessionBox,
|
||||||
if let mainSessionKey {
|
onConnected: { [weak self] in
|
||||||
await self?.runtime.updateMainSessionKey(mainSessionKey)
|
guard let self else { return }
|
||||||
}
|
self.logger.info("mac node connected to gateway")
|
||||||
await self?.runtime.setEventSender { [weak self] event, payload in
|
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 }
|
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
|
onDisconnected: { [weak self] reason in
|
||||||
await self?.runtime.setEventSender(nil)
|
guard let self else { return }
|
||||||
await MacNodeModeCoordinator.handleBridgeDisconnect(reason: reason)
|
await self.runtime.setEventSender(nil)
|
||||||
|
self.logger.error("mac node disconnected: \(reason, privacy: .public)")
|
||||||
},
|
},
|
||||||
onInvoke: { [weak self] req in
|
onInvoke: { [weak self] req in
|
||||||
guard let self else {
|
guard let self else {
|
||||||
@ -97,43 +95,17 @@ final class MacNodeModeCoordinator {
|
|||||||
}
|
}
|
||||||
return await self.runtime.handleInvoke(req)
|
return await self.runtime.handleInvoke(req)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
retryDelay = 1_000_000_000
|
||||||
|
try? await Task.sleep(nanoseconds: 1_000_000_000)
|
||||||
} catch {
|
} catch {
|
||||||
if await self.tryPair(target: target, error: error) {
|
self.logger.error("mac node gateway connect failed: \(error.localizedDescription, privacy: .public)")
|
||||||
continue
|
try? await Task.sleep(nanoseconds: min(retryDelay, 10_000_000_000))
|
||||||
}
|
|
||||||
self.logger.error(
|
|
||||||
"mac node bridge connect failed: \(error.localizedDescription, privacy: .public)")
|
|
||||||
try? await Task.sleep(nanoseconds: min(retryDelay, 5_000_000_000))
|
|
||||||
retryDelay = min(retryDelay * 2, 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] {
|
private func currentCaps() -> [String] {
|
||||||
var caps: [String] = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
|
var caps: [String] = [ClawdbotCapability.canvas.rawValue, ClawdbotCapability.screen.rawValue]
|
||||||
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
|
if UserDefaults.standard.object(forKey: cameraEnabledKey) as? Bool ?? false {
|
||||||
@ -182,370 +154,18 @@ final class MacNodeModeCoordinator {
|
|||||||
return commands
|
return commands
|
||||||
}
|
}
|
||||||
|
|
||||||
private func tryPair(target: BridgeTarget, error: Error) async -> Bool {
|
private func buildSessionBox(url: URL) -> WebSocketSessionBox? {
|
||||||
let text = error.localizedDescription.uppercased()
|
guard url.scheme?.lowercased() == "wss" else { return nil }
|
||||||
guard text.contains("NOT_PAIRED") || text.contains("UNAUTHORIZED") else { return false }
|
let host = url.host ?? "gateway"
|
||||||
|
let port = url.port ?? 443
|
||||||
do {
|
let stableID = "\(host):\(port)"
|
||||||
let shouldSilent = await MainActor.run {
|
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
|
||||||
AppStateStore.shared.connectionMode == .remote
|
let params = GatewayTLSParams(
|
||||||
}
|
required: true,
|
||||||
let hello = await self.makeHello()
|
expectedFingerprint: stored,
|
||||||
let token = try await MacNodeBridgePairingClient().pairAndHello(
|
allowTOFU: stored == nil,
|
||||||
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,
|
|
||||||
storeKey: stableID)
|
storeKey: stableID)
|
||||||
}
|
let session = GatewayTLSPinningSession(params: params)
|
||||||
|
return WebSocketSessionBox(session: session)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -486,46 +486,20 @@ actor MacNodeRuntime {
|
|||||||
return false
|
return false
|
||||||
}()
|
}()
|
||||||
|
|
||||||
var approvedByAsk = false
|
let approvedByAsk = params.approved == true
|
||||||
if requiresAsk {
|
if requiresAsk && !approvedByAsk {
|
||||||
let decision = await ExecApprovalsPromptPresenter.prompt(
|
await self.emitExecEvent(
|
||||||
ExecApprovalPromptRequest(
|
"exec.denied",
|
||||||
command: displayCommand,
|
payload: ExecEventPayload(
|
||||||
cwd: params.cwd,
|
sessionKey: sessionKey,
|
||||||
|
runId: runId,
|
||||||
host: "node",
|
host: "node",
|
||||||
security: security.rawValue,
|
command: displayCommand,
|
||||||
ask: ask.rawValue,
|
reason: "approval-required"))
|
||||||
agentId: agentId,
|
return Self.errorResponse(
|
||||||
resolvedPath: resolution?.resolvedPath))
|
req,
|
||||||
|
code: .unavailable,
|
||||||
switch decision {
|
message: "SYSTEM_RUN_DENIED: approval required")
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if security == .allowlist && allowlistMatch == nil && !skillAllow && !approvedByAsk {
|
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 {
|
private static func decodeParams<T: Decodable>(_ type: T.Type, from json: String?) throws -> T {
|
||||||
guard let json, let data = json.data(using: .utf8) else {
|
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",
|
NSLocalizedDescriptionKey: "INVALID_REQUEST: paramsJSON required",
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -543,7 +543,7 @@ final class NodePairingApprovalPrompter {
|
|||||||
try? await Task.sleep(nanoseconds: 200_000_000)
|
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
|
let gateway = model.gateways.first { $0.stableID == preferred } ?? model.gateways.first
|
||||||
guard let gateway else { return nil }
|
guard let gateway else { return nil }
|
||||||
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
|
let host = (gateway.tailnetDns?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty ??
|
||||||
|
|||||||
@ -9,7 +9,7 @@ extension OnboardingView {
|
|||||||
self.state.connectionMode = .local
|
self.state.connectionMode = .local
|
||||||
self.preferredGatewayID = nil
|
self.preferredGatewayID = nil
|
||||||
self.showAdvancedConnection = false
|
self.showAdvancedConnection = false
|
||||||
BridgeDiscoveryPreferences.setPreferredStableID(nil)
|
GatewayDiscoveryPreferences.setPreferredStableID(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectUnconfiguredGateway() {
|
func selectUnconfiguredGateway() {
|
||||||
@ -17,13 +17,13 @@ extension OnboardingView {
|
|||||||
self.state.connectionMode = .unconfigured
|
self.state.connectionMode = .unconfigured
|
||||||
self.preferredGatewayID = nil
|
self.preferredGatewayID = nil
|
||||||
self.showAdvancedConnection = false
|
self.showAdvancedConnection = false
|
||||||
BridgeDiscoveryPreferences.setPreferredStableID(nil)
|
GatewayDiscoveryPreferences.setPreferredStableID(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
func selectRemoteGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) {
|
||||||
Task { await self.onboardingWizard.cancelIfRunning() }
|
Task { await self.onboardingWizard.cancelIfRunning() }
|
||||||
self.preferredGatewayID = gateway.stableID
|
self.preferredGatewayID = gateway.stableID
|
||||||
BridgeDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
GatewayDiscoveryPreferences.setPreferredStableID(gateway.stableID)
|
||||||
|
|
||||||
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
if let host = gateway.tailnetDns ?? gateway.lanHost {
|
||||||
let user = NSUserName()
|
let user = NSUserName()
|
||||||
@ -36,7 +36,7 @@ extension OnboardingView {
|
|||||||
self.state.remoteCliPath = gateway.cliPath ?? ""
|
self.state.remoteCliPath = gateway.cliPath ?? ""
|
||||||
|
|
||||||
self.state.connectionMode = .remote
|
self.state.connectionMode = .remote
|
||||||
MacNodeModeCoordinator.shared.setPreferredBridgeStableID(gateway.stableID)
|
MacNodeModeCoordinator.shared.setPreferredGatewayStableID(gateway.stableID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func openSettings(tab: SettingsTab) {
|
func openSettings(tab: SettingsTab) {
|
||||||
|
|||||||
@ -63,7 +63,7 @@ extension OnboardingView {
|
|||||||
await self.ensureDefaultWorkspace()
|
await self.ensureDefaultWorkspace()
|
||||||
self.refreshAnthropicOAuthStatus()
|
self.refreshAnthropicOAuthStatus()
|
||||||
self.refreshBootstrapStatus()
|
self.refreshBootstrapStatus()
|
||||||
self.preferredGatewayID = BridgeDiscoveryPreferences.preferredStableID()
|
self.preferredGatewayID = GatewayDiscoveryPreferences.preferredStableID()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -77,7 +77,7 @@ extension OnboardingView {
|
|||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Clawdbot uses a single Gateway that stays running. Pick this Mac, " +
|
"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)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
@ -126,13 +126,13 @@ extension OnboardingView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if self.gatewayDiscovery.gateways.isEmpty {
|
if self.gatewayDiscovery.gateways.isEmpty {
|
||||||
Text("Searching for nearby bridges…")
|
Text("Searching for nearby gateways…")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
} else {
|
} else {
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text("Nearby bridges (pairing only)")
|
Text("Nearby gateways")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.padding(.leading, 4)
|
.padding(.leading, 4)
|
||||||
@ -229,12 +229,12 @@ extension OnboardingView {
|
|||||||
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
let portSuffix = gateway.sshPort != 22 ? " · ssh \(gateway.sshPort)" : ""
|
||||||
return "\(host)\(portSuffix)"
|
return "\(host)\(portSuffix)"
|
||||||
}
|
}
|
||||||
return "Bridge pairing only"
|
return "Gateway pairing only"
|
||||||
}
|
}
|
||||||
|
|
||||||
func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
|
func isSelectedGateway(_ gateway: GatewayDiscoveryModel.DiscoveredGateway) -> Bool {
|
||||||
guard self.state.connectionMode == .remote else { return false }
|
guard self.state.connectionMode == .remote else { return false }
|
||||||
let preferred = self.preferredGatewayID ?? BridgeDiscoveryPreferences.preferredStableID()
|
let preferred = self.preferredGatewayID ?? GatewayDiscoveryPreferences.preferredStableID()
|
||||||
return preferred == gateway.stableID
|
return preferred == gateway.stableID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -9,14 +9,14 @@ extension OnboardingView {
|
|||||||
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
let discovery = GatewayDiscoveryModel(localDisplayName: InstanceIdentity.displayName)
|
||||||
discovery.statusText = "Searching..."
|
discovery.statusText = "Searching..."
|
||||||
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
let gateway = GatewayDiscoveryModel.DiscoveredGateway(
|
||||||
displayName: "Test Bridge",
|
displayName: "Test Gateway",
|
||||||
lanHost: "bridge.local",
|
lanHost: "gateway.local",
|
||||||
tailnetDns: "bridge.ts.net",
|
tailnetDns: "gateway.ts.net",
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
cliPath: "/usr/local/bin/clawdbot",
|
cliPath: "/usr/local/bin/clawdbot",
|
||||||
stableID: "bridge-1",
|
stableID: "gateway-1",
|
||||||
debugID: "bridge-1",
|
debugID: "gateway-1",
|
||||||
isLocal: false)
|
isLocal: false)
|
||||||
discovery.gateways = [gateway]
|
discovery.gateways = [gateway]
|
||||||
|
|
||||||
|
|||||||
@ -81,11 +81,11 @@ public final class GatewayDiscoveryModel {
|
|||||||
public func start() {
|
public func start() {
|
||||||
if !self.browsers.isEmpty { return }
|
if !self.browsers.isEmpty { return }
|
||||||
|
|
||||||
for domain in ClawdbotBonjour.bridgeServiceDomains {
|
for domain in ClawdbotBonjour.gatewayServiceDomains {
|
||||||
let params = NWParameters.tcp
|
let params = NWParameters.tcp
|
||||||
params.includePeerToPeer = true
|
params.includePeerToPeer = true
|
||||||
let browser = NWBrowser(
|
let browser = NWBrowser(
|
||||||
for: .bonjour(type: ClawdbotBonjour.bridgeServiceType, domain: domain),
|
for: .bonjour(type: ClawdbotBonjour.gatewayServiceType, domain: domain),
|
||||||
using: params)
|
using: params)
|
||||||
|
|
||||||
browser.stateUpdateHandler = { [weak self] state in
|
browser.stateUpdateHandler = { [weak self] state in
|
||||||
@ -113,7 +113,7 @@ public final class GatewayDiscoveryModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
public func refreshWideAreaFallbackNow(timeoutSeconds: TimeInterval = 5.0) {
|
||||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||||
Task.detached(priority: .utility) { [weak self] in
|
Task.detached(priority: .utility) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
let beacons = WideAreaGatewayDiscovery.discover(timeoutSeconds: timeoutSeconds)
|
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),
|
// 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 {
|
guard !self.wideAreaFallbackGateways.isEmpty else {
|
||||||
self.gateways = primaryFiltered
|
self.gateways = primaryFiltered
|
||||||
return
|
return
|
||||||
@ -194,7 +194,7 @@ public final class GatewayDiscoveryModel {
|
|||||||
guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil }
|
guard case let .service(name, type, resultDomain, _) = result.endpoint else { return nil }
|
||||||
|
|
||||||
let decodedName = BonjourEscapes.decode(name)
|
let decodedName = BonjourEscapes.decode(name)
|
||||||
let stableID = BridgeEndpointID.stableID(result.endpoint)
|
let stableID = GatewayEndpointID.stableID(result.endpoint)
|
||||||
let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:]
|
let resolvedTXT = self.resolvedTXTByID[stableID] ?? [:]
|
||||||
let txt = Self.txtDictionary(from: result).merging(
|
let txt = Self.txtDictionary(from: result).merging(
|
||||||
resolvedTXT,
|
resolvedTXT,
|
||||||
@ -230,12 +230,12 @@ public final class GatewayDiscoveryModel {
|
|||||||
gatewayPort: parsedTXT.gatewayPort,
|
gatewayPort: parsedTXT.gatewayPort,
|
||||||
cliPath: parsedTXT.cliPath,
|
cliPath: parsedTXT.cliPath,
|
||||||
stableID: stableID,
|
stableID: stableID,
|
||||||
debugID: BridgeEndpointID.prettyDescription(result.endpoint),
|
debugID: GatewayEndpointID.prettyDescription(result.endpoint),
|
||||||
isLocal: isLocal)
|
isLocal: isLocal)
|
||||||
}
|
}
|
||||||
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
.sorted { $0.displayName.localizedCaseInsensitiveCompare($1.displayName) == .orderedAscending }
|
||||||
|
|
||||||
if domain == ClawdbotBonjour.wideAreaBridgeServiceDomain,
|
if domain == ClawdbotBonjour.wideAreaGatewayServiceDomain,
|
||||||
self.hasUsableWideAreaResults
|
self.hasUsableWideAreaResults
|
||||||
{
|
{
|
||||||
self.wideAreaFallbackGateways = []
|
self.wideAreaFallbackGateways = []
|
||||||
@ -243,7 +243,7 @@ public final class GatewayDiscoveryModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func scheduleWideAreaFallback() {
|
private func scheduleWideAreaFallback() {
|
||||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||||
if Self.isRunningTests { return }
|
if Self.isRunningTests { return }
|
||||||
guard self.wideAreaFallbackTask == nil else { return }
|
guard self.wideAreaFallbackTask == nil else { return }
|
||||||
self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in
|
self.wideAreaFallbackTask = Task.detached(priority: .utility) { [weak self] in
|
||||||
@ -276,7 +276,7 @@ public final class GatewayDiscoveryModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var hasUsableWideAreaResults: Bool {
|
private var hasUsableWideAreaResults: Bool {
|
||||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||||
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
|
guard let gateways = self.gatewaysByDomain[domain], !gateways.isEmpty else { return false }
|
||||||
if !self.filterLocalGateways { return true }
|
if !self.filterLocalGateways { return true }
|
||||||
return gateways.contains(where: { !$0.isLocal })
|
return gateways.contains(where: { !$0.isLocal })
|
||||||
@ -462,7 +462,7 @@ public final class GatewayDiscoveryModel {
|
|||||||
|
|
||||||
private nonisolated static func prettifyServiceName(_ decodedName: String) -> String {
|
private nonisolated static func prettifyServiceName(_ decodedName: String) -> String {
|
||||||
let normalized = Self.prettifyInstanceName(decodedName)
|
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
|
cleaned = cleaned
|
||||||
.replacingOccurrences(of: "_", with: " ")
|
.replacingOccurrences(of: "_", with: " ")
|
||||||
.replacingOccurrences(of: "-", with: " ")
|
.replacingOccurrences(of: "-", with: " ")
|
||||||
@ -598,11 +598,11 @@ public final class GatewayDiscoveryModel {
|
|||||||
private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? {
|
private nonisolated static func normalizeServiceHostToken(_ raw: String?) -> String? {
|
||||||
guard let raw else { return nil }
|
guard let raw else { return nil }
|
||||||
let prettified = Self.prettifyInstanceName(raw)
|
let prettified = Self.prettifyInstanceName(raw)
|
||||||
let strippedBridge = prettified.replacingOccurrences(
|
let strippedGateway = prettified.replacingOccurrences(
|
||||||
of: #"\s*-?\s*bridge$"#,
|
of: #"\s*-?\s*gateway$"#,
|
||||||
with: "",
|
with: "",
|
||||||
options: .regularExpression)
|
options: .regularExpression)
|
||||||
return self.normalizeHostToken(strippedBridge)
|
return self.normalizeHostToken(strippedGateway)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import ClawdbotKit
|
|||||||
import Foundation
|
import Foundation
|
||||||
import Network
|
import Network
|
||||||
|
|
||||||
public enum BridgeEndpointID {
|
public enum GatewayEndpointID {
|
||||||
public static func stableID(_ endpoint: NWEndpoint) -> String {
|
public static func stableID(_ endpoint: NWEndpoint) -> String {
|
||||||
switch endpoint {
|
switch endpoint {
|
||||||
case let .service(name, type, domain, _):
|
case let .service(name, type, domain, _):
|
||||||
@ -9,7 +9,6 @@ struct WideAreaGatewayBeacon: Sendable, Equatable {
|
|||||||
var lanHost: String?
|
var lanHost: String?
|
||||||
var tailnetDns: String?
|
var tailnetDns: String?
|
||||||
var gatewayPort: Int?
|
var gatewayPort: Int?
|
||||||
var bridgePort: Int?
|
|
||||||
var sshPort: Int?
|
var sshPort: Int?
|
||||||
var cliPath: String?
|
var cliPath: String?
|
||||||
}
|
}
|
||||||
@ -51,9 +50,9 @@ enum WideAreaGatewayDiscovery {
|
|||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
let domain = ClawdbotBonjour.wideAreaBridgeServiceDomain
|
let domain = ClawdbotBonjour.wideAreaGatewayServiceDomain
|
||||||
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
let domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||||
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
|
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||||
guard let ptrLines = context.dig(
|
guard let ptrLines = context.dig(
|
||||||
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
["+short", "+time=1", "+tries=1", "@\(nameserver)", probeName, "PTR"],
|
||||||
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
min(defaultTimeoutSeconds, remaining()))?.split(whereSeparator: \.isNewline),
|
||||||
@ -67,7 +66,7 @@ enum WideAreaGatewayDiscovery {
|
|||||||
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
let ptr = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if ptr.isEmpty { continue }
|
if ptr.isEmpty { continue }
|
||||||
let ptrName = ptr.hasSuffix(".") ? String(ptr.dropLast()) : ptr
|
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)
|
let rawInstanceName = ptrName.hasSuffix(suffix)
|
||||||
? String(ptrName.dropLast(suffix.count))
|
? String(ptrName.dropLast(suffix.count))
|
||||||
: ptrName
|
: ptrName
|
||||||
@ -94,7 +93,6 @@ enum WideAreaGatewayDiscovery {
|
|||||||
lanHost: txt["lanHost"],
|
lanHost: txt["lanHost"],
|
||||||
tailnetDns: txt["tailnetDns"],
|
tailnetDns: txt["tailnetDns"],
|
||||||
gatewayPort: parseInt(txt["gatewayPort"]),
|
gatewayPort: parseInt(txt["gatewayPort"]),
|
||||||
bridgePort: parseInt(txt["bridgePort"]),
|
|
||||||
sshPort: parseInt(txt["sshPort"]),
|
sshPort: parseInt(txt["sshPort"]),
|
||||||
cliPath: txt["cliPath"])
|
cliPath: txt["cliPath"])
|
||||||
beacons.append(beacon)
|
beacons.append(beacon)
|
||||||
@ -156,9 +154,9 @@ enum WideAreaGatewayDiscovery {
|
|||||||
remaining: () -> TimeInterval,
|
remaining: () -> TimeInterval,
|
||||||
dig: @escaping @Sendable (_ args: [String], _ timeout: TimeInterval) -> String?) -> String?
|
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 domainTrimmed = domain.trimmingCharacters(in: CharacterSet(charactersIn: "."))
|
||||||
let probeName = "_clawdbot-bridge._tcp.\(domainTrimmed)"
|
let probeName = "_clawdbot-gateway._tcp.\(domainTrimmed)"
|
||||||
|
|
||||||
let ips = candidates
|
let ips = candidates
|
||||||
candidates.removeAll(keepingCapacity: true)
|
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([
|
ClawdbotConfigFile.saveDict([
|
||||||
"gateway": [
|
"gateway": [
|
||||||
"remote": [
|
"remote": [
|
||||||
"url": "ws://bridge.ts.net:19999",
|
"url": "ws://gateway.ts.net:19999",
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
#expect(ClawdbotConfigFile.remoteGatewayPort() == 19999)
|
#expect(ClawdbotConfigFile.remoteGatewayPort() == 19999)
|
||||||
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge.ts.net") == 19999)
|
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "gateway.ts.net") == 19999)
|
||||||
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "bridge") == 19999)
|
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "gateway") == 19999)
|
||||||
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil)
|
#expect(ClawdbotConfigFile.remoteGatewayPort(matchingHost: "other.ts.net") == nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,7 +48,7 @@ struct GatewayDiscoveryModelTests {
|
|||||||
lanHost: "other.local",
|
lanHost: "other.local",
|
||||||
tailnetDns: "other.tailnet.example",
|
tailnetDns: "other.tailnet.example",
|
||||||
displayName: "Other Mac",
|
displayName: "Other Mac",
|
||||||
serviceName: "other-bridge",
|
serviceName: "other-gateway",
|
||||||
local: local))
|
local: local))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,7 +60,7 @@ struct GatewayDiscoveryModelTests {
|
|||||||
lanHost: nil,
|
lanHost: nil,
|
||||||
tailnetDns: nil,
|
tailnetDns: nil,
|
||||||
displayName: nil,
|
displayName: nil,
|
||||||
serviceName: "studio-bridge",
|
serviceName: "studio-gateway",
|
||||||
local: local))
|
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("@") }) ?? ""
|
let nameserver = args.first(where: { $0.hasPrefix("@") }) ?? ""
|
||||||
if recordType == "PTR" {
|
if recordType == "PTR" {
|
||||||
if nameserver == "@100.123.224.76" {
|
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 ""
|
return ""
|
||||||
}
|
}
|
||||||
if recordType == "SRV" {
|
if recordType == "SRV" {
|
||||||
return "0 0 18790 steipetacstudio.clawdbot.internal."
|
return "0 0 18789 steipetacstudio.clawdbot.internal."
|
||||||
}
|
}
|
||||||
if recordType == "TXT" {
|
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 ""
|
return ""
|
||||||
})
|
})
|
||||||
@ -41,7 +41,7 @@ struct WideAreaGatewayDiscoveryTests {
|
|||||||
let beacon = beacons[0]
|
let beacon = beacons[0]
|
||||||
let expectedDisplay = "Peter\u{2019}s Mac Studio (Clawdbot)"
|
let expectedDisplay = "Peter\u{2019}s Mac Studio (Clawdbot)"
|
||||||
#expect(beacon.displayName == expectedDisplay)
|
#expect(beacon.displayName == expectedDisplay)
|
||||||
#expect(beacon.bridgePort == 18790)
|
#expect(beacon.port == 18789)
|
||||||
#expect(beacon.gatewayPort == 18789)
|
#expect(beacon.gatewayPort == 18789)
|
||||||
#expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net")
|
#expect(beacon.tailnetDns == "peters-mac-studio-1.sheep-coho.ts.net")
|
||||||
#expect(beacon.cliPath == "/Users/steipete/clawdbot/src/entry.ts")
|
#expect(beacon.cliPath == "/Users/steipete/clawdbot/src/entry.ts")
|
||||||
|
|||||||
@ -2,24 +2,24 @@ import Foundation
|
|||||||
|
|
||||||
public enum ClawdbotBonjour {
|
public enum ClawdbotBonjour {
|
||||||
// v0: internal-only, subject to rename.
|
// v0: internal-only, subject to rename.
|
||||||
public static let bridgeServiceType = "_clawdbot-bridge._tcp"
|
public static let gatewayServiceType = "_clawdbot-gateway._tcp"
|
||||||
public static let bridgeServiceDomain = "local."
|
public static let gatewayServiceDomain = "local."
|
||||||
public static let wideAreaBridgeServiceDomain = "clawdbot.internal."
|
public static let wideAreaGatewayServiceDomain = "clawdbot.internal."
|
||||||
|
|
||||||
public static let bridgeServiceDomains = [
|
public static let gatewayServiceDomains = [
|
||||||
bridgeServiceDomain,
|
gatewayServiceDomain,
|
||||||
wideAreaBridgeServiceDomain,
|
wideAreaGatewayServiceDomain,
|
||||||
]
|
]
|
||||||
|
|
||||||
public static func normalizeServiceDomain(_ raw: String?) -> String {
|
public static func normalizeServiceDomain(_ raw: String?) -> String {
|
||||||
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
if trimmed.isEmpty {
|
if trimmed.isEmpty {
|
||||||
return self.bridgeServiceDomain
|
return self.gatewayServiceDomain
|
||||||
}
|
}
|
||||||
|
|
||||||
let lower = trimmed.lowercased()
|
let lower = trimmed.lowercased()
|
||||||
if lower == "local" || lower == "local." {
|
if lower == "local" || lower == "local." {
|
||||||
return self.bridgeServiceDomain
|
return self.gatewayServiceDomain
|
||||||
}
|
}
|
||||||
|
|
||||||
return lower.hasSuffix(".") ? lower : (lower + ".")
|
return lower.hasSuffix(".") ? lower : (lower + ".")
|
||||||
|
|||||||
@ -29,6 +29,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
public var needsScreenRecording: Bool?
|
public var needsScreenRecording: Bool?
|
||||||
public var agentId: String?
|
public var agentId: String?
|
||||||
public var sessionKey: String?
|
public var sessionKey: String?
|
||||||
|
public var approved: Bool?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
command: [String],
|
command: [String],
|
||||||
@ -38,7 +39,8 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
timeoutMs: Int? = nil,
|
timeoutMs: Int? = nil,
|
||||||
needsScreenRecording: Bool? = nil,
|
needsScreenRecording: Bool? = nil,
|
||||||
agentId: String? = nil,
|
agentId: String? = nil,
|
||||||
sessionKey: String? = nil)
|
sessionKey: String? = nil,
|
||||||
|
approved: Bool? = nil)
|
||||||
{
|
{
|
||||||
self.command = command
|
self.command = command
|
||||||
self.rawCommand = rawCommand
|
self.rawCommand = rawCommand
|
||||||
@ -48,6 +50,7 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
|||||||
self.needsScreenRecording = needsScreenRecording
|
self.needsScreenRecording = needsScreenRecording
|
||||||
self.agentId = agentId
|
self.agentId = agentId
|
||||||
self.sessionKey = sessionKey
|
self.sessionKey = sessionKey
|
||||||
|
self.approved = approved
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -425,7 +425,11 @@ export function createExecTool(
|
|||||||
applyPathPrepend(env, defaultPathPrepend);
|
applyPathPrepend(env, defaultPathPrepend);
|
||||||
|
|
||||||
if (host === "node") {
|
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");
|
throw new Error("exec denied: host=node security=deny");
|
||||||
}
|
}
|
||||||
const boundNode = defaults?.node?.trim();
|
const boundNode = defaults?.node?.trim();
|
||||||
@ -465,6 +469,79 @@ export function createExecTool(
|
|||||||
if (nodeEnv) {
|
if (nodeEnv) {
|
||||||
applyPathPrepend(nodeEnv, defaultPathPrepend, { requireExisting: true });
|
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> = {
|
const invokeParams: Record<string, unknown> = {
|
||||||
nodeId,
|
nodeId,
|
||||||
command: "system.run",
|
command: "system.run",
|
||||||
@ -476,6 +553,7 @@ export function createExecTool(
|
|||||||
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
||||||
agentId: defaults?.agentId,
|
agentId: defaults?.agentId,
|
||||||
sessionKey: defaults?.sessionKey,
|
sessionKey: defaults?.sessionKey,
|
||||||
|
approved: approvedByAsk,
|
||||||
},
|
},
|
||||||
idempotencyKey: crypto.randomUUID(),
|
idempotencyKey: crypto.randomUUID(),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import {
|
|||||||
resolveGatewayPort,
|
resolveGatewayPort,
|
||||||
resolveStateDir,
|
resolveStateDir,
|
||||||
} from "../../config/config.js";
|
} 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 { readLastGatewayErrorLine } from "../../daemon/diagnostics.js";
|
||||||
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
|
import type { FindExtraGatewayServicesOptions } from "../../daemon/inspect.js";
|
||||||
import { findExtraGatewayServices } from "../../daemon/inspect.js";
|
import { findExtraGatewayServices } from "../../daemon/inspect.js";
|
||||||
@ -33,7 +33,7 @@ type ConfigSummary = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type GatewayStatusSummary = {
|
type GatewayStatusSummary = {
|
||||||
bindMode: BridgeBindMode;
|
bindMode: GatewayBindMode;
|
||||||
bindHost: string;
|
bindHost: string;
|
||||||
customBindHost?: string;
|
customBindHost?: string;
|
||||||
port: number;
|
port: number;
|
||||||
|
|||||||
@ -122,7 +122,7 @@ export function registerDnsCli(program: Command) {
|
|||||||
console.log(
|
console.log(
|
||||||
JSON.stringify(
|
JSON.stringify(
|
||||||
{
|
{
|
||||||
bridge: { bind: "tailnet" },
|
gateway: { bind: "auto" },
|
||||||
discovery: { wideArea: { enabled: true } },
|
discovery: { wideArea: { enabled: true } },
|
||||||
},
|
},
|
||||||
null,
|
null,
|
||||||
|
|||||||
@ -146,7 +146,6 @@ describe("gateway-cli coverage", () => {
|
|||||||
lanHost: "studio.local",
|
lanHost: "studio.local",
|
||||||
tailnetDns: "studio.tailnet.ts.net",
|
tailnetDns: "studio.tailnet.ts.net",
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
bridgePort: 18790,
|
|
||||||
sshPort: 22,
|
sshPort: 22,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@ -179,7 +178,6 @@ describe("gateway-cli coverage", () => {
|
|||||||
lanHost: "studio.local",
|
lanHost: "studio.local",
|
||||||
tailnetDns: "studio.tailnet.ts.net",
|
tailnetDns: "studio.tailnet.ts.net",
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
bridgePort: 18790,
|
|
||||||
sshPort: 22,
|
sshPort: 22,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -46,7 +46,6 @@ export function dedupeBeacons(beacons: GatewayBonjourBeacon[]): GatewayBonjourBe
|
|||||||
b.displayName ?? "",
|
b.displayName ?? "",
|
||||||
host,
|
host,
|
||||||
String(b.port ?? ""),
|
String(b.port ?? ""),
|
||||||
String(b.bridgePort ?? ""),
|
|
||||||
String(b.gatewayPort ?? ""),
|
String(b.gatewayPort ?? ""),
|
||||||
].join("|");
|
].join("|");
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
|
|||||||
@ -110,7 +110,7 @@ describe("gateway SIGTERM", () => {
|
|||||||
CLAWDBOT_SKIP_CHANNELS: "1",
|
CLAWDBOT_SKIP_CHANNELS: "1",
|
||||||
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
|
CLAWDBOT_SKIP_BROWSER_CONTROL_SERVER: "1",
|
||||||
CLAWDBOT_SKIP_CANVAS_HOST: "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_HOST: "127.0.0.1",
|
||||||
CLAWDBOT_BRIDGE_PORT: "0",
|
CLAWDBOT_BRIDGE_PORT: "0",
|
||||||
},
|
},
|
||||||
|
|||||||
@ -90,7 +90,7 @@ function resolveNodeDefaults(
|
|||||||
if (opts.port !== undefined && portOverride === null) {
|
if (opts.port !== undefined && portOverride === null) {
|
||||||
return { host, port: null };
|
return { host, port: null };
|
||||||
}
|
}
|
||||||
const port = portOverride ?? config?.gateway?.port ?? 18790;
|
const port = portOverride ?? config?.gateway?.port ?? 18789;
|
||||||
return { host, port };
|
return { host, port };
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -179,7 +179,7 @@ export async function runNodeDaemonInstall(opts: NodeDaemonInstallOptions) {
|
|||||||
await buildNodeInstallPlan({
|
await buildNodeInstallPlan({
|
||||||
env: process.env,
|
env: process.env,
|
||||||
host,
|
host,
|
||||||
port: port ?? 18790,
|
port: port ?? 18789,
|
||||||
tls,
|
tls,
|
||||||
tlsFingerprint: tlsFingerprint || undefined,
|
tlsFingerprint: tlsFingerprint || undefined,
|
||||||
nodeId: opts.nodeId,
|
nodeId: opts.nodeId,
|
||||||
|
|||||||
@ -30,17 +30,17 @@ export function registerNodeCli(program: Command) {
|
|||||||
node
|
node
|
||||||
.command("start")
|
.command("start")
|
||||||
.description("Start the headless node host (foreground)")
|
.description("Start the headless node host (foreground)")
|
||||||
.option("--host <host>", "Gateway bridge host")
|
.option("--host <host>", "Gateway host")
|
||||||
.option("--port <port>", "Gateway bridge port")
|
.option("--port <port>", "Gateway port")
|
||||||
.option("--tls", "Use TLS for the bridge connection", false)
|
.option("--tls", "Use TLS for the gateway connection", false)
|
||||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
.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("--display-name <name>", "Override node display name")
|
||||||
.action(async (opts) => {
|
.action(async (opts) => {
|
||||||
const existing = await loadNodeHostConfig();
|
const existing = await loadNodeHostConfig();
|
||||||
const host =
|
const host =
|
||||||
(opts.host as string | undefined)?.trim() || existing?.gateway?.host || "127.0.0.1";
|
(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({
|
await runNodeHost({
|
||||||
gatewayHost: host,
|
gatewayHost: host,
|
||||||
gatewayPort: port,
|
gatewayPort: port,
|
||||||
@ -63,11 +63,11 @@ export function registerNodeCli(program: Command) {
|
|||||||
cmd
|
cmd
|
||||||
.command("install")
|
.command("install")
|
||||||
.description("Install the node service (launchd/systemd/schtasks)")
|
.description("Install the node service (launchd/systemd/schtasks)")
|
||||||
.option("--host <host>", "Gateway bridge host")
|
.option("--host <host>", "Gateway host")
|
||||||
.option("--port <port>", "Gateway bridge port")
|
.option("--port <port>", "Gateway port")
|
||||||
.option("--tls", "Use TLS for the bridge connection", false)
|
.option("--tls", "Use TLS for the gateway connection", false)
|
||||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
.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("--display-name <name>", "Override node display name")
|
||||||
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
|
.option("--runtime <runtime>", "Service runtime (node|bun). Default: node")
|
||||||
.option("--force", "Reinstall/overwrite if already installed", false)
|
.option("--force", "Reinstall/overwrite if already installed", false)
|
||||||
|
|||||||
@ -34,7 +34,7 @@ export function configureProgramHelp(program: Command, ctx: ProgramContext) {
|
|||||||
.version(ctx.programVersion)
|
.version(ctx.programVersion)
|
||||||
.option(
|
.option(
|
||||||
"--dev",
|
"--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(
|
.option(
|
||||||
"--profile <name>",
|
"--profile <name>",
|
||||||
|
|||||||
@ -107,9 +107,9 @@ export function registerServiceCli(program: Command) {
|
|||||||
node
|
node
|
||||||
.command("install")
|
.command("install")
|
||||||
.description("Install the node host service (launchd/systemd/schtasks)")
|
.description("Install the node host service (launchd/systemd/schtasks)")
|
||||||
.option("--host <host>", "Gateway bridge host")
|
.option("--host <host>", "Gateway host")
|
||||||
.option("--port <port>", "Gateway bridge port")
|
.option("--port <port>", "Gateway port")
|
||||||
.option("--tls", "Use TLS for the bridge connection", false)
|
.option("--tls", "Use TLS for the Gateway connection", false)
|
||||||
.option("--tls-fingerprint <sha256>", "Expected TLS certificate fingerprint (sha256)")
|
.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 (clears pairing token)")
|
||||||
.option("--display-name <name>", "Override node display name")
|
.option("--display-name <name>", "Override node display name")
|
||||||
|
|||||||
@ -45,7 +45,6 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => {
|
|||||||
valid: true,
|
valid: true,
|
||||||
config: {
|
config: {
|
||||||
gateway: { mode: "local" },
|
gateway: { mode: "local" },
|
||||||
bridge: { enabled: true, port: 18790 },
|
|
||||||
},
|
},
|
||||||
issues: [],
|
issues: [],
|
||||||
legacyIssues: [],
|
legacyIssues: [],
|
||||||
@ -73,7 +72,7 @@ const probeGateway = vi.fn(async ({ url }: { url: string }) => {
|
|||||||
path: "/tmp/remote.json",
|
path: "/tmp/remote.json",
|
||||||
exists: true,
|
exists: true,
|
||||||
valid: true,
|
valid: true,
|
||||||
config: { gateway: { mode: "remote" }, bridge: { enabled: false } },
|
config: { gateway: { mode: "remote" } },
|
||||||
issues: [],
|
issues: [],
|
||||||
legacyIssues: [],
|
legacyIssues: [],
|
||||||
},
|
},
|
||||||
|
|||||||
@ -222,7 +222,6 @@ export async function gatewayStatusCommand(
|
|||||||
host: b.host ?? null,
|
host: b.host ?? null,
|
||||||
lanHost: b.lanHost ?? null,
|
lanHost: b.lanHost ?? null,
|
||||||
tailnetDns: b.tailnetDns ?? null,
|
tailnetDns: b.tailnetDns ?? null,
|
||||||
bridgePort: b.bridgePort ?? null,
|
|
||||||
gatewayPort: b.gatewayPort ?? null,
|
gatewayPort: b.gatewayPort ?? null,
|
||||||
sshPort: b.sshPort ?? null,
|
sshPort: b.sshPort ?? null,
|
||||||
wsUrl: (() => {
|
wsUrl: (() => {
|
||||||
@ -309,17 +308,12 @@ export async function gatewayStatusCommand(
|
|||||||
}
|
}
|
||||||
if (p.configSummary) {
|
if (p.configSummary) {
|
||||||
const c = p.configSummary;
|
const c = p.configSummary;
|
||||||
const bridge =
|
|
||||||
c.bridge.enabled === false ? "disabled" : c.bridge.enabled === true ? "enabled" : "unknown";
|
|
||||||
const wideArea =
|
const wideArea =
|
||||||
c.discovery.wideAreaEnabled === true
|
c.discovery.wideAreaEnabled === true
|
||||||
? "enabled"
|
? "enabled"
|
||||||
: c.discovery.wideAreaEnabled === false
|
: c.discovery.wideAreaEnabled === false
|
||||||
? "disabled"
|
? "disabled"
|
||||||
: "unknown";
|
: "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(` ${colorize(rich, theme.info, "Wide-area discovery")}: ${wideArea}`);
|
||||||
}
|
}
|
||||||
runtime.log("");
|
runtime.log("");
|
||||||
|
|||||||
@ -40,11 +40,6 @@ export type GatewayConfigSummary = {
|
|||||||
remotePasswordConfigured: boolean;
|
remotePasswordConfigured: boolean;
|
||||||
tailscaleMode: string | null;
|
tailscaleMode: string | null;
|
||||||
};
|
};
|
||||||
bridge: {
|
|
||||||
enabled: boolean | null;
|
|
||||||
bind: string | null;
|
|
||||||
port: number | null;
|
|
||||||
};
|
|
||||||
discovery: {
|
discovery: {
|
||||||
wideAreaEnabled: boolean | null;
|
wideAreaEnabled: boolean | null;
|
||||||
};
|
};
|
||||||
@ -191,7 +186,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
|
|||||||
|
|
||||||
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
|
const cfg = (snap?.config ?? {}) as Record<string, unknown>;
|
||||||
const gateway = (cfg.gateway ?? {}) 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 discovery = (cfg.discovery ?? {}) as Record<string, unknown>;
|
||||||
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
|
const wideArea = (discovery.wideArea ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
@ -211,10 +205,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
|
|||||||
const remotePasswordConfigured =
|
const remotePasswordConfigured =
|
||||||
typeof remote.password === "string" ? String(remote.password).trim().length > 0 : false;
|
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;
|
const wideAreaEnabled = typeof wideArea.enabled === "boolean" ? wideArea.enabled : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -245,7 +235,6 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
|
|||||||
remotePasswordConfigured,
|
remotePasswordConfigured,
|
||||||
tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null,
|
tailscaleMode: typeof tailscale.mode === "string" ? tailscale.mode : null,
|
||||||
},
|
},
|
||||||
bridge: { enabled: bridgeEnabled, bind: bridgeBind, port: bridgePort },
|
|
||||||
discovery: { wideAreaEnabled },
|
discovery: { wideAreaEnabled },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -218,17 +218,14 @@ describe("legacy config detection", () => {
|
|||||||
expect(res.config?.gateway?.auth?.mode).toBe("token");
|
expect(res.config?.gateway?.auth?.mode).toBe("token");
|
||||||
expect((res.config?.gateway as { token?: string })?.token).toBeUndefined();
|
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();
|
vi.resetModules();
|
||||||
const { migrateLegacyConfig } = await import("./config.js");
|
const { migrateLegacyConfig } = await import("./config.js");
|
||||||
const res = migrateLegacyConfig({
|
const res = migrateLegacyConfig({
|
||||||
gateway: { bind: "tailnet" as const },
|
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 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?.gateway?.bind).toBe("auto");
|
||||||
expect(res.config?.bridge?.bind).toBe("auto");
|
|
||||||
});
|
});
|
||||||
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
it('rejects telegram.dmPolicy="open" without allowFrom "*"', async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
|
|||||||
@ -145,7 +145,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "bind-tailnet->auto",
|
id: "bind-tailnet->auto",
|
||||||
describe: "Remap gateway/bridge bind 'tailnet' to 'auto'",
|
describe: "Remap gateway bind 'tailnet' to 'auto'",
|
||||||
apply: (raw, changes) => {
|
apply: (raw, changes) => {
|
||||||
const migrateBind = (obj: Record<string, unknown> | null | undefined, key: string) => {
|
const migrateBind = (obj: Record<string, unknown> | null | undefined, key: string) => {
|
||||||
if (!obj) return;
|
if (!obj) return;
|
||||||
@ -158,9 +158,6 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_3: LegacyConfigMigration[] = [
|
|||||||
|
|
||||||
const gateway = getRecord(raw.gateway);
|
const gateway = getRecord(raw.gateway);
|
||||||
migrateBind(gateway, "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 { BrowserConfig } from "./types.browser.js";
|
||||||
import type { ChannelsConfig } from "./types.channels.js";
|
import type { ChannelsConfig } from "./types.channels.js";
|
||||||
import type { CronConfig } from "./types.cron.js";
|
import type { CronConfig } from "./types.cron.js";
|
||||||
import type {
|
import type { CanvasHostConfig, DiscoveryConfig, GatewayConfig, TalkConfig } from "./types.gateway.js";
|
||||||
BridgeConfig,
|
|
||||||
CanvasHostConfig,
|
|
||||||
DiscoveryConfig,
|
|
||||||
GatewayConfig,
|
|
||||||
TalkConfig,
|
|
||||||
} from "./types.gateway.js";
|
|
||||||
import type { HooksConfig } from "./types.hooks.js";
|
import type { HooksConfig } from "./types.hooks.js";
|
||||||
import type {
|
import type {
|
||||||
AudioConfig,
|
AudioConfig,
|
||||||
@ -81,7 +75,6 @@ export type ClawdbotConfig = {
|
|||||||
channels?: ChannelsConfig;
|
channels?: ChannelsConfig;
|
||||||
cron?: CronConfig;
|
cron?: CronConfig;
|
||||||
hooks?: HooksConfig;
|
hooks?: HooksConfig;
|
||||||
bridge?: BridgeConfig;
|
|
||||||
discovery?: DiscoveryConfig;
|
discovery?: DiscoveryConfig;
|
||||||
canvasHost?: CanvasHostConfig;
|
canvasHost?: CanvasHostConfig;
|
||||||
talk?: TalkConfig;
|
talk?: TalkConfig;
|
||||||
|
|||||||
@ -1,27 +1,13 @@
|
|||||||
export type BridgeBindMode = "auto" | "lan" | "loopback" | "custom";
|
export type GatewayBindMode = "auto" | "lan" | "loopback" | "custom";
|
||||||
|
|
||||||
export type BridgeConfig = {
|
export type GatewayTlsConfig = {
|
||||||
enabled?: boolean;
|
/** Enable TLS for the gateway server. */
|
||||||
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. */
|
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
/** Auto-generate a self-signed cert if cert/key are missing (default: true). */
|
/** Auto-generate a self-signed cert if cert/key are missing (default: true). */
|
||||||
autoGenerate?: boolean;
|
autoGenerate?: boolean;
|
||||||
/** PEM certificate path for the bridge server. */
|
/** PEM certificate path for the gateway server. */
|
||||||
certPath?: string;
|
certPath?: string;
|
||||||
/** PEM private key path for the bridge server. */
|
/** PEM private key path for the gateway server. */
|
||||||
keyPath?: string;
|
keyPath?: string;
|
||||||
/** Optional PEM CA bundle for TLS clients (mTLS or custom roots). */
|
/** Optional PEM CA bundle for TLS clients (mTLS or custom roots). */
|
||||||
caPath?: string;
|
caPath?: string;
|
||||||
@ -127,7 +113,6 @@ export type GatewayHttpConfig = {
|
|||||||
endpoints?: GatewayHttpEndpointsConfig;
|
endpoints?: GatewayHttpEndpointsConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type GatewayTlsConfig = BridgeTlsConfig;
|
|
||||||
|
|
||||||
export type GatewayConfig = {
|
export type GatewayConfig = {
|
||||||
/** Single multiplexed port for Gateway WS + HTTP (default: 18789). */
|
/** 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)
|
* - custom: User-specified IP, fallback to 0.0.0.0 if unavailable (requires customBindHost)
|
||||||
* Default: loopback (127.0.0.1).
|
* Default: loopback (127.0.0.1).
|
||||||
*/
|
*/
|
||||||
bind?: BridgeBindMode;
|
bind?: GatewayBindMode;
|
||||||
/** Custom IP address for bind="custom" mode. Fallback: 0.0.0.0. */
|
/** Custom IP address for bind="custom" mode. Fallback: 0.0.0.0. */
|
||||||
customBindHost?: string;
|
customBindHost?: string;
|
||||||
controlUi?: GatewayControlUiConfig;
|
controlUi?: GatewayControlUiConfig;
|
||||||
|
|||||||
@ -195,26 +195,6 @@ export const ClawdbotSchema = z
|
|||||||
.strict()
|
.strict()
|
||||||
.optional(),
|
.optional(),
|
||||||
channels: ChannelsSchema,
|
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
|
discovery: z
|
||||||
.object({
|
.object({
|
||||||
wideArea: z
|
wideArea: z
|
||||||
@ -251,7 +231,12 @@ export const ClawdbotSchema = z
|
|||||||
port: z.number().int().positive().optional(),
|
port: z.number().int().positive().optional(),
|
||||||
mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
|
mode: z.union([z.literal("local"), z.literal("remote")]).optional(),
|
||||||
bind: z
|
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(),
|
.optional(),
|
||||||
controlUi: z
|
controlUi: z
|
||||||
.object({
|
.object({
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export type ChatAbortOps = {
|
|||||||
) => { sessionKey: string; clientRunId: string } | undefined;
|
) => { sessionKey: string; clientRunId: string } | undefined;
|
||||||
agentRunSeq: Map<string, number>;
|
agentRunSeq: Map<string, number>;
|
||||||
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
|
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(
|
function broadcastChatAborted(
|
||||||
@ -61,7 +61,7 @@ function broadcastChatAborted(
|
|||||||
stopReason,
|
stopReason,
|
||||||
};
|
};
|
||||||
ops.broadcast("chat", payload);
|
ops.broadcast("chat", payload);
|
||||||
ops.bridgeSendToSession(sessionKey, "chat", payload);
|
ops.nodeSendToSession(sessionKey, "chat", payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function abortChatRunById(
|
export function abortChatRunById(
|
||||||
|
|||||||
@ -40,9 +40,13 @@ export type GatewayClientOptions = {
|
|||||||
mode?: GatewayClientMode;
|
mode?: GatewayClientMode;
|
||||||
role?: string;
|
role?: string;
|
||||||
scopes?: string[];
|
scopes?: string[];
|
||||||
|
caps?: string[];
|
||||||
|
commands?: string[];
|
||||||
|
permissions?: Record<string, boolean>;
|
||||||
deviceIdentity?: DeviceIdentity;
|
deviceIdentity?: DeviceIdentity;
|
||||||
minProtocol?: number;
|
minProtocol?: number;
|
||||||
maxProtocol?: number;
|
maxProtocol?: number;
|
||||||
|
tlsFingerprint?: string;
|
||||||
onEvent?: (evt: EventFrame) => void;
|
onEvent?: (evt: EventFrame) => void;
|
||||||
onHelloOk?: (hello: HelloOk) => void;
|
onHelloOk?: (hello: HelloOk) => void;
|
||||||
onConnectError?: (err: Error) => void;
|
onConnectError?: (err: Error) => void;
|
||||||
@ -81,7 +85,21 @@ export class GatewayClient {
|
|||||||
if (this.closed) return;
|
if (this.closed) return;
|
||||||
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
const url = this.opts.url ?? "ws://127.0.0.1:18789";
|
||||||
// Allow node screen snapshots and other large responses.
|
// 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("open", () => this.sendConnect());
|
||||||
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
|
this.ws.on("message", (data) => this.handleMessage(rawDataToString(data)));
|
||||||
@ -149,7 +167,12 @@ export class GatewayClient {
|
|||||||
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
mode: this.opts.mode ?? GATEWAY_CLIENT_MODES.BACKEND,
|
||||||
instanceId: this.opts.instanceId,
|
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,
|
auth,
|
||||||
role,
|
role,
|
||||||
scopes,
|
scopes,
|
||||||
@ -270,3 +293,7 @@ export class GatewayClient {
|
|||||||
return p;
|
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: "plugins", kind: "restart" },
|
||||||
{ prefix: "ui", kind: "none" },
|
{ prefix: "ui", kind: "none" },
|
||||||
{ prefix: "gateway", kind: "restart" },
|
{ prefix: "gateway", kind: "restart" },
|
||||||
{ prefix: "bridge", kind: "restart" },
|
|
||||||
{ prefix: "discovery", kind: "restart" },
|
{ prefix: "discovery", kind: "restart" },
|
||||||
{ prefix: "canvasHost", kind: "restart" },
|
{ prefix: "canvasHost", kind: "restart" },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -191,7 +191,7 @@ async function isPortFree(port: number): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getFreeGatewayPort(): Promise<number> {
|
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.
|
// ensuring the common derived offsets are free too.
|
||||||
for (let attempt = 0; attempt < 25; attempt += 1) {
|
for (let attempt = 0; attempt < 25; attempt += 1) {
|
||||||
const port = await getFreePort();
|
const port = await getFreePort();
|
||||||
|
|||||||
@ -23,7 +23,7 @@ export function isLoopbackAddress(ip: string | undefined): boolean {
|
|||||||
* @returns The bind address to use (never null)
|
* @returns The bind address to use (never null)
|
||||||
*/
|
*/
|
||||||
export async function resolveGatewayBindHost(
|
export async function resolveGatewayBindHost(
|
||||||
bind: import("../config/config.js").BridgeBindMode | undefined,
|
bind: import("../config/config.js").GatewayBindMode | undefined,
|
||||||
customHost?: string,
|
customHost?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const mode = bind ?? "loopback";
|
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",
|
CLI: "cli",
|
||||||
GATEWAY_CLIENT: "gateway-client",
|
GATEWAY_CLIENT: "gateway-client",
|
||||||
MACOS_APP: "clawdbot-macos",
|
MACOS_APP: "clawdbot-macos",
|
||||||
|
NODE_HOST: "node-host",
|
||||||
TEST: "test",
|
TEST: "test",
|
||||||
FINGERPRINT: "fingerprint",
|
FINGERPRINT: "fingerprint",
|
||||||
PROBE: "clawdbot-probe",
|
PROBE: "clawdbot-probe",
|
||||||
@ -21,6 +22,7 @@ export const GATEWAY_CLIENT_MODES = {
|
|||||||
CLI: "cli",
|
CLI: "cli",
|
||||||
UI: "ui",
|
UI: "ui",
|
||||||
BACKEND: "backend",
|
BACKEND: "backend",
|
||||||
|
NODE: "node",
|
||||||
PROBE: "probe",
|
PROBE: "probe",
|
||||||
TEST: "test",
|
TEST: "test",
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -93,8 +93,12 @@ import {
|
|||||||
ModelsListParamsSchema,
|
ModelsListParamsSchema,
|
||||||
type NodeDescribeParams,
|
type NodeDescribeParams,
|
||||||
NodeDescribeParamsSchema,
|
NodeDescribeParamsSchema,
|
||||||
|
type NodeEventParams,
|
||||||
|
NodeEventParamsSchema,
|
||||||
type NodeInvokeParams,
|
type NodeInvokeParams,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
|
type NodeInvokeResultParams,
|
||||||
|
NodeInvokeResultParamsSchema,
|
||||||
type NodeListParams,
|
type NodeListParams,
|
||||||
NodeListParamsSchema,
|
NodeListParamsSchema,
|
||||||
type NodePairApproveParams,
|
type NodePairApproveParams,
|
||||||
@ -207,6 +211,10 @@ export const validateNodeRenameParams = ajv.compile<NodeRenameParams>(NodeRename
|
|||||||
export const validateNodeListParams = ajv.compile<NodeListParams>(NodeListParamsSchema);
|
export const validateNodeListParams = ajv.compile<NodeListParams>(NodeListParamsSchema);
|
||||||
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(NodeDescribeParamsSchema);
|
export const validateNodeDescribeParams = ajv.compile<NodeDescribeParams>(NodeDescribeParamsSchema);
|
||||||
export const validateNodeInvokeParams = ajv.compile<NodeInvokeParams>(NodeInvokeParamsSchema);
|
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 validateSessionsListParams = ajv.compile<SessionsListParams>(SessionsListParamsSchema);
|
||||||
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
|
export const validateSessionsResolveParams = ajv.compile<SessionsResolveParams>(
|
||||||
SessionsResolveParamsSchema,
|
SessionsResolveParamsSchema,
|
||||||
@ -422,6 +430,8 @@ export type {
|
|||||||
NodePairVerifyParams,
|
NodePairVerifyParams,
|
||||||
NodeListParams,
|
NodeListParams,
|
||||||
NodeInvokeParams,
|
NodeInvokeParams,
|
||||||
|
NodeInvokeResultParams,
|
||||||
|
NodeEventParams,
|
||||||
SessionsListParams,
|
SessionsListParams,
|
||||||
SessionsResolveParams,
|
SessionsResolveParams,
|
||||||
SessionsPatchParams,
|
SessionsPatchParams,
|
||||||
|
|||||||
@ -35,6 +35,8 @@ export const ConnectParamsSchema = Type.Object(
|
|||||||
{ additionalProperties: false },
|
{ additionalProperties: false },
|
||||||
),
|
),
|
||||||
caps: Type.Optional(Type.Array(NonEmptyString, { default: [] })),
|
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),
|
role: Type.Optional(NonEmptyString),
|
||||||
scopes: Type.Optional(Type.Array(NonEmptyString)),
|
scopes: Type.Optional(Type.Array(NonEmptyString)),
|
||||||
device: Type.Optional(
|
device: Type.Optional(
|
||||||
|
|||||||
@ -59,3 +59,44 @@ export const NodeInvokeParamsSchema = Type.Object(
|
|||||||
},
|
},
|
||||||
{ additionalProperties: false },
|
{ 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";
|
} from "./logs-chat.js";
|
||||||
import {
|
import {
|
||||||
NodeDescribeParamsSchema,
|
NodeDescribeParamsSchema,
|
||||||
|
NodeEventParamsSchema,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
|
NodeInvokeResultParamsSchema,
|
||||||
|
NodeInvokeRequestEventSchema,
|
||||||
NodeListParamsSchema,
|
NodeListParamsSchema,
|
||||||
NodePairApproveParamsSchema,
|
NodePairApproveParamsSchema,
|
||||||
NodePairListParamsSchema,
|
NodePairListParamsSchema,
|
||||||
@ -140,6 +143,9 @@ export const ProtocolSchemas: Record<string, TSchema> = {
|
|||||||
NodeListParams: NodeListParamsSchema,
|
NodeListParams: NodeListParamsSchema,
|
||||||
NodeDescribeParams: NodeDescribeParamsSchema,
|
NodeDescribeParams: NodeDescribeParamsSchema,
|
||||||
NodeInvokeParams: NodeInvokeParamsSchema,
|
NodeInvokeParams: NodeInvokeParamsSchema,
|
||||||
|
NodeInvokeResultParams: NodeInvokeResultParamsSchema,
|
||||||
|
NodeEventParams: NodeEventParamsSchema,
|
||||||
|
NodeInvokeRequestEvent: NodeInvokeRequestEventSchema,
|
||||||
SessionsListParams: SessionsListParamsSchema,
|
SessionsListParams: SessionsListParamsSchema,
|
||||||
SessionsResolveParams: SessionsResolveParamsSchema,
|
SessionsResolveParams: SessionsResolveParamsSchema,
|
||||||
SessionsPatchParams: SessionsPatchParamsSchema,
|
SessionsPatchParams: SessionsPatchParamsSchema,
|
||||||
|
|||||||
@ -79,7 +79,9 @@ import type {
|
|||||||
} from "./logs-chat.js";
|
} from "./logs-chat.js";
|
||||||
import type {
|
import type {
|
||||||
NodeDescribeParamsSchema,
|
NodeDescribeParamsSchema,
|
||||||
|
NodeEventParamsSchema,
|
||||||
NodeInvokeParamsSchema,
|
NodeInvokeParamsSchema,
|
||||||
|
NodeInvokeResultParamsSchema,
|
||||||
NodeListParamsSchema,
|
NodeListParamsSchema,
|
||||||
NodePairApproveParamsSchema,
|
NodePairApproveParamsSchema,
|
||||||
NodePairListParamsSchema,
|
NodePairListParamsSchema,
|
||||||
@ -131,6 +133,8 @@ export type NodeRenameParams = Static<typeof NodeRenameParamsSchema>;
|
|||||||
export type NodeListParams = Static<typeof NodeListParamsSchema>;
|
export type NodeListParams = Static<typeof NodeListParamsSchema>;
|
||||||
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
export type NodeDescribeParams = Static<typeof NodeDescribeParamsSchema>;
|
||||||
export type NodeInvokeParams = Static<typeof NodeInvokeParamsSchema>;
|
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 SessionsListParams = Static<typeof SessionsListParamsSchema>;
|
||||||
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
export type SessionsResolveParams = Static<typeof SessionsResolveParamsSchema>;
|
||||||
export type SessionsPatchParams = Static<typeof SessionsPatchParamsSchema>;
|
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 },
|
opts?: { dropIfSlow?: boolean },
|
||||||
) => void;
|
) => void;
|
||||||
|
|
||||||
export type BridgeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
|
export type NodeSendToSession = (sessionKey: string, event: string, payload: unknown) => void;
|
||||||
|
|
||||||
export type AgentEventHandlerOptions = {
|
export type AgentEventHandlerOptions = {
|
||||||
broadcast: ChatEventBroadcast;
|
broadcast: ChatEventBroadcast;
|
||||||
bridgeSendToSession: BridgeSendToSession;
|
nodeSendToSession: NodeSendToSession;
|
||||||
agentRunSeq: Map<string, number>;
|
agentRunSeq: Map<string, number>;
|
||||||
chatRunState: ChatRunState;
|
chatRunState: ChatRunState;
|
||||||
resolveSessionKeyForRun: (runId: string) => string | undefined;
|
resolveSessionKeyForRun: (runId: string) => string | undefined;
|
||||||
@ -107,7 +107,7 @@ export type AgentEventHandlerOptions = {
|
|||||||
|
|
||||||
export function createAgentEventHandler({
|
export function createAgentEventHandler({
|
||||||
broadcast,
|
broadcast,
|
||||||
bridgeSendToSession,
|
nodeSendToSession,
|
||||||
agentRunSeq,
|
agentRunSeq,
|
||||||
chatRunState,
|
chatRunState,
|
||||||
resolveSessionKeyForRun,
|
resolveSessionKeyForRun,
|
||||||
@ -131,7 +131,7 @@ export function createAgentEventHandler({
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
broadcast("chat", payload, { dropIfSlow: true });
|
broadcast("chat", payload, { dropIfSlow: true });
|
||||||
bridgeSendToSession(sessionKey, "chat", payload);
|
nodeSendToSession(sessionKey, "chat", payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const emitChatFinal = (
|
const emitChatFinal = (
|
||||||
@ -159,7 +159,7 @@ export function createAgentEventHandler({
|
|||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
broadcast("chat", payload);
|
broadcast("chat", payload);
|
||||||
bridgeSendToSession(sessionKey, "chat", payload);
|
nodeSendToSession(sessionKey, "chat", payload);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = {
|
const payload = {
|
||||||
@ -170,7 +170,7 @@ export function createAgentEventHandler({
|
|||||||
errorMessage: error ? formatForLog(error) : undefined,
|
errorMessage: error ? formatForLog(error) : undefined,
|
||||||
};
|
};
|
||||||
broadcast("chat", payload);
|
broadcast("chat", payload);
|
||||||
bridgeSendToSession(sessionKey, "chat", payload);
|
nodeSendToSession(sessionKey, "chat", payload);
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldEmitToolEvents = (runId: string, sessionKey?: string) => {
|
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;
|
evt.stream === "lifecycle" && typeof evt.data?.phase === "string" ? evt.data.phase : null;
|
||||||
|
|
||||||
if (sessionKey) {
|
if (sessionKey) {
|
||||||
bridgeSendToSession(sessionKey, "agent", agentPayload);
|
nodeSendToSession(sessionKey, "agent", agentPayload);
|
||||||
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
if (!isAborted && evt.stream === "assistant" && typeof evt.data?.text === "string") {
|
||||||
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
|
emitChatDelta(sessionKey, clientRunId, evt.seq, evt.data.text);
|
||||||
} else if (!isAborted && (lifecyclePhase === "end" || lifecyclePhase === "error")) {
|
} 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 { CanvasHostHandler, CanvasHostServer } from "../canvas-host/server.js";
|
||||||
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
import { type ChannelId, listChannelPlugins } from "../channels/plugins/index.js";
|
||||||
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
import { stopGmailWatcher } from "../hooks/gmail-watcher.js";
|
||||||
import type { NodeBridgeServer } from "../infra/bridge/server.js";
|
|
||||||
import type { PluginServicesHandle } from "../plugins/services.js";
|
import type { PluginServicesHandle } from "../plugins/services.js";
|
||||||
|
|
||||||
export function createGatewayCloseHandler(params: {
|
export function createGatewayCloseHandler(params: {
|
||||||
@ -11,7 +10,6 @@ export function createGatewayCloseHandler(params: {
|
|||||||
tailscaleCleanup: (() => Promise<void>) | null;
|
tailscaleCleanup: (() => Promise<void>) | null;
|
||||||
canvasHost: CanvasHostHandler | null;
|
canvasHost: CanvasHostHandler | null;
|
||||||
canvasHostServer: CanvasHostServer | null;
|
canvasHostServer: CanvasHostServer | null;
|
||||||
bridge: NodeBridgeServer | null;
|
|
||||||
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
|
stopChannel: (name: ChannelId, accountId?: string) => Promise<void>;
|
||||||
pluginServices: PluginServicesHandle | null;
|
pluginServices: PluginServicesHandle | null;
|
||||||
cron: { stop: () => void };
|
cron: { stop: () => void };
|
||||||
@ -61,13 +59,6 @@ export function createGatewayCloseHandler(params: {
|
|||||||
/* ignore */
|
/* ignore */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (params.bridge) {
|
|
||||||
try {
|
|
||||||
await params.bridge.close();
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const plugin of listChannelPlugins()) {
|
for (const plugin of listChannelPlugins()) {
|
||||||
await params.stopChannel(plugin.id);
|
await params.stopChannel(plugin.id);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
|
import { startGatewayBonjourAdvertiser } from "../infra/bonjour.js";
|
||||||
import { pickPrimaryTailnetIPv4, pickPrimaryTailnetIPv6 } from "../infra/tailnet.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 {
|
import {
|
||||||
formatBonjourInstanceName,
|
formatBonjourInstanceName,
|
||||||
resolveBonjourCliPath,
|
resolveBonjourCliPath,
|
||||||
@ -11,8 +11,6 @@ export async function startGatewayDiscovery(params: {
|
|||||||
machineDisplayName: string;
|
machineDisplayName: string;
|
||||||
port: number;
|
port: number;
|
||||||
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
|
gatewayTls?: { enabled: boolean; fingerprintSha256?: string };
|
||||||
bridgePort?: number;
|
|
||||||
bridgeTls?: { enabled: boolean; fingerprintSha256?: string };
|
|
||||||
canvasPort?: number;
|
canvasPort?: number;
|
||||||
wideAreaDiscoveryEnabled: boolean;
|
wideAreaDiscoveryEnabled: boolean;
|
||||||
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
|
logDiscovery: { info: (msg: string) => void; warn: (msg: string) => void };
|
||||||
@ -34,10 +32,7 @@ export async function startGatewayDiscovery(params: {
|
|||||||
gatewayPort: params.port,
|
gatewayPort: params.port,
|
||||||
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
|
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
|
||||||
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
|
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
|
||||||
bridgePort: params.bridgePort,
|
|
||||||
canvasPort: params.canvasPort,
|
canvasPort: params.canvasPort,
|
||||||
bridgeTlsEnabled: params.bridgeTls?.enabled ?? false,
|
|
||||||
bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256,
|
|
||||||
sshPort,
|
sshPort,
|
||||||
tailnetDns,
|
tailnetDns,
|
||||||
cliPath: resolveBonjourCliPath(),
|
cliPath: resolveBonjourCliPath(),
|
||||||
@ -47,7 +42,7 @@ export async function startGatewayDiscovery(params: {
|
|||||||
params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`);
|
params.logDiscovery.warn(`bonjour advertising failed: ${String(err)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params.wideAreaDiscoveryEnabled && params.bridgePort) {
|
if (params.wideAreaDiscoveryEnabled) {
|
||||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||||
if (!tailnetIPv4) {
|
if (!tailnetIPv4) {
|
||||||
params.logDiscovery.warn(
|
params.logDiscovery.warn(
|
||||||
@ -56,14 +51,13 @@ export async function startGatewayDiscovery(params: {
|
|||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
const tailnetIPv6 = pickPrimaryTailnetIPv6();
|
||||||
const result = await writeWideAreaBridgeZone({
|
const result = await writeWideAreaGatewayZone({
|
||||||
bridgePort: params.bridgePort,
|
|
||||||
gatewayPort: params.port,
|
gatewayPort: params.port,
|
||||||
displayName: formatBonjourInstanceName(params.machineDisplayName),
|
displayName: formatBonjourInstanceName(params.machineDisplayName),
|
||||||
tailnetIPv4,
|
tailnetIPv4,
|
||||||
tailnetIPv6: tailnetIPv6 ?? undefined,
|
tailnetIPv6: tailnetIPv6 ?? undefined,
|
||||||
bridgeTlsEnabled: params.bridgeTls?.enabled ?? false,
|
gatewayTlsEnabled: params.gatewayTls?.enabled ?? false,
|
||||||
bridgeTlsFingerprintSha256: params.bridgeTls?.fingerprintSha256,
|
gatewayTlsFingerprintSha256: params.gatewayTls?.fingerprintSha256,
|
||||||
tailnetDns,
|
tailnetDns,
|
||||||
sshPort,
|
sshPort,
|
||||||
cliPath: resolveBonjourCliPath(),
|
cliPath: resolveBonjourCliPath(),
|
||||||
|
|||||||
@ -20,7 +20,7 @@ export function startGatewayMaintenanceTimers(params: {
|
|||||||
stateVersion?: { presence?: number; health?: number };
|
stateVersion?: { presence?: number; health?: number };
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
bridgeSendToAllSubscribed: (event: string, payload: unknown) => void;
|
nodeSendToAllSubscribed: (event: string, payload: unknown) => void;
|
||||||
getPresenceVersion: () => number;
|
getPresenceVersion: () => number;
|
||||||
getHealthVersion: () => number;
|
getHealthVersion: () => number;
|
||||||
refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
refreshGatewayHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
||||||
@ -36,7 +36,7 @@ export function startGatewayMaintenanceTimers(params: {
|
|||||||
sessionKey?: string,
|
sessionKey?: string,
|
||||||
) => ChatRunEntry | undefined;
|
) => ChatRunEntry | undefined;
|
||||||
agentRunSeq: Map<string, number>;
|
agentRunSeq: Map<string, number>;
|
||||||
bridgeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||||
}): {
|
}): {
|
||||||
tickInterval: ReturnType<typeof setInterval>;
|
tickInterval: ReturnType<typeof setInterval>;
|
||||||
healthInterval: ReturnType<typeof setInterval>;
|
healthInterval: ReturnType<typeof setInterval>;
|
||||||
@ -49,14 +49,14 @@ export function startGatewayMaintenanceTimers(params: {
|
|||||||
health: params.getHealthVersion(),
|
health: params.getHealthVersion(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
params.bridgeSendToAllSubscribed("health", snap);
|
params.nodeSendToAllSubscribed("health", snap);
|
||||||
});
|
});
|
||||||
|
|
||||||
// periodic keepalive
|
// periodic keepalive
|
||||||
const tickInterval = setInterval(() => {
|
const tickInterval = setInterval(() => {
|
||||||
const payload = { ts: Date.now() };
|
const payload = { ts: Date.now() };
|
||||||
params.broadcast("tick", payload, { dropIfSlow: true });
|
params.broadcast("tick", payload, { dropIfSlow: true });
|
||||||
params.bridgeSendToAllSubscribed("tick", payload);
|
params.nodeSendToAllSubscribed("tick", payload);
|
||||||
}, TICK_INTERVAL_MS);
|
}, TICK_INTERVAL_MS);
|
||||||
|
|
||||||
// periodic health refresh to keep cached snapshot warm
|
// periodic health refresh to keep cached snapshot warm
|
||||||
@ -95,7 +95,7 @@ export function startGatewayMaintenanceTimers(params: {
|
|||||||
removeChatRun: params.removeChatRun,
|
removeChatRun: params.removeChatRun,
|
||||||
agentRunSeq: params.agentRunSeq,
|
agentRunSeq: params.agentRunSeq,
|
||||||
broadcast: params.broadcast,
|
broadcast: params.broadcast,
|
||||||
bridgeSendToSession: params.bridgeSendToSession,
|
nodeSendToSession: params.nodeSendToSession,
|
||||||
},
|
},
|
||||||
{ runId, sessionKey: entry.sessionKey, stopReason: "timeout" },
|
{ runId, sessionKey: entry.sessionKey, stopReason: "timeout" },
|
||||||
);
|
);
|
||||||
|
|||||||
@ -52,6 +52,8 @@ const BASE_METHODS = [
|
|||||||
"node.list",
|
"node.list",
|
||||||
"node.describe",
|
"node.describe",
|
||||||
"node.invoke",
|
"node.invoke",
|
||||||
|
"node.invoke.result",
|
||||||
|
"node.event",
|
||||||
"cron.list",
|
"cron.list",
|
||||||
"cron.status",
|
"cron.status",
|
||||||
"cron.add",
|
"cron.add",
|
||||||
@ -87,6 +89,7 @@ export const GATEWAY_EVENTS = [
|
|||||||
"cron",
|
"cron",
|
||||||
"node.pair.requested",
|
"node.pair.requested",
|
||||||
"node.pair.resolved",
|
"node.pair.resolved",
|
||||||
|
"node.invoke.request",
|
||||||
"device.pair.requested",
|
"device.pair.requested",
|
||||||
"device.pair.resolved",
|
"device.pair.resolved",
|
||||||
"voicewake.changed",
|
"voicewake.changed",
|
||||||
|
|||||||
@ -29,6 +29,7 @@ const APPROVALS_SCOPE = "operator.approvals";
|
|||||||
const PAIRING_SCOPE = "operator.pairing";
|
const PAIRING_SCOPE = "operator.pairing";
|
||||||
|
|
||||||
const APPROVAL_METHODS = new Set(["exec.approval.request", "exec.approval.resolve"]);
|
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([
|
const PAIRING_METHODS = new Set([
|
||||||
"node.pair.request",
|
"node.pair.request",
|
||||||
"node.pair.list",
|
"node.pair.list",
|
||||||
@ -45,6 +46,10 @@ function authorizeGatewayMethod(method: string, client: GatewayRequestOptions["c
|
|||||||
if (!client?.connect) return null;
|
if (!client?.connect) return null;
|
||||||
const role = client.connect.role ?? "operator";
|
const role = client.connect.role ?? "operator";
|
||||||
const scopes = client.connect.scopes ?? [];
|
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") {
|
if (role !== "operator") {
|
||||||
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
return errorShape(ErrorCodes.INVALID_REQUEST, `unauthorized role: ${role}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -113,7 +113,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
removeChatRun: context.removeChatRun,
|
removeChatRun: context.removeChatRun,
|
||||||
agentRunSeq: context.agentRunSeq,
|
agentRunSeq: context.agentRunSeq,
|
||||||
broadcast: context.broadcast,
|
broadcast: context.broadcast,
|
||||||
bridgeSendToSession: context.bridgeSendToSession,
|
nodeSendToSession: context.nodeSendToSession,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!runId) {
|
if (!runId) {
|
||||||
@ -250,7 +250,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
removeChatRun: context.removeChatRun,
|
removeChatRun: context.removeChatRun,
|
||||||
agentRunSeq: context.agentRunSeq,
|
agentRunSeq: context.agentRunSeq,
|
||||||
broadcast: context.broadcast,
|
broadcast: context.broadcast,
|
||||||
bridgeSendToSession: context.bridgeSendToSession,
|
nodeSendToSession: context.nodeSendToSession,
|
||||||
},
|
},
|
||||||
{ sessionKey: p.sessionKey, stopReason: "stop" },
|
{ sessionKey: p.sessionKey, stopReason: "stop" },
|
||||||
);
|
);
|
||||||
@ -451,7 +451,7 @@ export const chatHandlers: GatewayRequestHandlers = {
|
|||||||
message: transcriptEntry.message,
|
message: transcriptEntry.message,
|
||||||
};
|
};
|
||||||
context.broadcast("chat", chatPayload);
|
context.broadcast("chat", chatPayload);
|
||||||
context.bridgeSendToSession(p.sessionKey, "chat", chatPayload);
|
context.nodeSendToSession(p.sessionKey, "chat", chatPayload);
|
||||||
|
|
||||||
respond(true, { ok: true, messageId });
|
respond(true, { ok: true, messageId });
|
||||||
},
|
},
|
||||||
|
|||||||
@ -167,11 +167,6 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bridge = context.bridge;
|
|
||||||
if (!bridge) {
|
|
||||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { nodeId } = params as { nodeId: string };
|
const { nodeId } = params as { nodeId: string };
|
||||||
const id = nodeId.trim();
|
const id = nodeId.trim();
|
||||||
if (!id) {
|
if (!id) {
|
||||||
@ -179,10 +174,10 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await respondUnavailableOnThrow(respond, async () => {
|
await respondUnavailableOnThrow(respond, async () => {
|
||||||
const res = await bridge.invoke({
|
const res = await context.nodeRegistry.invoke({
|
||||||
nodeId: id,
|
nodeId: id,
|
||||||
command: "system.execApprovals.get",
|
command: "system.execApprovals.get",
|
||||||
paramsJSON: "{}",
|
params: {},
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
respond(
|
respond(
|
||||||
@ -194,7 +189,7 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = safeParseJson(res.payloadJSON ?? null);
|
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
|
||||||
respond(true, payload, undefined);
|
respond(true, payload, undefined);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@ -210,11 +205,6 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bridge = context.bridge;
|
|
||||||
if (!bridge) {
|
|
||||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const { nodeId, file, baseHash } = params as {
|
const { nodeId, file, baseHash } = params as {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
file: ExecApprovalsFile;
|
file: ExecApprovalsFile;
|
||||||
@ -226,10 +216,10 @@ export const execApprovalsHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await respondUnavailableOnThrow(respond, async () => {
|
await respondUnavailableOnThrow(respond, async () => {
|
||||||
const res = await bridge.invoke({
|
const res = await context.nodeRegistry.invoke({
|
||||||
nodeId: id,
|
nodeId: id,
|
||||||
command: "system.execApprovals.set",
|
command: "system.execApprovals.set",
|
||||||
paramsJSON: JSON.stringify({ file, baseHash }),
|
params: { file, baseHash },
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
respond(
|
respond(
|
||||||
|
|||||||
@ -6,11 +6,14 @@ import {
|
|||||||
requestNodePairing,
|
requestNodePairing,
|
||||||
verifyNodeToken,
|
verifyNodeToken,
|
||||||
} from "../../infra/node-pairing.js";
|
} from "../../infra/node-pairing.js";
|
||||||
|
import { listDevicePairing } from "../../infra/device-pairing.js";
|
||||||
import {
|
import {
|
||||||
ErrorCodes,
|
ErrorCodes,
|
||||||
errorShape,
|
errorShape,
|
||||||
validateNodeDescribeParams,
|
validateNodeDescribeParams,
|
||||||
|
validateNodeEventParams,
|
||||||
validateNodeInvokeParams,
|
validateNodeInvokeParams,
|
||||||
|
validateNodeInvokeResultParams,
|
||||||
validateNodeListParams,
|
validateNodeListParams,
|
||||||
validateNodePairApproveParams,
|
validateNodePairApproveParams,
|
||||||
validateNodePairListParams,
|
validateNodePairListParams,
|
||||||
@ -201,9 +204,29 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await respondUnavailableOnThrow(respond, async () => {
|
await respondUnavailableOnThrow(respond, async () => {
|
||||||
const list = await listNodePairing();
|
const list = await listDevicePairing();
|
||||||
const pairedById = new Map(list.paired.map((n) => [n.nodeId, n]));
|
const pairedById = new Map(
|
||||||
const connected = context.bridge?.listConnected?.() ?? [];
|
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 connectedById = new Map(connected.map((n) => [n.nodeId, n]));
|
||||||
const nodeIds = new Set<string>([...pairedById.keys(), ...connectedById.keys()]);
|
const nodeIds = new Set<string>([...pairedById.keys(), ...connectedById.keys()]);
|
||||||
|
|
||||||
@ -260,9 +283,9 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await respondUnavailableOnThrow(respond, async () => {
|
await respondUnavailableOnThrow(respond, async () => {
|
||||||
const list = await listNodePairing();
|
const list = await listDevicePairing();
|
||||||
const paired = list.paired.find((n) => n.nodeId === id);
|
const paired = list.paired.find((n) => n.deviceId === id && n.role === "node");
|
||||||
const connected = context.bridge?.listConnected?.() ?? [];
|
const connected = context.nodeRegistry.listConnected();
|
||||||
const live = connected.find((n) => n.nodeId === id);
|
const live = connected.find((n) => n.nodeId === id);
|
||||||
|
|
||||||
if (!paired && !live) {
|
if (!paired && !live) {
|
||||||
@ -270,8 +293,8 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const caps = uniqueSortedStrings([...(live?.caps ?? paired?.caps ?? [])]);
|
const caps = uniqueSortedStrings([...(live?.caps ?? [])]);
|
||||||
const commands = uniqueSortedStrings([...(live?.commands ?? paired?.commands ?? [])]);
|
const commands = uniqueSortedStrings([...(live?.commands ?? [])]);
|
||||||
|
|
||||||
respond(
|
respond(
|
||||||
true,
|
true,
|
||||||
@ -280,15 +303,15 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
nodeId: id,
|
nodeId: id,
|
||||||
displayName: live?.displayName ?? paired?.displayName,
|
displayName: live?.displayName ?? paired?.displayName,
|
||||||
platform: live?.platform ?? paired?.platform,
|
platform: live?.platform ?? paired?.platform,
|
||||||
version: live?.version ?? paired?.version,
|
version: live?.version,
|
||||||
coreVersion: live?.coreVersion ?? paired?.coreVersion,
|
coreVersion: live?.coreVersion,
|
||||||
uiVersion: live?.uiVersion ?? paired?.uiVersion,
|
uiVersion: live?.uiVersion,
|
||||||
deviceFamily: live?.deviceFamily ?? paired?.deviceFamily,
|
deviceFamily: live?.deviceFamily,
|
||||||
modelIdentifier: live?.modelIdentifier ?? paired?.modelIdentifier,
|
modelIdentifier: live?.modelIdentifier,
|
||||||
remoteIp: live?.remoteIp ?? paired?.remoteIp,
|
remoteIp: live?.remoteIp ?? paired?.remoteIp,
|
||||||
caps,
|
caps,
|
||||||
commands,
|
commands,
|
||||||
permissions: live?.permissions ?? paired?.permissions,
|
permissions: live?.permissions,
|
||||||
paired: Boolean(paired),
|
paired: Boolean(paired),
|
||||||
connected: Boolean(live),
|
connected: Boolean(live),
|
||||||
},
|
},
|
||||||
@ -305,11 +328,6 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bridge = context.bridge;
|
|
||||||
if (!bridge) {
|
|
||||||
respond(false, undefined, errorShape(ErrorCodes.UNAVAILABLE, "bridge not running"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const p = params as {
|
const p = params as {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
command: string;
|
command: string;
|
||||||
@ -329,12 +347,12 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await respondUnavailableOnThrow(respond, async () => {
|
await respondUnavailableOnThrow(respond, async () => {
|
||||||
const paramsJSON = "params" in p && p.params !== undefined ? JSON.stringify(p.params) : null;
|
const res = await context.nodeRegistry.invoke({
|
||||||
const res = await bridge.invoke({
|
|
||||||
nodeId,
|
nodeId,
|
||||||
command,
|
command,
|
||||||
paramsJSON,
|
params: p.params,
|
||||||
timeoutMs: p.timeoutMs,
|
timeoutMs: p.timeoutMs,
|
||||||
|
idempotencyKey: p.idempotencyKey,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
respond(
|
respond(
|
||||||
@ -346,7 +364,7 @@ export const nodeHandlers: GatewayRequestHandlers = {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const payload = safeParseJson(res.payloadJSON ?? null);
|
const payload = res.payloadJSON ? safeParseJson(res.payloadJSON) : res.payload;
|
||||||
respond(
|
respond(
|
||||||
true,
|
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 { createDefaultDeps } from "../../cli/deps.js";
|
||||||
import type { HealthSummary } from "../../commands/health.js";
|
import type { HealthSummary } from "../../commands/health.js";
|
||||||
import type { CronService } from "../../cron/service.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 { WizardSession } from "../../wizard/session.js";
|
||||||
import type { ChatAbortControllerEntry } from "../chat-abort.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 { ConnectParams, ErrorShape, RequestFrame } from "../protocol/index.js";
|
||||||
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
|
import type { ChannelRuntimeSnapshot } from "../server-channels.js";
|
||||||
import type { DedupeEntry } from "../server-shared.js";
|
import type { DedupeEntry } from "../server-shared.js";
|
||||||
@ -39,9 +39,13 @@ export type GatewayRequestContext = {
|
|||||||
stateVersion?: { presence?: number; health?: number };
|
stateVersion?: { presence?: number; health?: number };
|
||||||
},
|
},
|
||||||
) => void;
|
) => void;
|
||||||
bridge: Awaited<ReturnType<typeof startNodeBridgeServer>> | null;
|
nodeSendToSession: (sessionKey: string, event: string, payload: unknown) => void;
|
||||||
bridgeSendToSession: (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;
|
hasConnectedMobileNode: () => boolean;
|
||||||
|
nodeRegistry: NodeRegistry;
|
||||||
agentRunSeq: Map<string, number>;
|
agentRunSeq: Map<string, number>;
|
||||||
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
chatAbortControllers: Map<string, ChatAbortControllerEntry>;
|
||||||
chatAbortedRuns: Map<string, number>;
|
chatAbortedRuns: Map<string, number>;
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
type BridgeLike = {
|
import type { NodeRegistry } from "./node-registry.js";
|
||||||
listConnected?: () => Array<{ platform?: string | null }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const isMobilePlatform = (platform: unknown): boolean => {
|
const isMobilePlatform = (platform: unknown): boolean => {
|
||||||
const p = typeof platform === "string" ? platform.trim().toLowerCase() : "";
|
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");
|
return p.startsWith("ios") || p.startsWith("ipados") || p.startsWith("android");
|
||||||
};
|
};
|
||||||
|
|
||||||
export function hasConnectedMobileNode(bridge: BridgeLike | null): boolean {
|
export function hasConnectedMobileNode(registry: NodeRegistry): boolean {
|
||||||
const connected = bridge?.listConnected?.() ?? [];
|
const connected = registry.listConnected();
|
||||||
return connected.some((n) => isMobilePlatform(n.platform));
|
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 { ChatRunEntry } from "./server-chat.js";
|
||||||
import type { DedupeEntry } from "./server-shared.js";
|
import type { DedupeEntry } from "./server-shared.js";
|
||||||
|
|
||||||
export type BridgeHandlersContext = {
|
export type NodeEventContext = {
|
||||||
deps: CliDeps;
|
deps: CliDeps;
|
||||||
broadcast: (event: string, payload: unknown, opts?: { dropIfSlow?: boolean }) => void;
|
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;
|
||||||
bridgeSubscribe: (nodeId: string, sessionKey: string) => void;
|
nodeSubscribe: (nodeId: string, sessionKey: string) => void;
|
||||||
bridgeUnsubscribe: (nodeId: string, sessionKey: string) => void;
|
nodeUnsubscribe: (nodeId: string, sessionKey: string) => void;
|
||||||
broadcastVoiceWakeChanged: (triggers: string[]) => void;
|
broadcastVoiceWakeChanged: (triggers: string[]) => void;
|
||||||
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
|
addChatRun: (sessionId: string, entry: ChatRunEntry) => void;
|
||||||
removeChatRun: (
|
removeChatRun: (
|
||||||
@ -27,32 +27,10 @@ export type BridgeHandlersContext = {
|
|||||||
getHealthCache: () => HealthSummary | null;
|
getHealthCache: () => HealthSummary | null;
|
||||||
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
refreshHealthSnapshot: (opts?: { probe?: boolean }) => Promise<HealthSummary>;
|
||||||
loadGatewayModelCatalog: () => Promise<ModelCatalogEntry[]>;
|
loadGatewayModelCatalog: () => Promise<ModelCatalogEntry[]>;
|
||||||
logBridge: { warn: (msg: string) => void };
|
logGateway: { warn: (msg: string) => void };
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BridgeRequest = {
|
export type NodeEvent = {
|
||||||
id: string;
|
|
||||||
method: string;
|
|
||||||
paramsJSON?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type BridgeEvent = {
|
|
||||||
event: string;
|
event: string;
|
||||||
payloadJSON?: string | null;
|
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 { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
||||||
import { handleBridgeEvent } from "./server-bridge-events.js";
|
import { handleNodeEvent } from "./server-node-events.js";
|
||||||
import type { BridgeHandlersContext } from "./server-bridge-types.js";
|
import type { NodeEventContext } from "./server-node-events-types.js";
|
||||||
import type { HealthSummary } from "../commands/health.js";
|
import type { HealthSummary } from "../commands/health.js";
|
||||||
import type { CliDeps } from "../cli/deps.js";
|
import type { CliDeps } from "../cli/deps.js";
|
||||||
|
|
||||||
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
|
const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent);
|
||||||
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
|
const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow);
|
||||||
|
|
||||||
function buildCtx(): BridgeHandlersContext {
|
function buildCtx(): NodeEventContext {
|
||||||
return {
|
return {
|
||||||
deps: {} as CliDeps,
|
deps: {} as CliDeps,
|
||||||
broadcast: () => {},
|
broadcast: () => {},
|
||||||
bridgeSendToSession: () => {},
|
nodeSendToSession: () => {},
|
||||||
bridgeSubscribe: () => {},
|
nodeSubscribe: () => {},
|
||||||
bridgeUnsubscribe: () => {},
|
nodeUnsubscribe: () => {},
|
||||||
broadcastVoiceWakeChanged: () => {},
|
broadcastVoiceWakeChanged: () => {},
|
||||||
addChatRun: () => {},
|
addChatRun: () => {},
|
||||||
removeChatRun: () => undefined,
|
removeChatRun: () => undefined,
|
||||||
@ -36,11 +36,11 @@ function buildCtx(): BridgeHandlersContext {
|
|||||||
getHealthCache: () => null,
|
getHealthCache: () => null,
|
||||||
refreshHealthSnapshot: async () => ({}) as HealthSummary,
|
refreshHealthSnapshot: async () => ({}) as HealthSummary,
|
||||||
loadGatewayModelCatalog: async () => [],
|
loadGatewayModelCatalog: async () => [],
|
||||||
logBridge: { warn: () => {} },
|
logGateway: { warn: () => {} },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("bridge exec events", () => {
|
describe("node exec events", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
enqueueSystemEventMock.mockReset();
|
enqueueSystemEventMock.mockReset();
|
||||||
requestHeartbeatNowMock.mockReset();
|
requestHeartbeatNowMock.mockReset();
|
||||||
@ -48,7 +48,7 @@ describe("bridge exec events", () => {
|
|||||||
|
|
||||||
it("enqueues exec.started events", async () => {
|
it("enqueues exec.started events", async () => {
|
||||||
const ctx = buildCtx();
|
const ctx = buildCtx();
|
||||||
await handleBridgeEvent(ctx, "node-1", {
|
await handleNodeEvent(ctx, "node-1", {
|
||||||
event: "exec.started",
|
event: "exec.started",
|
||||||
payloadJSON: JSON.stringify({
|
payloadJSON: JSON.stringify({
|
||||||
sessionKey: "agent:main:main",
|
sessionKey: "agent:main:main",
|
||||||
@ -66,7 +66,7 @@ describe("bridge exec events", () => {
|
|||||||
|
|
||||||
it("enqueues exec.finished events with output", async () => {
|
it("enqueues exec.finished events with output", async () => {
|
||||||
const ctx = buildCtx();
|
const ctx = buildCtx();
|
||||||
await handleBridgeEvent(ctx, "node-2", {
|
await handleNodeEvent(ctx, "node-2", {
|
||||||
event: "exec.finished",
|
event: "exec.finished",
|
||||||
payloadJSON: JSON.stringify({
|
payloadJSON: JSON.stringify({
|
||||||
runId: "run-2",
|
runId: "run-2",
|
||||||
@ -85,7 +85,7 @@ describe("bridge exec events", () => {
|
|||||||
|
|
||||||
it("enqueues exec.denied events with reason", async () => {
|
it("enqueues exec.denied events with reason", async () => {
|
||||||
const ctx = buildCtx();
|
const ctx = buildCtx();
|
||||||
await handleBridgeEvent(ctx, "node-3", {
|
await handleNodeEvent(ctx, "node-3", {
|
||||||
event: "exec.denied",
|
event: "exec.denied",
|
||||||
payloadJSON: JSON.stringify({
|
payloadJSON: JSON.stringify({
|
||||||
sessionKey: "agent:demo:main",
|
sessionKey: "agent:demo:main",
|
||||||
@ -7,14 +7,14 @@ import { requestHeartbeatNow } from "../infra/heartbeat-wake.js";
|
|||||||
import { enqueueSystemEvent } from "../infra/system-events.js";
|
import { enqueueSystemEvent } from "../infra/system-events.js";
|
||||||
import { normalizeMainKey } from "../routing/session-key.js";
|
import { normalizeMainKey } from "../routing/session-key.js";
|
||||||
import { defaultRuntime } from "../runtime.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 { loadSessionEntry } from "./session-utils.js";
|
||||||
import { formatForLog } from "./ws-log.js";
|
import { formatForLog } from "./ws-log.js";
|
||||||
|
|
||||||
export const handleBridgeEvent = async (
|
export const handleNodeEvent = async (
|
||||||
ctx: BridgeHandlersContext,
|
ctx: NodeEventContext,
|
||||||
nodeId: string,
|
nodeId: string,
|
||||||
evt: BridgeEvent,
|
evt: NodeEvent,
|
||||||
) => {
|
) => {
|
||||||
switch (evt.event) {
|
switch (evt.event) {
|
||||||
case "voice.transcript": {
|
case "voice.transcript": {
|
||||||
@ -72,7 +72,7 @@ export const handleBridgeEvent = async (
|
|||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
ctx.deps,
|
ctx.deps,
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -140,7 +140,7 @@ export const handleBridgeEvent = async (
|
|||||||
defaultRuntime,
|
defaultRuntime,
|
||||||
ctx.deps,
|
ctx.deps,
|
||||||
).catch((err) => {
|
).catch((err) => {
|
||||||
ctx.logBridge.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
ctx.logGateway.warn(`agent failed node=${nodeId}: ${formatForLog(err)}`);
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -156,7 +156,7 @@ export const handleBridgeEvent = async (
|
|||||||
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
||||||
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
||||||
if (!sessionKey) return;
|
if (!sessionKey) return;
|
||||||
ctx.bridgeSubscribe(nodeId, sessionKey);
|
ctx.nodeSubscribe(nodeId, sessionKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "chat.unsubscribe": {
|
case "chat.unsubscribe": {
|
||||||
@ -171,7 +171,7 @@ export const handleBridgeEvent = async (
|
|||||||
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
typeof payload === "object" && payload !== null ? (payload as Record<string, unknown>) : {};
|
||||||
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
const sessionKey = typeof obj.sessionKey === "string" ? obj.sessionKey.trim() : "";
|
||||||
if (!sessionKey) return;
|
if (!sessionKey) return;
|
||||||
ctx.bridgeUnsubscribe(nodeId, sessionKey);
|
ctx.nodeUnsubscribe(nodeId, sessionKey);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
case "exec.started":
|
case "exec.started":
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
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", () => {
|
test("routes events to subscribed nodes", () => {
|
||||||
const manager = createBridgeSubscriptionManager();
|
const manager = createNodeSubscriptionManager();
|
||||||
const sent: Array<{
|
const sent: Array<{
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
event: string;
|
event: string;
|
||||||
@ -22,7 +22,7 @@ describe("bridge subscription manager", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("unsubscribeAll clears session mappings", () => {
|
test("unsubscribeAll clears session mappings", () => {
|
||||||
const manager = createBridgeSubscriptionManager();
|
const manager = createNodeSubscriptionManager();
|
||||||
const sent: string[] = [];
|
const sent: string[] = [];
|
||||||
const sendEvent = (evt: { nodeId: string; event: string }) =>
|
const sendEvent = (evt: { nodeId: string; event: string }) =>
|
||||||
sent.push(`${evt.nodeId}:${evt.event}`);
|
sent.push(`${evt.nodeId}:${evt.event}`);
|
||||||
@ -1,12 +1,12 @@
|
|||||||
export type BridgeSendEventFn = (opts: {
|
export type NodeSendEventFn = (opts: {
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
event: string;
|
event: string;
|
||||||
payloadJSON?: string | null;
|
payloadJSON?: string | null;
|
||||||
}) => void;
|
}) => 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;
|
subscribe: (nodeId: string, sessionKey: string) => void;
|
||||||
unsubscribe: (nodeId: string, sessionKey: string) => void;
|
unsubscribe: (nodeId: string, sessionKey: string) => void;
|
||||||
unsubscribeAll: (nodeId: string) => void;
|
unsubscribeAll: (nodeId: string) => void;
|
||||||
@ -14,25 +14,25 @@ export type BridgeSubscriptionManager = {
|
|||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
event: string,
|
event: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
sendEvent?: BridgeSendEventFn | null,
|
sendEvent?: NodeSendEventFn | null,
|
||||||
) => void;
|
) => void;
|
||||||
sendToAllSubscribed: (
|
sendToAllSubscribed: (
|
||||||
event: string,
|
event: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
sendEvent?: BridgeSendEventFn | null,
|
sendEvent?: NodeSendEventFn | null,
|
||||||
) => void;
|
) => void;
|
||||||
sendToAllConnected: (
|
sendToAllConnected: (
|
||||||
event: string,
|
event: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
listConnected?: BridgeListConnectedFn | null,
|
listConnected?: NodeListConnectedFn | null,
|
||||||
sendEvent?: BridgeSendEventFn | null,
|
sendEvent?: NodeSendEventFn | null,
|
||||||
) => void;
|
) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
export function createNodeSubscriptionManager(): NodeSubscriptionManager {
|
||||||
const bridgeNodeSubscriptions = new Map<string, Set<string>>();
|
const nodeSubscriptions = new Map<string, Set<string>>();
|
||||||
const bridgeSessionSubscribers = new Map<string, Set<string>>();
|
const sessionSubscribers = new Map<string, Set<string>>();
|
||||||
|
|
||||||
const toPayloadJSON = (payload: unknown) => (payload ? JSON.stringify(payload) : null);
|
const toPayloadJSON = (payload: unknown) => (payload ? JSON.stringify(payload) : null);
|
||||||
|
|
||||||
@ -41,18 +41,18 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
|||||||
const normalizedSessionKey = sessionKey.trim();
|
const normalizedSessionKey = sessionKey.trim();
|
||||||
if (!normalizedNodeId || !normalizedSessionKey) return;
|
if (!normalizedNodeId || !normalizedSessionKey) return;
|
||||||
|
|
||||||
let nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
|
let nodeSet = nodeSubscriptions.get(normalizedNodeId);
|
||||||
if (!nodeSet) {
|
if (!nodeSet) {
|
||||||
nodeSet = new Set<string>();
|
nodeSet = new Set<string>();
|
||||||
bridgeNodeSubscriptions.set(normalizedNodeId, nodeSet);
|
nodeSubscriptions.set(normalizedNodeId, nodeSet);
|
||||||
}
|
}
|
||||||
if (nodeSet.has(normalizedSessionKey)) return;
|
if (nodeSet.has(normalizedSessionKey)) return;
|
||||||
nodeSet.add(normalizedSessionKey);
|
nodeSet.add(normalizedSessionKey);
|
||||||
|
|
||||||
let sessionSet = bridgeSessionSubscribers.get(normalizedSessionKey);
|
let sessionSet = sessionSubscribers.get(normalizedSessionKey);
|
||||||
if (!sessionSet) {
|
if (!sessionSet) {
|
||||||
sessionSet = new Set<string>();
|
sessionSet = new Set<string>();
|
||||||
bridgeSessionSubscribers.set(normalizedSessionKey, sessionSet);
|
sessionSubscribers.set(normalizedSessionKey, sessionSet);
|
||||||
}
|
}
|
||||||
sessionSet.add(normalizedNodeId);
|
sessionSet.add(normalizedNodeId);
|
||||||
};
|
};
|
||||||
@ -62,36 +62,36 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
|||||||
const normalizedSessionKey = sessionKey.trim();
|
const normalizedSessionKey = sessionKey.trim();
|
||||||
if (!normalizedNodeId || !normalizedSessionKey) return;
|
if (!normalizedNodeId || !normalizedSessionKey) return;
|
||||||
|
|
||||||
const nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
|
const nodeSet = nodeSubscriptions.get(normalizedNodeId);
|
||||||
nodeSet?.delete(normalizedSessionKey);
|
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);
|
sessionSet?.delete(normalizedNodeId);
|
||||||
if (sessionSet?.size === 0) bridgeSessionSubscribers.delete(normalizedSessionKey);
|
if (sessionSet?.size === 0) sessionSubscribers.delete(normalizedSessionKey);
|
||||||
};
|
};
|
||||||
|
|
||||||
const unsubscribeAll = (nodeId: string) => {
|
const unsubscribeAll = (nodeId: string) => {
|
||||||
const normalizedNodeId = nodeId.trim();
|
const normalizedNodeId = nodeId.trim();
|
||||||
const nodeSet = bridgeNodeSubscriptions.get(normalizedNodeId);
|
const nodeSet = nodeSubscriptions.get(normalizedNodeId);
|
||||||
if (!nodeSet) return;
|
if (!nodeSet) return;
|
||||||
for (const sessionKey of nodeSet) {
|
for (const sessionKey of nodeSet) {
|
||||||
const sessionSet = bridgeSessionSubscribers.get(sessionKey);
|
const sessionSet = sessionSubscribers.get(sessionKey);
|
||||||
sessionSet?.delete(normalizedNodeId);
|
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 = (
|
const sendToSession = (
|
||||||
sessionKey: string,
|
sessionKey: string,
|
||||||
event: string,
|
event: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
sendEvent?: BridgeSendEventFn | null,
|
sendEvent?: NodeSendEventFn | null,
|
||||||
) => {
|
) => {
|
||||||
const normalizedSessionKey = sessionKey.trim();
|
const normalizedSessionKey = sessionKey.trim();
|
||||||
if (!normalizedSessionKey || !sendEvent) return;
|
if (!normalizedSessionKey || !sendEvent) return;
|
||||||
const subs = bridgeSessionSubscribers.get(normalizedSessionKey);
|
const subs = sessionSubscribers.get(normalizedSessionKey);
|
||||||
if (!subs || subs.size === 0) return;
|
if (!subs || subs.size === 0) return;
|
||||||
|
|
||||||
const payloadJSON = toPayloadJSON(payload);
|
const payloadJSON = toPayloadJSON(payload);
|
||||||
@ -103,11 +103,11 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
|||||||
const sendToAllSubscribed = (
|
const sendToAllSubscribed = (
|
||||||
event: string,
|
event: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
sendEvent?: BridgeSendEventFn | null,
|
sendEvent?: NodeSendEventFn | null,
|
||||||
) => {
|
) => {
|
||||||
if (!sendEvent) return;
|
if (!sendEvent) return;
|
||||||
const payloadJSON = toPayloadJSON(payload);
|
const payloadJSON = toPayloadJSON(payload);
|
||||||
for (const nodeId of bridgeNodeSubscriptions.keys()) {
|
for (const nodeId of nodeSubscriptions.keys()) {
|
||||||
sendEvent({ nodeId, event, payloadJSON });
|
sendEvent({ nodeId, event, payloadJSON });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -115,8 +115,8 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
|||||||
const sendToAllConnected = (
|
const sendToAllConnected = (
|
||||||
event: string,
|
event: string,
|
||||||
payload: unknown,
|
payload: unknown,
|
||||||
listConnected?: BridgeListConnectedFn | null,
|
listConnected?: NodeListConnectedFn | null,
|
||||||
sendEvent?: BridgeSendEventFn | null,
|
sendEvent?: NodeSendEventFn | null,
|
||||||
) => {
|
) => {
|
||||||
if (!sendEvent || !listConnected) return;
|
if (!sendEvent || !listConnected) return;
|
||||||
const payloadJSON = toPayloadJSON(payload);
|
const payloadJSON = toPayloadJSON(payload);
|
||||||
@ -126,8 +126,8 @@ export function createBridgeSubscriptionManager(): BridgeSubscriptionManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const clear = () => {
|
const clear = () => {
|
||||||
bridgeNodeSubscriptions.clear();
|
nodeSubscriptions.clear();
|
||||||
bridgeSessionSubscribers.clear();
|
sessionSubscribers.clear();
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -1,9 +1,4 @@
|
|||||||
import type {
|
import type { GatewayAuthConfig, GatewayBindMode, GatewayTailscaleConfig, loadConfig } from "../config/config.js";
|
||||||
BridgeBindMode,
|
|
||||||
GatewayAuthConfig,
|
|
||||||
GatewayTailscaleConfig,
|
|
||||||
loadConfig,
|
|
||||||
} from "../config/config.js";
|
|
||||||
import {
|
import {
|
||||||
assertGatewayAuthConfigured,
|
assertGatewayAuthConfigured,
|
||||||
type ResolvedGatewayAuth,
|
type ResolvedGatewayAuth,
|
||||||
@ -29,7 +24,7 @@ export type GatewayRuntimeConfig = {
|
|||||||
export async function resolveGatewayRuntimeConfig(params: {
|
export async function resolveGatewayRuntimeConfig(params: {
|
||||||
cfg: ReturnType<typeof loadConfig>;
|
cfg: ReturnType<typeof loadConfig>;
|
||||||
port: number;
|
port: number;
|
||||||
bind?: BridgeBindMode;
|
bind?: GatewayBindMode;
|
||||||
host?: string;
|
host?: string;
|
||||||
controlUiEnabled?: boolean;
|
controlUiEnabled?: boolean;
|
||||||
openAiChatCompletionsEnabled?: boolean;
|
openAiChatCompletionsEnabled?: boolean;
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export function attachGatewayWsHandlers(params: {
|
|||||||
wss: WebSocketServer;
|
wss: WebSocketServer;
|
||||||
clients: Set<GatewayWsClient>;
|
clients: Set<GatewayWsClient>;
|
||||||
port: number;
|
port: number;
|
||||||
bridgeHost?: string;
|
gatewayHost?: string;
|
||||||
canvasHostEnabled: boolean;
|
canvasHostEnabled: boolean;
|
||||||
canvasHostServerPort?: number;
|
canvasHostServerPort?: number;
|
||||||
resolvedAuth: ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
@ -33,7 +33,7 @@ export function attachGatewayWsHandlers(params: {
|
|||||||
wss: params.wss,
|
wss: params.wss,
|
||||||
clients: params.clients,
|
clients: params.clients,
|
||||||
port: params.port,
|
port: params.port,
|
||||||
bridgeHost: params.bridgeHost,
|
gatewayHost: params.gatewayHost,
|
||||||
canvasHostEnabled: params.canvasHostEnabled,
|
canvasHostEnabled: params.canvasHostEnabled,
|
||||||
canvasHostServerPort: params.canvasHostServerPort,
|
canvasHostServerPort: params.canvasHostServerPort,
|
||||||
resolvedAuth: params.resolvedAuth,
|
resolvedAuth: params.resolvedAuth,
|
||||||
|
|||||||
@ -20,7 +20,7 @@ import { ensureClawdbotCliOnPath } from "../infra/path-env.js";
|
|||||||
import {
|
import {
|
||||||
primeRemoteSkillsCache,
|
primeRemoteSkillsCache,
|
||||||
refreshRemoteBinsForConnectedNodes,
|
refreshRemoteBinsForConnectedNodes,
|
||||||
setSkillsRemoteBridge,
|
setSkillsRemoteRegistry,
|
||||||
} from "../infra/skills-remote.js";
|
} from "../infra/skills-remote.js";
|
||||||
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
|
import { scheduleGatewayUpdateCheck } from "../infra/update-startup.js";
|
||||||
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
|
import { setGatewaySigusr1RestartPolicy } from "../infra/restart.js";
|
||||||
@ -36,7 +36,7 @@ import {
|
|||||||
incrementPresenceVersion,
|
incrementPresenceVersion,
|
||||||
refreshGatewayHealthSnapshot,
|
refreshGatewayHealthSnapshot,
|
||||||
} from "./server/health-state.js";
|
} 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 { ExecApprovalManager } from "./exec-approval-manager.js";
|
||||||
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
|
import { createExecApprovalHandlers } from "./server-methods/exec-approval.js";
|
||||||
import type { startBrowserControlServerIfEnabled } from "./server-browser.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 { startGatewayMaintenanceTimers } from "./server-maintenance.js";
|
||||||
import { coreGatewayHandlers } from "./server-methods.js";
|
import { coreGatewayHandlers } from "./server-methods.js";
|
||||||
import { GATEWAY_EVENTS, listGatewayMethods } from "./server-methods-list.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 { 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 { loadGatewayPlugins } from "./server-plugins.js";
|
||||||
import { createGatewayReloadHandlers } from "./server-reload-handlers.js";
|
import { createGatewayReloadHandlers } from "./server-reload-handlers.js";
|
||||||
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
|
import { resolveGatewayRuntimeConfig } from "./server-runtime-config.js";
|
||||||
import { createGatewayRuntimeState } from "./server-runtime-state.js";
|
import { createGatewayRuntimeState } from "./server-runtime-state.js";
|
||||||
|
import { hasConnectedMobileNode } from "./server-mobile-nodes.js";
|
||||||
import { resolveSessionKeyForRun } from "./server-session-key.js";
|
import { resolveSessionKeyForRun } from "./server-session-key.js";
|
||||||
import { startGatewaySidecars } from "./server-startup.js";
|
import { startGatewaySidecars } from "./server-startup.js";
|
||||||
import { logGatewayStartup } from "./server-startup-log.js";
|
import { logGatewayStartup } from "./server-startup-log.js";
|
||||||
@ -68,7 +71,6 @@ ensureClawdbotCliOnPath();
|
|||||||
|
|
||||||
const log = createSubsystemLogger("gateway");
|
const log = createSubsystemLogger("gateway");
|
||||||
const logCanvas = log.child("canvas");
|
const logCanvas = log.child("canvas");
|
||||||
const logBridge = log.child("bridge");
|
|
||||||
const logDiscovery = log.child("discovery");
|
const logDiscovery = log.child("discovery");
|
||||||
const logTailscale = log.child("tailscale");
|
const logTailscale = log.child("tailscale");
|
||||||
const logChannels = log.child("channels");
|
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)
|
* - tailnet: bind only to the Tailscale IPv4 address (100.64.0.0/10)
|
||||||
* - auto: prefer tailnet, else LAN
|
* - 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.
|
* Advanced override for the bind host, bypassing bind resolution.
|
||||||
* Prefer `bind` unless you really need a specific address.
|
* Prefer `bind` unless you really need a specific address.
|
||||||
@ -135,7 +137,7 @@ export async function startGatewayServer(
|
|||||||
port = 18789,
|
port = 18789,
|
||||||
opts: GatewayServerOptions = {},
|
opts: GatewayServerOptions = {},
|
||||||
): Promise<GatewayServer> {
|
): 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);
|
process.env.CLAWDBOT_GATEWAY_PORT = String(port);
|
||||||
|
|
||||||
let configSnapshot = await readConfigFileSnapshot();
|
let configSnapshot = await readConfigFileSnapshot();
|
||||||
@ -261,9 +263,24 @@ export async function startGatewayServer(
|
|||||||
logPlugins,
|
logPlugins,
|
||||||
});
|
});
|
||||||
let bonjourStop: (() => Promise<void>) | null = null;
|
let bonjourStop: (() => Promise<void>) | null = null;
|
||||||
let bridge: import("../infra/bridge/server.js").NodeBridgeServer | null = null;
|
const nodeRegistry = new NodeRegistry();
|
||||||
|
const nodePresenceTimers = new Map<string, ReturnType<typeof setInterval>>();
|
||||||
const hasConnectedMobileNode = () => hasConnectedMobileNodeFromBridge(bridge);
|
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);
|
applyGatewayLaneConcurrency(cfgAtStart);
|
||||||
|
|
||||||
let cronState = buildGatewayCronService({
|
let cronState = buildGatewayCronService({
|
||||||
@ -282,44 +299,18 @@ export async function startGatewayServer(
|
|||||||
channelManager;
|
channelManager;
|
||||||
|
|
||||||
const machineDisplayName = await getMachineDisplayName();
|
const machineDisplayName = await getMachineDisplayName();
|
||||||
const bridgeRuntime = await startGatewayBridgeRuntime({
|
const discovery = await startGatewayDiscovery({
|
||||||
cfg: cfgAtStart,
|
machineDisplayName,
|
||||||
port,
|
port,
|
||||||
gatewayTls: gatewayTls.enabled
|
gatewayTls: gatewayTls.enabled
|
||||||
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
|
? { enabled: true, fingerprintSha256: gatewayTls.fingerprintSha256 }
|
||||||
: undefined,
|
: undefined,
|
||||||
canvasHostEnabled,
|
wideAreaDiscoveryEnabled: cfgAtStart.discovery?.wideArea?.enabled === true,
|
||||||
canvasHost,
|
|
||||||
canvasRuntime,
|
|
||||||
allowCanvasHostInTests: opts.allowCanvasHostInTests,
|
|
||||||
machineDisplayName,
|
|
||||||
deps,
|
|
||||||
broadcast,
|
|
||||||
dedupe,
|
|
||||||
agentRunSeq,
|
|
||||||
chatRunState,
|
|
||||||
chatRunBuffers,
|
|
||||||
chatDeltaSentAt,
|
|
||||||
addChatRun,
|
|
||||||
removeChatRun,
|
|
||||||
chatAbortControllers,
|
|
||||||
getHealthCache,
|
|
||||||
refreshGatewayHealthSnapshot,
|
|
||||||
loadGatewayModelCatalog,
|
|
||||||
logBridge,
|
|
||||||
logCanvas,
|
|
||||||
logDiscovery,
|
logDiscovery,
|
||||||
});
|
});
|
||||||
bridge = bridgeRuntime.bridge;
|
bonjourStop = discovery.bonjourStop;
|
||||||
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;
|
|
||||||
|
|
||||||
setSkillsRemoteBridge(bridge);
|
setSkillsRemoteRegistry(nodeRegistry);
|
||||||
void primeRemoteSkillsCache();
|
void primeRemoteSkillsCache();
|
||||||
registerSkillsChangeListener(() => {
|
registerSkillsChangeListener(() => {
|
||||||
const latest = loadConfig();
|
const latest = loadConfig();
|
||||||
@ -328,7 +319,7 @@ export async function startGatewayServer(
|
|||||||
|
|
||||||
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
|
const { tickInterval, healthInterval, dedupeCleanup } = startGatewayMaintenanceTimers({
|
||||||
broadcast,
|
broadcast,
|
||||||
bridgeSendToAllSubscribed,
|
nodeSendToAllSubscribed,
|
||||||
getPresenceVersion,
|
getPresenceVersion,
|
||||||
getHealthVersion,
|
getHealthVersion,
|
||||||
refreshGatewayHealthSnapshot,
|
refreshGatewayHealthSnapshot,
|
||||||
@ -340,13 +331,13 @@ export async function startGatewayServer(
|
|||||||
chatDeltaSentAt,
|
chatDeltaSentAt,
|
||||||
removeChatRun,
|
removeChatRun,
|
||||||
agentRunSeq,
|
agentRunSeq,
|
||||||
bridgeSendToSession,
|
nodeSendToSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
const agentUnsub = onAgentEvent(
|
const agentUnsub = onAgentEvent(
|
||||||
createAgentEventHandler({
|
createAgentEventHandler({
|
||||||
broadcast,
|
broadcast,
|
||||||
bridgeSendToSession,
|
nodeSendToSession,
|
||||||
agentRunSeq,
|
agentRunSeq,
|
||||||
chatRunState,
|
chatRunState,
|
||||||
resolveSessionKeyForRun,
|
resolveSessionKeyForRun,
|
||||||
@ -369,7 +360,7 @@ export async function startGatewayServer(
|
|||||||
wss,
|
wss,
|
||||||
clients,
|
clients,
|
||||||
port,
|
port,
|
||||||
bridgeHost: bridgeHost ?? undefined,
|
gatewayHost: bindHost ?? undefined,
|
||||||
canvasHostEnabled: Boolean(canvasHost),
|
canvasHostEnabled: Boolean(canvasHost),
|
||||||
canvasHostServerPort: canvasHostServer?.port ?? undefined,
|
canvasHostServerPort: canvasHostServer?.port ?? undefined,
|
||||||
resolvedAuth,
|
resolvedAuth,
|
||||||
@ -395,9 +386,13 @@ export async function startGatewayServer(
|
|||||||
incrementPresenceVersion,
|
incrementPresenceVersion,
|
||||||
getHealthVersion,
|
getHealthVersion,
|
||||||
broadcast,
|
broadcast,
|
||||||
bridge,
|
nodeSendToSession,
|
||||||
bridgeSendToSession,
|
nodeSendToAllSubscribed,
|
||||||
hasConnectedMobileNode,
|
nodeSubscribe,
|
||||||
|
nodeUnsubscribe,
|
||||||
|
nodeUnsubscribeAll,
|
||||||
|
hasConnectedMobileNode: hasMobileNodeConnected,
|
||||||
|
nodeRegistry,
|
||||||
agentRunSeq,
|
agentRunSeq,
|
||||||
chatAbortControllers,
|
chatAbortControllers,
|
||||||
chatAbortedRuns: chatRunState.abortedRuns,
|
chatAbortedRuns: chatRunState.abortedRuns,
|
||||||
@ -491,7 +486,6 @@ export async function startGatewayServer(
|
|||||||
tailscaleCleanup,
|
tailscaleCleanup,
|
||||||
canvasHost,
|
canvasHost,
|
||||||
canvasHostServer,
|
canvasHostServer,
|
||||||
bridge,
|
|
||||||
stopChannel,
|
stopChannel,
|
||||||
pluginServices,
|
pluginServices,
|
||||||
cron,
|
cron,
|
||||||
|
|||||||
@ -2,10 +2,8 @@ import fs from "node:fs/promises";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
import {
|
import {
|
||||||
bridgeListConnected,
|
|
||||||
bridgeSendEvent,
|
|
||||||
bridgeStartCalls,
|
|
||||||
connectOk,
|
connectOk,
|
||||||
installGatewayTestHooks,
|
installGatewayTestHooks,
|
||||||
onceMessage,
|
onceMessage,
|
||||||
@ -13,6 +11,7 @@ import {
|
|||||||
rpcReq,
|
rpcReq,
|
||||||
startServerWithClient,
|
startServerWithClient,
|
||||||
} from "./test-helpers.js";
|
} from "./test-helpers.js";
|
||||||
|
import { GATEWAY_CLIENT_MODES } from "../utils/message-channel.js";
|
||||||
|
|
||||||
installGatewayTestHooks();
|
installGatewayTestHooks();
|
||||||
|
|
||||||
@ -116,42 +115,50 @@ describe("gateway server models + voicewake", () => {
|
|||||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
|
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-home-"));
|
||||||
const restoreHome = setTempHome(homeDir);
|
const restoreHome = setTempHome(homeDir);
|
||||||
|
|
||||||
bridgeSendEvent.mockClear();
|
const { server, ws, port } = await startServerWithClient();
|
||||||
bridgeListConnected.mockReturnValue([{ nodeId: "n1" }]);
|
|
||||||
|
|
||||||
const { server, ws } = await startServerWithClient();
|
|
||||||
await connectOk(ws);
|
await connectOk(ws);
|
||||||
|
|
||||||
const startCall = bridgeStartCalls.at(-1);
|
const nodeWs = new WebSocket(`ws://127.0.0.1:${port}`);
|
||||||
expect(startCall).toBeTruthy();
|
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 = await firstEventP;
|
||||||
|
expect(first.event).toBe("voicewake.changed");
|
||||||
const first = bridgeSendEvent.mock.calls.find(
|
expect((first.payload as { triggers?: unknown } | undefined)?.triggers).toEqual([
|
||||||
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
|
"clawd",
|
||||||
)?.[0] as { payloadJSON?: string | null } | undefined;
|
"claude",
|
||||||
expect(first?.payloadJSON).toBeTruthy();
|
"computer",
|
||||||
const firstPayload = JSON.parse(String(first?.payloadJSON)) as {
|
]);
|
||||||
triggers?: unknown;
|
|
||||||
};
|
|
||||||
expect(firstPayload.triggers).toEqual(["clawd", "claude", "computer"]);
|
|
||||||
|
|
||||||
bridgeSendEvent.mockClear();
|
|
||||||
|
|
||||||
|
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", {
|
const setRes = await rpcReq<{ triggers: string[] }>(ws, "voicewake.set", {
|
||||||
triggers: ["clawd", "computer"],
|
triggers: ["clawd", "computer"],
|
||||||
});
|
});
|
||||||
expect(setRes.ok).toBe(true);
|
expect(setRes.ok).toBe(true);
|
||||||
|
|
||||||
const broadcast = bridgeSendEvent.mock.calls.find(
|
const broadcast = await broadcastP;
|
||||||
(c) => c[0]?.event === "voicewake.changed" && c[0]?.nodeId === "n1",
|
expect(broadcast.event).toBe("voicewake.changed");
|
||||||
)?.[0] as { payloadJSON?: string | null } | undefined;
|
expect((broadcast.payload as { triggers?: unknown } | undefined)?.triggers).toEqual([
|
||||||
expect(broadcast?.payloadJSON).toBeTruthy();
|
"clawd",
|
||||||
const broadcastPayload = JSON.parse(String(broadcast?.payloadJSON)) as {
|
"computer",
|
||||||
triggers?: unknown;
|
]);
|
||||||
};
|
|
||||||
expect(broadcastPayload.triggers).toEqual(["clawd", "computer"]);
|
|
||||||
|
|
||||||
|
nodeWs.close();
|
||||||
ws.close();
|
ws.close();
|
||||||
await server.close();
|
await server.close();
|
||||||
|
|
||||||
@ -254,36 +261,4 @@ describe("gateway server models + voicewake", () => {
|
|||||||
await server.close();
|
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 {
|
import {
|
||||||
type BridgeTlsRuntime,
|
type GatewayTlsRuntime,
|
||||||
loadBridgeTlsRuntime,
|
loadGatewayTlsRuntime as loadGatewayTlsRuntimeConfig,
|
||||||
} from "../../infra/bridge/server/tls.js";
|
} from "../../infra/tls/gateway.js";
|
||||||
|
|
||||||
export type GatewayTlsRuntime = BridgeTlsRuntime;
|
|
||||||
|
|
||||||
export async function loadGatewayTlsRuntime(
|
export async function loadGatewayTlsRuntime(
|
||||||
cfg: BridgeTlsConfig | undefined,
|
cfg: GatewayTlsConfig | undefined,
|
||||||
log?: { info?: (msg: string) => void; warn?: (msg: string) => void },
|
log?: { info?: (msg: string) => void; warn?: (msg: string) => void },
|
||||||
): Promise<GatewayTlsRuntime> {
|
): Promise<GatewayTlsRuntime> {
|
||||||
return await loadBridgeTlsRuntime(cfg, log);
|
return await loadGatewayTlsRuntimeConfig(cfg, log);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
|||||||
wss: WebSocketServer;
|
wss: WebSocketServer;
|
||||||
clients: Set<GatewayWsClient>;
|
clients: Set<GatewayWsClient>;
|
||||||
port: number;
|
port: number;
|
||||||
bridgeHost?: string;
|
gatewayHost?: string;
|
||||||
canvasHostEnabled: boolean;
|
canvasHostEnabled: boolean;
|
||||||
canvasHostServerPort?: number;
|
canvasHostServerPort?: number;
|
||||||
resolvedAuth: ResolvedGatewayAuth;
|
resolvedAuth: ResolvedGatewayAuth;
|
||||||
@ -46,7 +46,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
|||||||
wss,
|
wss,
|
||||||
clients,
|
clients,
|
||||||
port,
|
port,
|
||||||
bridgeHost,
|
gatewayHost,
|
||||||
canvasHostEnabled,
|
canvasHostEnabled,
|
||||||
canvasHostServerPort,
|
canvasHostServerPort,
|
||||||
resolvedAuth,
|
resolvedAuth,
|
||||||
@ -76,7 +76,7 @@ export function attachGatewayWsConnectionHandler(params: {
|
|||||||
|
|
||||||
const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
|
const canvasHostPortForWs = canvasHostServerPort ?? (canvasHostEnabled ? port : undefined);
|
||||||
const canvasHostOverride =
|
const canvasHostOverride =
|
||||||
bridgeHost && bridgeHost !== "0.0.0.0" && bridgeHost !== "::" ? bridgeHost : undefined;
|
gatewayHost && gatewayHost !== "0.0.0.0" && gatewayHost !== "::" ? gatewayHost : undefined;
|
||||||
const canvasHostUrl = resolveCanvasHostUrl({
|
const canvasHostUrl = resolveCanvasHostUrl({
|
||||||
canvasPort: canvasHostPortForWs,
|
canvasPort: canvasHostPortForWs,
|
||||||
hostOverride: canvasHostServerPort ? canvasHostOverride : undefined,
|
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", {
|
logWs("out", "close", {
|
||||||
connId,
|
connId,
|
||||||
code,
|
code,
|
||||||
|
|||||||
@ -13,12 +13,15 @@ import {
|
|||||||
requestDevicePairing,
|
requestDevicePairing,
|
||||||
updatePairedDeviceMetadata,
|
updatePairedDeviceMetadata,
|
||||||
} from "../../../infra/device-pairing.js";
|
} 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 { upsertPresence } from "../../../infra/system-presence.js";
|
||||||
import { rawDataToString } from "../../../infra/ws.js";
|
import { rawDataToString } from "../../../infra/ws.js";
|
||||||
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
import type { createSubsystemLogger } from "../../../logging/subsystem.js";
|
||||||
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
import { isGatewayCliClient, isWebchatClient } from "../../../utils/message-channel.js";
|
||||||
import type { ResolvedGatewayAuth } from "../../auth.js";
|
import type { ResolvedGatewayAuth } from "../../auth.js";
|
||||||
import { authorizeGatewayConnect } from "../../auth.js";
|
import { authorizeGatewayConnect } from "../../auth.js";
|
||||||
|
import { loadConfig } from "../../../config/config.js";
|
||||||
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
import { buildDeviceAuthPayload } from "../../device-auth.js";
|
||||||
import { isLoopbackAddress } from "../../net.js";
|
import { isLoopbackAddress } from "../../net.js";
|
||||||
import {
|
import {
|
||||||
@ -478,6 +481,38 @@ export function attachGatewayWsMessageHandler(params: {
|
|||||||
};
|
};
|
||||||
setClient(nextClient);
|
setClient(nextClient);
|
||||||
setHandshakeState("connected");
|
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", {
|
logWs("out", "hello-ok", {
|
||||||
connId,
|
connId,
|
||||||
|
|||||||
@ -13,34 +13,6 @@ import type { PluginRegistry } from "../plugins/registry.js";
|
|||||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||||
import { DEFAULT_ACCOUNT_ID } from "../routing/session-key.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 = {
|
type StubChannelOptions = {
|
||||||
id: ChannelPlugin["id"];
|
id: ChannelPlugin["id"];
|
||||||
@ -173,16 +145,6 @@ const createStubPluginRegistry = (): PluginRegistry => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const hoisted = vi.hoisted(() => ({
|
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 },
|
testTailnetIPv4: { value: undefined as string | undefined },
|
||||||
piSdkMock: {
|
piSdkMock: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@ -232,10 +194,6 @@ export const setTestConfigRoot = (root: string) => {
|
|||||||
process.env.CLAWDBOT_CONFIG_PATH = path.join(root, "clawdbot.json");
|
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 testTailnetIPv4 = hoisted.testTailnetIPv4;
|
||||||
export const piSdkMock = hoisted.piSdkMock;
|
export const piSdkMock = hoisted.piSdkMock;
|
||||||
export const cronIsolatedRun = hoisted.cronIsolatedRun;
|
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", () => ({
|
vi.mock("../cron/isolated-agent.js", () => ({
|
||||||
runCronIsolatedAgentTurn: (...args: unknown[]) =>
|
runCronIsolatedAgentTurn: (...args: unknown[]) =>
|
||||||
(cronIsolatedRun as (...args: unknown[]) => unknown)(...args),
|
(cronIsolatedRun as (...args: unknown[]) => unknown)(...args),
|
||||||
|
|||||||
@ -247,6 +247,11 @@ export async function connectReq(
|
|||||||
modelIdentifier?: string;
|
modelIdentifier?: string;
|
||||||
instanceId?: string;
|
instanceId?: string;
|
||||||
};
|
};
|
||||||
|
role?: string;
|
||||||
|
scopes?: string[];
|
||||||
|
caps?: string[];
|
||||||
|
commands?: string[];
|
||||||
|
permissions?: Record<string, boolean>;
|
||||||
},
|
},
|
||||||
): Promise<ConnectResponse> {
|
): Promise<ConnectResponse> {
|
||||||
const { randomUUID } = await import("node:crypto");
|
const { randomUUID } = await import("node:crypto");
|
||||||
@ -265,7 +270,11 @@ export async function connectReq(
|
|||||||
platform: "test",
|
platform: "test",
|
||||||
mode: GATEWAY_CLIENT_MODES.TEST,
|
mode: GATEWAY_CLIENT_MODES.TEST,
|
||||||
},
|
},
|
||||||
caps: [],
|
caps: opts?.caps ?? [],
|
||||||
|
commands: opts?.commands ?? [],
|
||||||
|
permissions: opts?.permissions ?? undefined,
|
||||||
|
role: opts?.role,
|
||||||
|
scopes: opts?.scopes,
|
||||||
auth:
|
auth:
|
||||||
opts?.token || opts?.password
|
opts?.token || opts?.password
|
||||||
? {
|
? {
|
||||||
|
|||||||
@ -7,7 +7,7 @@ import { WIDE_AREA_DISCOVERY_DOMAIN } from "./widearea-dns.js";
|
|||||||
describe("bonjour-discovery", () => {
|
describe("bonjour-discovery", () => {
|
||||||
it("discovers beacons on darwin across local + wide-area domains", async () => {
|
it("discovers beacons on darwin across local + wide-area domains", async () => {
|
||||||
const calls: Array<{ argv: string[]; timeoutMs: number }> = [];
|
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 }) => {
|
const run = vi.fn(async (argv: string[], options: { timeoutMs: number }) => {
|
||||||
calls.push({ argv, timeoutMs: options.timeoutMs });
|
calls.push({ argv, timeoutMs: options.timeoutMs });
|
||||||
@ -17,8 +17,8 @@ describe("bonjour-discovery", () => {
|
|||||||
if (domain === "local.") {
|
if (domain === "local.") {
|
||||||
return {
|
return {
|
||||||
stdout: [
|
stdout: [
|
||||||
"Add 2 3 local. _clawdbot-bridge._tcp. Peter\\226\\128\\153s Mac Studio Bridge",
|
"Add 2 3 local. _clawdbot-gateway._tcp. Peter\\226\\128\\153s Mac Studio Gateway",
|
||||||
"Add 2 3 local. _clawdbot-bridge._tcp. Laptop Bridge",
|
"Add 2 3 local. _clawdbot-gateway._tcp. Laptop Gateway",
|
||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
stderr: "",
|
stderr: "",
|
||||||
@ -30,7 +30,7 @@ describe("bonjour-discovery", () => {
|
|||||||
if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
|
if (domain === WIDE_AREA_DISCOVERY_DOMAIN) {
|
||||||
return {
|
return {
|
||||||
stdout: [
|
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"),
|
].join("\n"),
|
||||||
stderr: "",
|
stderr: "",
|
||||||
@ -46,27 +46,26 @@ describe("bonjour-discovery", () => {
|
|||||||
const host =
|
const host =
|
||||||
instance === studioInstance
|
instance === studioInstance
|
||||||
? "studio.local"
|
? "studio.local"
|
||||||
: instance === "Laptop Bridge"
|
: instance === "Laptop Gateway"
|
||||||
? "laptop.local"
|
? "laptop.local"
|
||||||
: "tailnet.local";
|
: "tailnet.local";
|
||||||
const tailnetDns = instance === "Tailnet Bridge" ? "studio.tailnet.ts.net" : "";
|
const tailnetDns = instance === "Tailnet Gateway" ? "studio.tailnet.ts.net" : "";
|
||||||
const displayName =
|
const displayName =
|
||||||
instance === studioInstance
|
instance === studioInstance
|
||||||
? "Peter’s\\032Mac\\032Studio"
|
? "Peter’s\\032Mac\\032Studio"
|
||||||
: instance.replace(" Bridge", "");
|
: instance.replace(" Gateway", "");
|
||||||
const txtParts = [
|
const txtParts = [
|
||||||
"txtvers=1",
|
"txtvers=1",
|
||||||
`displayName=${displayName}`,
|
`displayName=${displayName}`,
|
||||||
`lanHost=${host}`,
|
`lanHost=${host}`,
|
||||||
"gatewayPort=18789",
|
"gatewayPort=18789",
|
||||||
"bridgePort=18790",
|
|
||||||
"sshPort=22",
|
"sshPort=22",
|
||||||
tailnetDns ? `tailnetDns=${tailnetDns}` : null,
|
tailnetDns ? `tailnetDns=${tailnetDns}` : null,
|
||||||
].filter((v): v is string => Boolean(v));
|
].filter((v): v is string => Boolean(v));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stdout: [
|
stdout: [
|
||||||
`${instance}._clawdbot-bridge._tcp. can be reached at ${host}:18790`,
|
`${instance}._clawdbot-gateway._tcp. can be reached at ${host}:18789`,
|
||||||
txtParts.join(" "),
|
txtParts.join(" "),
|
||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
@ -113,7 +112,7 @@ describe("bonjour-discovery", () => {
|
|||||||
const domain = argv[3] ?? "";
|
const domain = argv[3] ?? "";
|
||||||
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
|
if (argv[0] === "dns-sd" && argv[1] === "-B" && domain === "local.") {
|
||||||
return {
|
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: "",
|
stderr: "",
|
||||||
code: 0,
|
code: 0,
|
||||||
signal: null,
|
signal: null,
|
||||||
@ -124,8 +123,8 @@ describe("bonjour-discovery", () => {
|
|||||||
if (argv[0] === "dns-sd" && argv[1] === "-L") {
|
if (argv[0] === "dns-sd" && argv[1] === "-L") {
|
||||||
return {
|
return {
|
||||||
stdout: [
|
stdout: [
|
||||||
"Studio Bridge._clawdbot-bridge._tcp. can be reached at studio.local:18790",
|
"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 bridgePort=18790 sshPort=22",
|
"txtvers=1 displayName=Peter\\226\\128\\153s\\032Mac\\032Studio lanHost=studio.local gatewayPort=18789 sshPort=22",
|
||||||
"",
|
"",
|
||||||
].join("\n"),
|
].join("\n"),
|
||||||
stderr: "",
|
stderr: "",
|
||||||
@ -154,7 +153,7 @@ describe("bonjour-discovery", () => {
|
|||||||
expect(beacons).toEqual([
|
expect(beacons).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
domain: "local.",
|
domain: "local.",
|
||||||
instanceName: "Studio Bridge",
|
instanceName: "Studio Gateway",
|
||||||
displayName: "Peter’s Mac Studio",
|
displayName: "Peter’s Mac Studio",
|
||||||
txt: expect.objectContaining({
|
txt: expect.objectContaining({
|
||||||
displayName: "Peter’s Mac Studio",
|
displayName: "Peter’s Mac Studio",
|
||||||
@ -204,10 +203,10 @@ describe("bonjour-discovery", () => {
|
|||||||
if (
|
if (
|
||||||
server === "100.123.224.76" &&
|
server === "100.123.224.76" &&
|
||||||
qtype === "PTR" &&
|
qtype === "PTR" &&
|
||||||
qname === "_clawdbot-bridge._tcp.clawdbot.internal"
|
qname === "_clawdbot-gateway._tcp.clawdbot.internal"
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
stdout: `studio-bridge._clawdbot-bridge._tcp.clawdbot.internal.\n`,
|
stdout: `studio-gateway._clawdbot-gateway._tcp.clawdbot.internal.\n`,
|
||||||
stderr: "",
|
stderr: "",
|
||||||
code: 0,
|
code: 0,
|
||||||
signal: null,
|
signal: null,
|
||||||
@ -218,10 +217,10 @@ describe("bonjour-discovery", () => {
|
|||||||
if (
|
if (
|
||||||
server === "100.123.224.76" &&
|
server === "100.123.224.76" &&
|
||||||
qtype === "SRV" &&
|
qtype === "SRV" &&
|
||||||
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
|
qname === "studio-gateway._clawdbot-gateway._tcp.clawdbot.internal"
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
stdout: `0 0 18790 studio.clawdbot.internal.\n`,
|
stdout: `0 0 18789 studio.clawdbot.internal.\n`,
|
||||||
stderr: "",
|
stderr: "",
|
||||||
code: 0,
|
code: 0,
|
||||||
signal: null,
|
signal: null,
|
||||||
@ -232,14 +231,13 @@ describe("bonjour-discovery", () => {
|
|||||||
if (
|
if (
|
||||||
server === "100.123.224.76" &&
|
server === "100.123.224.76" &&
|
||||||
qtype === "TXT" &&
|
qtype === "TXT" &&
|
||||||
qname === "studio-bridge._clawdbot-bridge._tcp.clawdbot.internal"
|
qname === "studio-gateway._clawdbot-gateway._tcp.clawdbot.internal"
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
stdout: [
|
stdout: [
|
||||||
`"displayName=Studio"`,
|
`"displayName=Studio"`,
|
||||||
`"transport=bridge"`,
|
|
||||||
`"bridgePort=18790"`,
|
|
||||||
`"gatewayPort=18789"`,
|
`"gatewayPort=18789"`,
|
||||||
|
`"transport=gateway"`,
|
||||||
`"sshPort=22"`,
|
`"sshPort=22"`,
|
||||||
`"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net"`,
|
`"tailnetDns=peters-mac-studio-1.sheep-coho.ts.net"`,
|
||||||
`"cliPath=/opt/homebrew/bin/clawdbot"`,
|
`"cliPath=/opt/homebrew/bin/clawdbot"`,
|
||||||
@ -266,10 +264,10 @@ describe("bonjour-discovery", () => {
|
|||||||
expect(beacons).toEqual([
|
expect(beacons).toEqual([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
domain: WIDE_AREA_DISCOVERY_DOMAIN,
|
domain: WIDE_AREA_DISCOVERY_DOMAIN,
|
||||||
instanceName: "studio-bridge",
|
instanceName: "studio-gateway",
|
||||||
displayName: "Studio",
|
displayName: "Studio",
|
||||||
host: "studio.clawdbot.internal",
|
host: "studio.clawdbot.internal",
|
||||||
port: 18790,
|
port: 18789,
|
||||||
tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net",
|
tailnetDns: "peters-mac-studio-1.sheep-coho.ts.net",
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
sshPort: 22,
|
sshPort: 22,
|
||||||
|
|||||||
@ -9,11 +9,10 @@ export type GatewayBonjourBeacon = {
|
|||||||
port?: number;
|
port?: number;
|
||||||
lanHost?: string;
|
lanHost?: string;
|
||||||
tailnetDns?: string;
|
tailnetDns?: string;
|
||||||
bridgePort?: number;
|
|
||||||
gatewayPort?: number;
|
gatewayPort?: number;
|
||||||
sshPort?: number;
|
sshPort?: number;
|
||||||
bridgeTls?: boolean;
|
gatewayTls?: boolean;
|
||||||
bridgeTlsFingerprintSha256?: string;
|
gatewayTlsFingerprintSha256?: string;
|
||||||
cliPath?: string;
|
cliPath?: string;
|
||||||
txt?: Record<string, string>;
|
txt?: Record<string, string>;
|
||||||
};
|
};
|
||||||
@ -165,9 +164,9 @@ function parseDnsSdBrowse(stdout: string): string[] {
|
|||||||
const instances = new Set<string>();
|
const instances = new Set<string>();
|
||||||
for (const raw of stdout.split("\n")) {
|
for (const raw of stdout.split("\n")) {
|
||||||
const line = raw.trim();
|
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;
|
if (!line.includes("Add")) continue;
|
||||||
const match = line.match(/_clawdbot-bridge\._tcp\.?\s+(.+)$/);
|
const match = line.match(/_clawdbot-gateway\._tcp\.?\s+(.+)$/);
|
||||||
if (match?.[1]) {
|
if (match?.[1]) {
|
||||||
instances.add(decodeDnsSdEscapes(match[1].trim()));
|
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.lanHost) beacon.lanHost = txt.lanHost;
|
||||||
if (txt.tailnetDns) beacon.tailnetDns = txt.tailnetDns;
|
if (txt.tailnetDns) beacon.tailnetDns = txt.tailnetDns;
|
||||||
if (txt.cliPath) beacon.cliPath = txt.cliPath;
|
if (txt.cliPath) beacon.cliPath = txt.cliPath;
|
||||||
beacon.bridgePort = parseIntOrNull(txt.bridgePort);
|
|
||||||
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
beacon.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
||||||
beacon.sshPort = parseIntOrNull(txt.sshPort);
|
beacon.sshPort = parseIntOrNull(txt.sshPort);
|
||||||
if (txt.bridgeTls) {
|
if (txt.gatewayTls) {
|
||||||
const raw = txt.bridgeTls.trim().toLowerCase();
|
const raw = txt.gatewayTls.trim().toLowerCase();
|
||||||
beacon.bridgeTls = raw === "1" || raw === "true" || raw === "yes";
|
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;
|
if (!beacon.displayName) beacon.displayName = decodedInstanceName;
|
||||||
return beacon;
|
return beacon;
|
||||||
@ -223,13 +221,13 @@ async function discoverViaDnsSd(
|
|||||||
timeoutMs: number,
|
timeoutMs: number,
|
||||||
run: typeof runCommandWithTimeout,
|
run: typeof runCommandWithTimeout,
|
||||||
): Promise<GatewayBonjourBeacon[]> {
|
): 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,
|
timeoutMs,
|
||||||
});
|
});
|
||||||
const instances = parseDnsSdBrowse(browse.stdout);
|
const instances = parseDnsSdBrowse(browse.stdout);
|
||||||
const results: GatewayBonjourBeacon[] = [];
|
const results: GatewayBonjourBeacon[] = [];
|
||||||
for (const instance of instances) {
|
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,
|
timeoutMs,
|
||||||
});
|
});
|
||||||
const parsed = parseDnsSdResolve(resolved.stdout, instance);
|
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.
|
// Keep scans bounded: this is a fallback and should not block long.
|
||||||
ips = ips.slice(0, 40);
|
ips = ips.slice(0, 40);
|
||||||
|
|
||||||
const probeName = `_clawdbot-bridge._tcp.${domain.replace(/\.$/, "")}`;
|
const probeName = `_clawdbot-gateway._tcp.${domain.replace(/\.$/, "")}`;
|
||||||
|
|
||||||
const concurrency = 6;
|
const concurrency = 6;
|
||||||
let nextIndex = 0;
|
let nextIndex = 0;
|
||||||
@ -310,7 +308,7 @@ async function discoverWideAreaViaTailnetDns(
|
|||||||
if (budget <= 0) break;
|
if (budget <= 0) break;
|
||||||
const ptrName = ptr.trim().replace(/\.$/, "");
|
const ptrName = ptr.trim().replace(/\.$/, "");
|
||||||
if (!ptrName) continue;
|
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"], {
|
const srv = await run(["dig", "+short", "+time=1", "+tries=1", nameserverArg, ptrName, "SRV"], {
|
||||||
timeoutMs: Math.max(1, Math.min(350, budget)),
|
timeoutMs: Math.max(1, Math.min(350, budget)),
|
||||||
@ -343,12 +341,16 @@ async function discoverWideAreaViaTailnetDns(
|
|||||||
host: srvParsed.host,
|
host: srvParsed.host,
|
||||||
port: srvParsed.port,
|
port: srvParsed.port,
|
||||||
txt: Object.keys(txtMap).length ? txtMap : undefined,
|
txt: Object.keys(txtMap).length ? txtMap : undefined,
|
||||||
bridgePort: parseIntOrNull(txtMap.bridgePort),
|
|
||||||
gatewayPort: parseIntOrNull(txtMap.gatewayPort),
|
gatewayPort: parseIntOrNull(txtMap.gatewayPort),
|
||||||
sshPort: parseIntOrNull(txtMap.sshPort),
|
sshPort: parseIntOrNull(txtMap.sshPort),
|
||||||
tailnetDns: txtMap.tailnetDns || undefined,
|
tailnetDns: txtMap.tailnetDns || undefined,
|
||||||
cliPath: txtMap.cliPath || 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);
|
results.push(beacon);
|
||||||
}
|
}
|
||||||
@ -363,9 +365,9 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
|
|||||||
for (const raw of stdout.split("\n")) {
|
for (const raw of stdout.split("\n")) {
|
||||||
const line = raw.trimEnd();
|
const line = raw.trimEnd();
|
||||||
if (!line) continue;
|
if (!line) continue;
|
||||||
if (line.startsWith("=") && line.includes("_clawdbot-bridge._tcp")) {
|
if (line.startsWith("=") && line.includes("_clawdbot-gateway._tcp")) {
|
||||||
if (current) results.push(current);
|
if (current) results.push(current);
|
||||||
const marker = " _clawdbot-bridge._tcp";
|
const marker = " _clawdbot-gateway._tcp";
|
||||||
const idx = line.indexOf(marker);
|
const idx = line.indexOf(marker);
|
||||||
const left = idx >= 0 ? line.slice(0, idx).trim() : line;
|
const left = idx >= 0 ? line.slice(0, idx).trim() : line;
|
||||||
const parts = left.split(/\s+/);
|
const parts = left.split(/\s+/);
|
||||||
@ -400,9 +402,13 @@ function parseAvahiBrowse(stdout: string): GatewayBonjourBeacon[] {
|
|||||||
if (txt.lanHost) current.lanHost = txt.lanHost;
|
if (txt.lanHost) current.lanHost = txt.lanHost;
|
||||||
if (txt.tailnetDns) current.tailnetDns = txt.tailnetDns;
|
if (txt.tailnetDns) current.tailnetDns = txt.tailnetDns;
|
||||||
if (txt.cliPath) current.cliPath = txt.cliPath;
|
if (txt.cliPath) current.cliPath = txt.cliPath;
|
||||||
current.bridgePort = parseIntOrNull(txt.bridgePort);
|
|
||||||
current.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
current.gatewayPort = parseIntOrNull(txt.gatewayPort);
|
||||||
current.sshPort = parseIntOrNull(txt.sshPort);
|
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,
|
timeoutMs: number,
|
||||||
run: typeof runCommandWithTimeout,
|
run: typeof runCommandWithTimeout,
|
||||||
): Promise<GatewayBonjourBeacon[]> {
|
): Promise<GatewayBonjourBeacon[]> {
|
||||||
const args = ["avahi-browse", "-rt", "_clawdbot-bridge._tcp"];
|
const args = ["avahi-browse", "-rt", "_clawdbot-gateway._tcp"];
|
||||||
if (domain && domain !== "local.") {
|
if (domain && domain !== "local.") {
|
||||||
// avahi-browse wants a plain domain (no trailing dot)
|
// avahi-browse wants a plain domain (no trailing dot)
|
||||||
args.push("-d", domain.replace(/\.$/, ""));
|
args.push("-d", domain.replace(/\.$/, ""));
|
||||||
|
|||||||
@ -110,24 +110,23 @@ describe("gateway bonjour advertiser", () => {
|
|||||||
const started = await startGatewayBonjourAdvertiser({
|
const started = await startGatewayBonjourAdvertiser({
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
bridgePort: 18790,
|
|
||||||
tailnetDns: "host.tailnet.ts.net",
|
tailnetDns: "host.tailnet.ts.net",
|
||||||
cliPath: "/opt/homebrew/bin/clawdbot",
|
cliPath: "/opt/homebrew/bin/clawdbot",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(createService).toHaveBeenCalledTimes(1);
|
expect(createService).toHaveBeenCalledTimes(1);
|
||||||
const [bridgeCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
|
const [gatewayCall] = createService.mock.calls as Array<[Record<string, unknown>]>;
|
||||||
expect(bridgeCall?.[0]?.type).toBe("clawdbot-bridge");
|
expect(gatewayCall?.[0]?.type).toBe("clawdbot-gateway");
|
||||||
expect(bridgeCall?.[0]?.port).toBe(18790);
|
expect(gatewayCall?.[0]?.port).toBe(18789);
|
||||||
expect(bridgeCall?.[0]?.domain).toBe("local");
|
expect(gatewayCall?.[0]?.domain).toBe("local");
|
||||||
expect(bridgeCall?.[0]?.hostname).toBe("test-host");
|
expect(gatewayCall?.[0]?.hostname).toBe("test-host");
|
||||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
|
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("test-host.local");
|
||||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.bridgePort).toBe("18790");
|
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.gatewayPort).toBe("18789");
|
||||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
|
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.sshPort).toBe("2222");
|
||||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
|
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.cliPath).toBe(
|
||||||
"/opt/homebrew/bin/clawdbot",
|
"/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.
|
// We don't await `advertise()`, but it should still be called for each service.
|
||||||
expect(advertise).toHaveBeenCalledTimes(1);
|
expect(advertise).toHaveBeenCalledTimes(1);
|
||||||
@ -166,7 +165,6 @@ describe("gateway bonjour advertiser", () => {
|
|||||||
const started = await startGatewayBonjourAdvertiser({
|
const started = await startGatewayBonjourAdvertiser({
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
bridgePort: 18790,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1 service × 2 listeners
|
// 1 service × 2 listeners
|
||||||
@ -209,7 +207,6 @@ describe("gateway bonjour advertiser", () => {
|
|||||||
const started = await startGatewayBonjourAdvertiser({
|
const started = await startGatewayBonjourAdvertiser({
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
bridgePort: 18790,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
await started.stop();
|
await started.stop();
|
||||||
@ -248,7 +245,6 @@ describe("gateway bonjour advertiser", () => {
|
|||||||
const started = await startGatewayBonjourAdvertiser({
|
const started = await startGatewayBonjourAdvertiser({
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
bridgePort: 18790,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// initial advertise attempt happens immediately
|
// initial advertise attempt happens immediately
|
||||||
@ -295,7 +291,6 @@ describe("gateway bonjour advertiser", () => {
|
|||||||
const started = await startGatewayBonjourAdvertiser({
|
const started = await startGatewayBonjourAdvertiser({
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
bridgePort: 18790,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(advertise).toHaveBeenCalledTimes(1);
|
expect(advertise).toHaveBeenCalledTimes(1);
|
||||||
@ -328,14 +323,13 @@ describe("gateway bonjour advertiser", () => {
|
|||||||
const started = await startGatewayBonjourAdvertiser({
|
const started = await startGatewayBonjourAdvertiser({
|
||||||
gatewayPort: 18789,
|
gatewayPort: 18789,
|
||||||
sshPort: 2222,
|
sshPort: 2222,
|
||||||
bridgePort: 18790,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const [bridgeCall] = createService.mock.calls as Array<[ServiceCall]>;
|
const [gatewayCall] = createService.mock.calls as Array<[ServiceCall]>;
|
||||||
expect(bridgeCall?.[0]?.name).toBe("Mac (Clawdbot)");
|
expect(gatewayCall?.[0]?.name).toBe("Mac (Clawdbot)");
|
||||||
expect(bridgeCall?.[0]?.domain).toBe("local");
|
expect(gatewayCall?.[0]?.domain).toBe("local");
|
||||||
expect(bridgeCall?.[0]?.hostname).toBe("Mac");
|
expect(gatewayCall?.[0]?.hostname).toBe("Mac");
|
||||||
expect((bridgeCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
|
expect((gatewayCall?.[0]?.txt as Record<string, string>)?.lanHost).toBe("Mac.local");
|
||||||
|
|
||||||
await started.stop();
|
await started.stop();
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user