Discovery: wide-area bridge DNS-SD

# Conflicts:
#	apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift
#	src/cli/dns-cli.ts
main
Peter Steinberger 2025-12-17 20:25:40 +01:00
parent e9bfe34850
commit 557ffdbe35
24 changed files with 293 additions and 19 deletions

@ -1 +1 @@
Subproject commit 74a1c75702760f3865f89d1193e9c76f7f38275f Subproject commit 4ff4d3db244f9794a7af1b728a6104929e95250b

View File

@ -0,0 +1,30 @@
package com.steipete.clawdis.node.bridge
object BonjourEscapes {
fun decode(input: String): String {
if (input.isEmpty()) return input
val out = StringBuilder(input.length)
var i = 0
while (i < input.length) {
if (input[i] == '\\' && i + 3 < input.length) {
val d0 = input[i + 1]
val d1 = input[i + 2]
val d2 = input[i + 3]
if (d0.isDigit() && d1.isDigit() && d2.isDigit()) {
val value =
((d0.code - '0'.code) * 100) + ((d1.code - '0'.code) * 10) + (d2.code - '0'.code)
if (value in 0..0x10FFFF) {
out.appendCodePoint(value)
i += 4
continue
}
}
}
out.append(input[i])
i += 1
}
return out.toString()
}
}

View File

@ -1,10 +1,12 @@
package com.steipete.clawdis.node.bridge package com.steipete.clawdis.node.bridge
import android.content.Context import android.content.Context
import android.net.ConnectivityManager
import android.net.nsd.NsdManager import android.net.nsd.NsdManager
import android.net.nsd.NsdServiceInfo import android.net.nsd.NsdServiceInfo
import android.os.Build import android.os.Build
import java.net.InetAddress import java.net.InetAddress
import java.time.Duration
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.xbill.DNS.ExtendedResolver
import org.xbill.DNS.Lookup import org.xbill.DNS.Lookup
import org.xbill.DNS.PTRRecord import org.xbill.DNS.PTRRecord
import org.xbill.DNS.SRVRecord import org.xbill.DNS.SRVRecord
@ -25,6 +28,7 @@ class BridgeDiscovery(
private val scope: CoroutineScope, private val scope: CoroutineScope,
) { ) {
private val nsd = context.getSystemService(NsdManager::class.java) private val nsd = context.getSystemService(NsdManager::class.java)
private val connectivity = context.getSystemService(ConnectivityManager::class.java)
private val serviceType = "_clawdis-bridge._tcp." private val serviceType = "_clawdis-bridge._tcp."
private val wideAreaDomain = "clawdis.internal." private val wideAreaDomain = "clawdis.internal."
@ -100,8 +104,10 @@ class BridgeDiscovery(
val port = resolved.port val port = resolved.port
if (port <= 0) return if (port <= 0) return
val displayName = txt(resolved, "displayName") ?: resolved.serviceName val rawServiceName = resolved.serviceName
val id = stableId(resolved.serviceName, "local.") val serviceName = BonjourEscapes.decode(rawServiceName)
val displayName = BonjourEscapes.decode(txt(resolved, "displayName") ?: serviceName)
val id = stableId(serviceName, "local.")
localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port) localById[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
publish() publish()
} }
@ -133,13 +139,15 @@ class BridgeDiscovery(
} }
private suspend fun refreshUnicast(domain: String) { private suspend fun refreshUnicast(domain: String) {
val resolver = createUnicastResolver()
val ptrName = "${serviceType}${domain}" val ptrName = "${serviceType}${domain}"
val ptrRecords = lookup(ptrName, Type.PTR).mapNotNull { it as? PTRRecord } val ptrRecords = lookup(ptrName, Type.PTR, resolver).mapNotNull { it as? PTRRecord }
val next = LinkedHashMap<String, BridgeEndpoint>() val next = LinkedHashMap<String, BridgeEndpoint>()
for (ptr in ptrRecords) { for (ptr in ptrRecords) {
val instanceFqdn = ptr.target.toString() val instanceFqdn = ptr.target.toString()
val srv = lookup(instanceFqdn, Type.SRV).firstOrNull { it is SRVRecord } as? SRVRecord ?: continue val srv =
lookup(instanceFqdn, Type.SRV, resolver).firstOrNull { it is SRVRecord } as? SRVRecord ?: continue
val port = srv.port val port = srv.port
if (port <= 0) continue if (port <= 0) continue
@ -152,9 +160,9 @@ class BridgeDiscovery(
null null
} ?: continue } ?: continue
val txt = lookup(instanceFqdn, Type.TXT).mapNotNull { it as? TXTRecord } val txt = lookup(instanceFqdn, Type.TXT, resolver).mapNotNull { it as? TXTRecord }
val instanceName = decodeInstanceName(instanceFqdn, domain) val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain))
val displayName = txtValue(txt, "displayName") ?: instanceName val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName)
val id = stableId(instanceName, domain) val id = stableId(instanceName, domain)
next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port) next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port)
} }
@ -179,15 +187,41 @@ class BridgeDiscovery(
return raw.removeSuffix(".") return raw.removeSuffix(".")
} }
private fun lookup(name: String, type: Int): List<org.xbill.DNS.Record> { private fun lookup(name: String, type: Int, resolver: org.xbill.DNS.Resolver?): List<org.xbill.DNS.Record> {
return try { return try {
val out = Lookup(name, type).run() ?: return emptyList() val lookup = Lookup(name, type)
if (resolver != null) {
lookup.setResolver(resolver)
lookup.setCache(null)
}
val out = lookup.run() ?: return emptyList()
out.toList() out.toList()
} catch (_: Throwable) { } catch (_: Throwable) {
emptyList() emptyList()
} }
} }
private fun createUnicastResolver(): org.xbill.DNS.Resolver? {
val cm = connectivity ?: return null
val net = cm.activeNetwork ?: return null
val dnsServers = cm.getLinkProperties(net)?.dnsServers ?: return null
val addrs =
dnsServers
.mapNotNull { it.hostAddress }
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
if (addrs.isEmpty()) return null
return try {
ExtendedResolver(addrs.toTypedArray()).apply {
setTimeout(Duration.ofMillis(1500))
}
} catch (_: Throwable) {
null
}
}
private fun txtValue(records: List<TXTRecord>, key: String): String? { private fun txtValue(records: List<TXTRecord>, key: String): String? {
val prefix = "$key=" val prefix = "$key="
for (r in records) { for (r in records) {

View File

@ -0,0 +1,19 @@
package com.steipete.clawdis.node.bridge
import org.junit.Assert.assertEquals
import org.junit.Test
class BonjourEscapesTest {
@Test
fun decodeNoop() {
assertEquals("", BonjourEscapes.decode(""))
assertEquals("hello", BonjourEscapes.decode("hello"))
}
@Test
fun decodeDecodesDecimalEscapes() {
assertEquals("Clawdis Gateway", BonjourEscapes.decode("Clawdis\\032Gateway"))
assertEquals("A B", BonjourEscapes.decode("A\\032B"))
}
}

View File

@ -51,7 +51,9 @@ actor BridgeClient {
nodeId: hello.nodeId, nodeId: hello.nodeId,
displayName: hello.displayName, displayName: hello.displayName,
platform: hello.platform, platform: hello.platform,
version: hello.version), version: hello.version,
deviceFamily: hello.deviceFamily,
modelIdentifier: hello.modelIdentifier),
over: connection) over: connection)
onStatus?("Waiting for approval…") onStatus?("Waiting for approval…")

View File

@ -3,6 +3,7 @@ import Foundation
import Network import Network
import Observation import Observation
import SwiftUI import SwiftUI
import UIKit
@MainActor @MainActor
@Observable @Observable
@ -131,12 +132,43 @@ final class BridgeConnectionController {
displayName: displayName, displayName: displayName,
token: token, token: token,
platform: self.platformString(), platform: self.platformString(),
version: self.appVersion()) version: self.appVersion(),
deviceFamily: self.deviceFamily(),
modelIdentifier: self.modelIdentifier())
} }
private func platformString() -> String { private func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion let v = ProcessInfo.processInfo.operatingSystemVersion
return "iOS \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" let name: String
switch UIDevice.current.userInterfaceIdiom {
case .pad:
name = "iPadOS"
case .phone:
name = "iOS"
default:
name = "iOS"
}
return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
private func deviceFamily() -> String {
switch UIDevice.current.userInterfaceIdiom {
case .pad:
return "iPad"
case .phone:
return "iPhone"
default:
return "iOS"
}
}
private func modelIdentifier() -> String {
var systemInfo = utsname()
uname(&systemInfo)
let machine = withUnsafeBytes(of: &systemInfo.machine) { ptr in
String(decoding: ptr.prefix { $0 != 0 }, as: UTF8.self)
}
return machine.isEmpty ? "unknown" : machine
} }
private func appVersion() -> String { private func appVersion() -> String {

View File

@ -192,7 +192,7 @@ actor GatewayChannelActor {
let clientName = InstanceIdentity.displayName let clientName = InstanceIdentity.displayName
let reqId = UUID().uuidString let reqId = UUID().uuidString
let client: [String: ProtoAnyCodable] = [ var client: [String: ProtoAnyCodable] = [
"name": ProtoAnyCodable(clientName), "name": ProtoAnyCodable(clientName),
"version": ProtoAnyCodable( "version": ProtoAnyCodable(
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"),
@ -200,6 +200,10 @@ actor GatewayChannelActor {
"mode": ProtoAnyCodable("app"), "mode": ProtoAnyCodable("app"),
"instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId),
] ]
client["deviceFamily"] = ProtoAnyCodable("Mac")
if let model = InstanceIdentity.modelIdentifier {
client["modelIdentifier"] = ProtoAnyCodable(model)
}
var params: [String: ProtoAnyCodable] = [ var params: [String: ProtoAnyCodable] = [
"minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),
"maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION),

View File

@ -1,3 +1,4 @@
import Darwin
import Foundation import Foundation
enum InstanceIdentity { enum InstanceIdentity {
@ -30,4 +31,15 @@ enum InstanceIdentity {
} }
return "clawdis-mac" return "clawdis-mac"
}() }()
static let modelIdentifier: String? = {
var size = 0
guard sysctlbyname("hw.model", nil, &size, nil, 0) == 0, size > 1 else { return nil }
var buffer = [CChar](repeating: 0, count: size)
guard sysctlbyname("hw.model", &buffer, &size, nil, 0) == 0 else { return nil }
let s = String(cString: buffer).trimmingCharacters(in: .whitespacesAndNewlines)
return s.isEmpty ? nil : s
}()
} }

View File

@ -70,6 +70,11 @@ struct InstancesSettings: View {
if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) { if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) {
self.label(icon: self.platformIcon(platform), text: prettyPlatform) self.label(icon: self.platformIcon(platform), text: prettyPlatform)
} }
if let deviceText = self.deviceDescription(inst),
let deviceIcon = self.deviceIcon(inst)
{
self.label(icon: deviceIcon, text: deviceText)
}
self.label(icon: "clock", text: inst.lastInputDescription) self.label(icon: "clock", text: inst.lastInputDescription)
if let mode = inst.mode { self.label(icon: "network", text: mode) } if let mode = inst.mode { self.label(icon: "network", text: mode) }
if let reason = inst.reason, !reason.isEmpty { if let reason = inst.reason, !reason.isEmpty {
@ -115,6 +120,28 @@ struct InstancesSettings: View {
} }
} }
private func deviceIcon(_ inst: InstanceInfo) -> String? {
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if family.isEmpty { return nil }
switch family.lowercased() {
case "ipad":
return "ipad"
case "iphone":
return "iphone"
case "mac":
return "laptopcomputer"
default:
return "cpu"
}
}
private func deviceDescription(_ inst: InstanceInfo) -> String? {
let model = inst.modelIdentifier?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if !model.isEmpty { return model }
let family = inst.deviceFamily?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
return family.isEmpty ? nil : family
}
private func prettyPlatform(_ raw: String) -> String? { private func prettyPlatform(_ raw: String) -> String? {
let (prefix, version) = self.parsePlatform(raw) let (prefix, version) = self.parsePlatform(raw)
if prefix.isEmpty { return nil } if prefix.isEmpty { return nil }

View File

@ -10,6 +10,8 @@ struct InstanceInfo: Identifiable, Codable {
let ip: String? let ip: String?
let version: String? let version: String?
let platform: String? let platform: String?
let deviceFamily: String?
let modelIdentifier: String?
let lastInputSeconds: Int? let lastInputSeconds: Int?
let mode: String? let mode: String?
let reason: String? let reason: String?
@ -284,6 +286,8 @@ final class InstancesStore {
ip: entry.ip, ip: entry.ip,
version: entry.version, version: entry.version,
platform: entry.platform, platform: entry.platform,
deviceFamily: entry.devicefamily,
modelIdentifier: entry.modelidentifier,
lastInputSeconds: entry.lastinputseconds, lastInputSeconds: entry.lastinputseconds,
mode: entry.mode, mode: entry.mode,
reason: entry.reason, reason: entry.reason,
@ -308,6 +312,8 @@ extension InstancesStore {
ip: "10.0.0.12", ip: "10.0.0.12",
version: "1.2.3", version: "1.2.3",
platform: "macos 26.2.0", platform: "macos 26.2.0",
deviceFamily: "Mac",
modelIdentifier: "Mac16,6",
lastInputSeconds: 12, lastInputSeconds: 12,
mode: "local", mode: "local",
reason: "preview", reason: "preview",
@ -319,6 +325,8 @@ extension InstancesStore {
ip: "100.64.0.2", ip: "100.64.0.2",
version: "1.2.3", version: "1.2.3",
platform: "linux 6.6.0", platform: "linux 6.6.0",
deviceFamily: "Linux",
modelIdentifier: "x86_64",
lastInputSeconds: 45, lastInputSeconds: 45,
mode: "remote", mode: "remote",
reason: "preview", reason: "preview",

View File

@ -35,6 +35,7 @@ final class PresenceReporter {
let host = InstanceIdentity.displayName let host = InstanceIdentity.displayName
let ip = Self.primaryIPv4Address() ?? "ip-unknown" let ip = Self.primaryIPv4Address() ?? "ip-unknown"
let version = Self.appVersionString() let version = Self.appVersionString()
let platform = Self.platformString()
let lastInput = Self.lastInputSeconds() let lastInput = Self.lastInputSeconds()
let text = Self.composePresenceSummary(mode: mode, reason: reason) let text = Self.composePresenceSummary(mode: mode, reason: reason)
var params: [String: AnyHashable] = [ var params: [String: AnyHashable] = [
@ -43,8 +44,11 @@ final class PresenceReporter {
"ip": AnyHashable(ip), "ip": AnyHashable(ip),
"mode": AnyHashable(mode), "mode": AnyHashable(mode),
"version": AnyHashable(version), "version": AnyHashable(version),
"platform": AnyHashable(platform),
"deviceFamily": AnyHashable("Mac"),
"reason": AnyHashable(reason), "reason": AnyHashable(reason),
] ]
if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) }
if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) } if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) }
do { do {
try await ControlChannel.shared.sendSystemEvent(text, params: params) try await ControlChannel.shared.sendSystemEvent(text, params: params)
@ -78,6 +82,11 @@ final class PresenceReporter {
return version return version
} }
private static func platformString() -> String {
let v = ProcessInfo.processInfo.operatingSystemVersion
return "macos \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
}
private static func lastInputSeconds() -> Int? { private static func lastInputSeconds() -> Int? {
let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null
let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent)

View File

@ -168,6 +168,8 @@ public struct PresenceEntry: Codable {
public let ip: String? public let ip: String?
public let version: String? public let version: String?
public let platform: String? public let platform: String?
public let devicefamily: String?
public let modelidentifier: String?
public let mode: String? public let mode: String?
public let lastinputseconds: Int? public let lastinputseconds: Int?
public let reason: String? public let reason: String?
@ -181,6 +183,8 @@ public struct PresenceEntry: Codable {
ip: String?, ip: String?,
version: String?, version: String?,
platform: String?, platform: String?,
devicefamily: String?,
modelidentifier: String?,
mode: String?, mode: String?,
lastinputseconds: Int?, lastinputseconds: Int?,
reason: String?, reason: String?,
@ -193,6 +197,8 @@ public struct PresenceEntry: Codable {
self.ip = ip self.ip = ip
self.version = version self.version = version
self.platform = platform self.platform = platform
self.devicefamily = devicefamily
self.modelidentifier = modelidentifier
self.mode = mode self.mode = mode
self.lastinputseconds = lastinputseconds self.lastinputseconds = lastinputseconds
self.reason = reason self.reason = reason
@ -206,6 +212,8 @@ public struct PresenceEntry: Codable {
case ip case ip
case version case version
case platform case platform
case devicefamily = "deviceFamily"
case modelidentifier = "modelIdentifier"
case mode case mode
case lastinputseconds = "lastInputSeconds" case lastinputseconds = "lastInputSeconds"
case reason case reason

View File

@ -63,6 +63,8 @@ public struct BridgeHello: Codable, Sendable {
public let token: String? public let token: String?
public let platform: String? public let platform: String?
public let version: String? public let version: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public init( public init(
type: String = "hello", type: String = "hello",
@ -70,7 +72,9 @@ public struct BridgeHello: Codable, Sendable {
displayName: String?, displayName: String?,
token: String?, token: String?,
platform: String?, platform: String?,
version: String?) version: String?,
deviceFamily: String? = nil,
modelIdentifier: String? = nil)
{ {
self.type = type self.type = type
self.nodeId = nodeId self.nodeId = nodeId
@ -78,6 +82,8 @@ public struct BridgeHello: Codable, Sendable {
self.token = token self.token = token
self.platform = platform self.platform = platform
self.version = version self.version = version
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
} }
} }
@ -97,6 +103,8 @@ public struct BridgePairRequest: Codable, Sendable {
public let displayName: String? public let displayName: String?
public let platform: String? public let platform: String?
public let version: String? public let version: String?
public let deviceFamily: String?
public let modelIdentifier: String?
public let remoteAddress: String? public let remoteAddress: String?
public init( public init(
@ -105,6 +113,8 @@ public struct BridgePairRequest: Codable, Sendable {
displayName: String?, displayName: String?,
platform: String?, platform: String?,
version: String?, version: String?,
deviceFamily: String? = nil,
modelIdentifier: String? = nil,
remoteAddress: String? = nil) remoteAddress: String? = nil)
{ {
self.type = type self.type = type
@ -112,6 +122,8 @@ public struct BridgePairRequest: Codable, Sendable {
self.displayName = displayName self.displayName = displayName
self.platform = platform self.platform = platform
self.version = version self.version = version
self.deviceFamily = deviceFamily
self.modelIdentifier = modelIdentifier
self.remoteAddress = remoteAddress self.remoteAddress = remoteAddress
} }
} }

View File

@ -36,12 +36,12 @@ pnpm clawdis gateway --force
- If a token is configured, clients must include it in `connect.params.auth.token` even over the tunnel. - If a token is configured, clients must include it in `connect.params.auth.token` even over the tunnel.
## Protocol (operator view) ## Protocol (operator view)
- Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,mode,instanceId}, caps, auth?, locale?, userAgent? } }`. - Mandatory first frame from client: `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth?, locale?, userAgent? } }`.
- Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes). - Gateway replies `res {type:"res", id, ok:true, payload:hello-ok }` (or `ok:false` with an error, then closes).
- After handshake: - After handshake:
- Requests: `{type:"req", id, method, params}``{type:"res", id, ok, payload|error}` - Requests: `{type:"req", id, method, params}``{type:"res", id, ok, payload|error}`
- Events: `{type:"event", event, payload, seq?, stateVersion?}` - Events: `{type:"event", event, payload, seq?, stateVersion?}`
- Structured presence entries: `{host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`. - Structured presence entries: `{host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId? }`.
- `agent` responses are two-stage: first `res` ack `{runId,status:"accepted"}`, then a final `res` `{runId,status:"ok"|"error",summary}` after the run finishes; streamed output arrives as `event:"agent"`. - `agent` responses are two-stage: first `res` ack `{runId,status:"accepted"}`, then a final `res` `{runId,status:"ok"|"error",summary}` after the run finishes; streamed output arrives as `event:"agent"`.
## Methods (initial set) ## Methods (initial set)

View File

@ -20,6 +20,8 @@ Presence entries are structured objects with (some) fields:
- `host`: a human-readable name (often the machine name) - `host`: a human-readable name (often the machine name)
- `ip`: best-effort IP address (may be missing or stale) - `ip`: best-effort IP address (may be missing or stale)
- `version`: client version string - `version`: client version string
- `deviceFamily` (optional): hardware family like `iPad`, `iPhone`, `Mac`
- `modelIdentifier` (optional): hardware model identifier like `iPad16,6` or `Mac16,6`
- `mode`: e.g. `gateway`, `app`, `webchat`, `cli` - `mode`: e.g. `gateway`, `app`, `webchat`, `cli`
- `lastInputSeconds` (optional): “seconds since last user input” for that client machine - `lastInputSeconds` (optional): “seconds since last user input” for that client machine
- `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh` - `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh`

View File

@ -23,7 +23,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
## Phase 1 — Protocol specification ## Phase 1 — Protocol specification
- Frames (WS text JSON, all with explicit `type`): - Frames (WS text JSON, all with explicit `type`):
- `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}` - `req {type:"req", id, method:"connect", params:{minProtocol,maxProtocol,client:{name,version,platform,deviceFamily?,modelIdentifier?,mode,instanceId}, caps, auth:{token?}, locale?, userAgent?}}`
- `res {type:"res", id, ok:true, payload: hello-ok }` (or `ok:false` then close) - `res {type:"res", id, ok:true, payload: hello-ok }` (or `ok:false` then close)
- `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}` - `hello-ok {type:"hello-ok", protocol:<chosen>, server:{version,commit,host,connId}, features:{methods,events}, snapshot:{presence[], health, stateVersion:{presence,health}, uptimeMs}, policy:{maxPayload, maxBufferedBytes, tickIntervalMs}}`
- `req {type:"req", id, method, params?}` - `req {type:"req", id, method, params?}`
@ -31,7 +31,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway,
- `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent) - `event {type:"event", event, payload, seq?, stateVersion?}` (presence/tick/shutdown/agent)
- `close` (standard WS close codes; policy uses 1008 for slow consumer/unauthorized, 1012/1001 for restart) - `close` (standard WS close codes; policy uses 1008 for slow consumer/unauthorized, 1012/1001 for restart)
- Payload types: - Payload types:
- `PresenceEntry {host, ip, version, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}` - `PresenceEntry {host, ip, version, platform?, deviceFamily?, modelIdentifier?, mode, lastInputSeconds?, ts, reason?, tags?[], instanceId?}`
- `HealthSnapshot` (match existing `clawdis health --json` fields) - `HealthSnapshot` (match existing `clawdis health --json` fields)
- `AgentEvent` (streamed tool/output; `{runId, seq, stream, data, ts}`) - `AgentEvent` (streamed tool/output; `{runId, seq, stream, data, ts}`)
- `TickEvent {ts}` - `TickEvent {ts}`

View File

@ -85,6 +85,9 @@ describe("gateway SIGTERM", () => {
env: { env: {
...process.env, ...process.env,
CLAWDIS_SKIP_PROVIDERS: "1", CLAWDIS_SKIP_PROVIDERS: "1",
// Avoid port collisions with other test processes that may also start a bridge server.
CLAWDIS_BRIDGE_HOST: "127.0.0.1",
CLAWDIS_BRIDGE_PORT: "0",
}, },
stdio: ["ignore", "pipe", "pipe"], stdio: ["ignore", "pipe", "pipe"],
}, },

View File

@ -8,6 +8,8 @@ export const PresenceEntrySchema = Type.Object(
ip: Type.Optional(NonEmptyString), ip: Type.Optional(NonEmptyString),
version: Type.Optional(NonEmptyString), version: Type.Optional(NonEmptyString),
platform: Type.Optional(NonEmptyString), platform: Type.Optional(NonEmptyString),
deviceFamily: Type.Optional(NonEmptyString),
modelIdentifier: Type.Optional(NonEmptyString),
mode: Type.Optional(NonEmptyString), mode: Type.Optional(NonEmptyString),
lastInputSeconds: Type.Optional(Type.Integer({ minimum: 0 })), lastInputSeconds: Type.Optional(Type.Integer({ minimum: 0 })),
reason: Type.Optional(NonEmptyString), reason: Type.Optional(NonEmptyString),
@ -63,6 +65,8 @@ export const ConnectParamsSchema = Type.Object(
name: NonEmptyString, name: NonEmptyString,
version: NonEmptyString, version: NonEmptyString,
platform: NonEmptyString, platform: NonEmptyString,
deviceFamily: Type.Optional(NonEmptyString),
modelIdentifier: Type.Optional(NonEmptyString),
mode: NonEmptyString, mode: NonEmptyString,
instanceId: Type.Optional(NonEmptyString), instanceId: Type.Optional(NonEmptyString),
}, },

View File

@ -2109,6 +2109,8 @@ describe("gateway server", () => {
name: "fingerprint", name: "fingerprint",
version: "9.9.9", version: "9.9.9",
platform: "test", platform: "test",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
mode: "ui", mode: "ui",
instanceId: "abc", instanceId: "abc",
}, },
@ -2133,6 +2135,8 @@ describe("gateway server", () => {
expect(clientEntry?.host).toBe("fingerprint"); expect(clientEntry?.host).toBe("fingerprint");
expect(clientEntry?.version).toBe("9.9.9"); expect(clientEntry?.version).toBe("9.9.9");
expect(clientEntry?.mode).toBe("ui"); expect(clientEntry?.mode).toBe("ui");
expect(clientEntry?.deviceFamily).toBe("iPad");
expect(clientEntry?.modelIdentifier).toBe("iPad16,6");
ws.close(); ws.close();
await server.close(); await server.close();

View File

@ -1300,12 +1300,16 @@ export async function startGatewayServer(
const ip = node.remoteIp?.trim(); const ip = node.remoteIp?.trim();
const version = node.version?.trim() || "unknown"; const version = node.version?.trim() || "unknown";
const platform = node.platform?.trim() || undefined; 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 iris-connected`; const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason iris-connected`;
upsertPresence(node.nodeId, { upsertPresence(node.nodeId, {
host, host,
ip, ip,
version, version,
platform, platform,
deviceFamily,
modelIdentifier,
mode: "remote", mode: "remote",
reason: "iris-connected", reason: "iris-connected",
lastInputSeconds: 0, lastInputSeconds: 0,
@ -1342,12 +1346,16 @@ export async function startGatewayServer(
const ip = node.remoteIp?.trim(); const ip = node.remoteIp?.trim();
const version = node.version?.trim() || "unknown"; const version = node.version?.trim() || "unknown";
const platform = node.platform?.trim() || undefined; 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 iris-disconnected`; const text = `Node: ${host}${ip ? ` (${ip})` : ""} · app ${version} · last input 0s ago · mode remote · reason iris-disconnected`;
upsertPresence(node.nodeId, { upsertPresence(node.nodeId, {
host, host,
ip, ip,
version, version,
platform, platform,
deviceFamily,
modelIdentifier,
mode: "remote", mode: "remote",
reason: "iris-disconnected", reason: "iris-disconnected",
lastInputSeconds: 0, lastInputSeconds: 0,
@ -1743,6 +1751,8 @@ export async function startGatewayServer(
ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr, ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr,
version: connectParams.client.version, version: connectParams.client.version,
platform: connectParams.client.platform, platform: connectParams.client.platform,
deviceFamily: connectParams.client.deviceFamily,
modelIdentifier: connectParams.client.modelIdentifier,
mode: connectParams.client.mode, mode: connectParams.client.mode,
instanceId: connectParams.client.instanceId, instanceId: connectParams.client.instanceId,
reason: "connect", reason: "connect",
@ -2424,6 +2434,14 @@ export async function startGatewayServer(
typeof params.version === "string" ? params.version : undefined; typeof params.version === "string" ? params.version : undefined;
const platform = const platform =
typeof params.platform === "string" ? params.platform : undefined; typeof params.platform === "string" ? params.platform : undefined;
const deviceFamily =
typeof params.deviceFamily === "string"
? params.deviceFamily
: undefined;
const modelIdentifier =
typeof params.modelIdentifier === "string"
? params.modelIdentifier
: undefined;
const lastInputSeconds = const lastInputSeconds =
typeof params.lastInputSeconds === "number" && typeof params.lastInputSeconds === "number" &&
Number.isFinite(params.lastInputSeconds) Number.isFinite(params.lastInputSeconds)
@ -2444,6 +2462,8 @@ export async function startGatewayServer(
mode, mode,
version, version,
platform, platform,
deviceFamily,
modelIdentifier,
lastInputSeconds, lastInputSeconds,
reason, reason,
tags, tags,

View File

@ -225,6 +225,8 @@ describe("node bridge server", () => {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string; remoteIp?: string;
} | null = null; } | null = null;
@ -233,6 +235,8 @@ describe("node bridge server", () => {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string; remoteIp?: string;
} | null = null; } | null = null;
@ -262,6 +266,8 @@ describe("node bridge server", () => {
displayName: "Iris", displayName: "Iris",
platform: "ios", platform: "ios",
version: "1.0", version: "1.0",
deviceFamily: "iPad",
modelIdentifier: "iPad16,6",
}); });
// Approve the pending request from the gateway side. // Approve the pending request from the gateway side.
@ -296,6 +302,8 @@ describe("node bridge server", () => {
displayName: "Different name", displayName: "Different name",
platform: "ios", platform: "ios",
version: "2.0", version: "2.0",
deviceFamily: "iPad",
modelIdentifier: "iPad99,1",
}); });
const line3 = JSON.parse(await readLine2()) as { type: string }; const line3 = JSON.parse(await readLine2()) as { type: string };
expect(line3.type).toBe("hello-ok"); expect(line3.type).toBe("hello-ok");
@ -310,6 +318,8 @@ describe("node bridge server", () => {
expect(lastAuthed?.displayName).toBe("Iris"); expect(lastAuthed?.displayName).toBe("Iris");
expect(lastAuthed?.platform).toBe("ios"); expect(lastAuthed?.platform).toBe("ios");
expect(lastAuthed?.version).toBe("1.0"); expect(lastAuthed?.version).toBe("1.0");
expect(lastAuthed?.deviceFamily).toBe("iPad");
expect(lastAuthed?.modelIdentifier).toBe("iPad16,6");
expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true); expect(lastAuthed?.remoteIp?.includes("127.0.0.1")).toBe(true);
socket2.destroy(); socket2.destroy();

View File

@ -17,6 +17,8 @@ type BridgeHelloFrame = {
token?: string; token?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
}; };
type BridgePairRequestFrame = { type BridgePairRequestFrame = {
@ -25,6 +27,8 @@ type BridgePairRequestFrame = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteAddress?: string; remoteAddress?: string;
}; };
@ -108,6 +112,8 @@ export type NodeBridgeClientInfo = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string; remoteIp?: string;
}; };
@ -263,6 +269,8 @@ export async function startNodeBridgeServer(
displayName: verified.node.displayName ?? hello.displayName, displayName: verified.node.displayName ?? hello.displayName,
platform: verified.node.platform ?? hello.platform, platform: verified.node.platform ?? hello.platform,
version: verified.node.version ?? hello.version, version: verified.node.version ?? hello.version,
deviceFamily: verified.node.deviceFamily ?? hello.deviceFamily,
modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier,
remoteIp: remoteAddress, remoteIp: remoteAddress,
}; };
connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); connections.set(nodeId, { socket, nodeInfo, invokeWaiters });
@ -319,6 +327,8 @@ export async function startNodeBridgeServer(
displayName: req.displayName, displayName: req.displayName,
platform: req.platform, platform: req.platform,
version: req.version, version: req.version,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
remoteIp: remoteAddress, remoteIp: remoteAddress,
}, },
opts.pairingBaseDir, opts.pairingBaseDir,
@ -347,6 +357,8 @@ export async function startNodeBridgeServer(
displayName: req.displayName, displayName: req.displayName,
platform: req.platform, platform: req.platform,
version: req.version, version: req.version,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
remoteIp: remoteAddress, remoteIp: remoteAddress,
}; };
connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); connections.set(nodeId, { socket, nodeInfo, invokeWaiters });

View File

@ -9,6 +9,8 @@ export type NodePairingPendingRequest = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string; remoteIp?: string;
isRepair?: boolean; isRepair?: boolean;
ts: number; ts: number;
@ -20,6 +22,8 @@ export type NodePairingPairedNode = {
displayName?: string; displayName?: string;
platform?: string; platform?: string;
version?: string; version?: string;
deviceFamily?: string;
modelIdentifier?: string;
remoteIp?: string; remoteIp?: string;
createdAtMs: number; createdAtMs: number;
approvedAtMs: number; approvedAtMs: number;
@ -172,6 +176,8 @@ export async function requestNodePairing(
displayName: req.displayName, displayName: req.displayName,
platform: req.platform, platform: req.platform,
version: req.version, version: req.version,
deviceFamily: req.deviceFamily,
modelIdentifier: req.modelIdentifier,
remoteIp: req.remoteIp, remoteIp: req.remoteIp,
isRepair, isRepair,
ts: Date.now(), ts: Date.now(),
@ -199,6 +205,8 @@ export async function approveNodePairing(
displayName: pending.displayName, displayName: pending.displayName,
platform: pending.platform, platform: pending.platform,
version: pending.version, version: pending.version,
deviceFamily: pending.deviceFamily,
modelIdentifier: pending.modelIdentifier,
remoteIp: pending.remoteIp, remoteIp: pending.remoteIp,
createdAtMs: existing?.createdAtMs ?? now, createdAtMs: existing?.createdAtMs ?? now,
approvedAtMs: now, approvedAtMs: now,

View File

@ -5,6 +5,8 @@ export type SystemPresence = {
ip?: string; ip?: string;
version?: string; version?: string;
platform?: string; platform?: string;
deviceFamily?: string;
modelIdentifier?: string;
lastInputSeconds?: number; lastInputSeconds?: number;
mode?: string; mode?: string;
reason?: string; reason?: string;
@ -54,12 +56,20 @@ function initSelfPresence() {
if (p === "win32") return `windows ${rel}`; if (p === "win32") return `windows ${rel}`;
return `${p} ${rel}`; return `${p} ${rel}`;
})(); })();
const deviceFamily = (() => {
const p = os.platform();
if (p === "darwin") return "Mac";
if (p === "win32") return "Windows";
if (p === "linux") return "Linux";
return p;
})();
const text = `Gateway: ${host}${ip ? ` (${ip})` : ""} · app ${version} · mode gateway · reason self`; const text = `Gateway: ${host}${ip ? ` (${ip})` : ""} · app ${version} · mode gateway · reason self`;
const selfEntry: SystemPresence = { const selfEntry: SystemPresence = {
host, host,
ip, ip,
version, version,
platform, platform,
deviceFamily,
mode: "gateway", mode: "gateway",
reason: "self", reason: "self",
text, text,
@ -123,6 +133,8 @@ type SystemPresencePayload = {
ip?: string; ip?: string;
version?: string; version?: string;
platform?: string; platform?: string;
deviceFamily?: string;
modelIdentifier?: string;
lastInputSeconds?: number; lastInputSeconds?: number;
mode?: string; mode?: string;
reason?: string; reason?: string;
@ -147,6 +159,8 @@ export function updateSystemPresence(payload: SystemPresencePayload) {
ip: payload.ip ?? parsed.ip ?? existing.ip, ip: payload.ip ?? parsed.ip ?? existing.ip,
version: payload.version ?? parsed.version ?? existing.version, version: payload.version ?? parsed.version ?? existing.version,
platform: payload.platform ?? existing.platform, platform: payload.platform ?? existing.platform,
deviceFamily: payload.deviceFamily ?? existing.deviceFamily,
modelIdentifier: payload.modelIdentifier ?? existing.modelIdentifier,
mode: payload.mode ?? parsed.mode ?? existing.mode, mode: payload.mode ?? parsed.mode ?? existing.mode,
lastInputSeconds: lastInputSeconds:
payload.lastInputSeconds ?? payload.lastInputSeconds ??