From 557ffdbe35b2f06c687072064973a9268d66c69b Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 17 Dec 2025 20:25:40 +0100 Subject: [PATCH] Discovery: wide-area bridge DNS-SD # Conflicts: # apps/ios/Sources/Bridge/BridgeDiscoveryModel.swift # src/cli/dns-cli.ts --- Peekaboo | 2 +- .../clawdis/node/bridge/BonjourEscapes.kt | 30 +++++++++++ .../clawdis/node/bridge/BridgeDiscovery.kt | 52 +++++++++++++++---- .../clawdis/node/bridge/BonjourEscapesTest.kt | 19 +++++++ apps/ios/Sources/Bridge/BridgeClient.swift | 4 +- .../Bridge/BridgeConnectionController.swift | 36 ++++++++++++- .../Sources/Clawdis/GatewayChannel.swift | 6 ++- .../Sources/Clawdis/InstanceIdentity.swift | 12 +++++ .../Sources/Clawdis/InstancesSettings.swift | 27 ++++++++++ .../Sources/Clawdis/InstancesStore.swift | 8 +++ .../Sources/Clawdis/PresenceReporter.swift | 9 ++++ .../ClawdisProtocol/GatewayModels.swift | 8 +++ .../Sources/ClawdisKit/BridgeFrames.swift | 14 ++++- docs/gateway.md | 4 +- docs/presence.md | 2 + docs/refactor/new-arch.md | 4 +- src/cli/gateway.sigterm.test.ts | 3 ++ src/gateway/protocol/schema.ts | 4 ++ src/gateway/server.test.ts | 4 ++ src/gateway/server.ts | 20 +++++++ src/infra/bridge/server.test.ts | 10 ++++ src/infra/bridge/server.ts | 12 +++++ src/infra/node-pairing.ts | 8 +++ src/infra/system-presence.ts | 14 +++++ 24 files changed, 293 insertions(+), 19 deletions(-) create mode 100644 apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BonjourEscapes.kt create mode 100644 apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BonjourEscapesTest.kt diff --git a/Peekaboo b/Peekaboo index 74a1c7570..4ff4d3db2 160000 --- a/Peekaboo +++ b/Peekaboo @@ -1 +1 @@ -Subproject commit 74a1c75702760f3865f89d1193e9c76f7f38275f +Subproject commit 4ff4d3db244f9794a7af1b728a6104929e95250b diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BonjourEscapes.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BonjourEscapes.kt new file mode 100644 index 000000000..5c875ba1a --- /dev/null +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BonjourEscapes.kt @@ -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() + } +} diff --git a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt index 9725d4b25..d3f2f0acd 100644 --- a/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt +++ b/apps/android/app/src/main/java/com/steipete/clawdis/node/bridge/BridgeDiscovery.kt @@ -1,10 +1,12 @@ package com.steipete.clawdis.node.bridge import android.content.Context +import android.net.ConnectivityManager import android.net.nsd.NsdManager import android.net.nsd.NsdServiceInfo import android.os.Build import java.net.InetAddress +import java.time.Duration import java.util.concurrent.ConcurrentHashMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import org.xbill.DNS.ExtendedResolver import org.xbill.DNS.Lookup import org.xbill.DNS.PTRRecord import org.xbill.DNS.SRVRecord @@ -25,6 +28,7 @@ class BridgeDiscovery( private val scope: CoroutineScope, ) { private val nsd = context.getSystemService(NsdManager::class.java) + private val connectivity = context.getSystemService(ConnectivityManager::class.java) private val serviceType = "_clawdis-bridge._tcp." private val wideAreaDomain = "clawdis.internal." @@ -100,8 +104,10 @@ class BridgeDiscovery( val port = resolved.port if (port <= 0) return - val displayName = txt(resolved, "displayName") ?: resolved.serviceName - val id = stableId(resolved.serviceName, "local.") + val rawServiceName = resolved.serviceName + 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) publish() } @@ -133,13 +139,15 @@ class BridgeDiscovery( } private suspend fun refreshUnicast(domain: String) { + val resolver = createUnicastResolver() 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() for (ptr in ptrRecords) { 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 if (port <= 0) continue @@ -152,9 +160,9 @@ class BridgeDiscovery( null } ?: continue - val txt = lookup(instanceFqdn, Type.TXT).mapNotNull { it as? TXTRecord } - val instanceName = decodeInstanceName(instanceFqdn, domain) - val displayName = txtValue(txt, "displayName") ?: instanceName + val txt = lookup(instanceFqdn, Type.TXT, resolver).mapNotNull { it as? TXTRecord } + val instanceName = BonjourEscapes.decode(decodeInstanceName(instanceFqdn, domain)) + val displayName = BonjourEscapes.decode(txtValue(txt, "displayName") ?: instanceName) val id = stableId(instanceName, domain) next[id] = BridgeEndpoint(stableId = id, name = displayName, host = host, port = port) } @@ -179,15 +187,41 @@ class BridgeDiscovery( return raw.removeSuffix(".") } - private fun lookup(name: String, type: Int): List { + private fun lookup(name: String, type: Int, resolver: org.xbill.DNS.Resolver?): List { 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() } catch (_: Throwable) { 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, key: String): String? { val prefix = "$key=" for (r in records) { diff --git a/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BonjourEscapesTest.kt b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BonjourEscapesTest.kt new file mode 100644 index 000000000..546430202 --- /dev/null +++ b/apps/android/app/src/test/java/com/steipete/clawdis/node/bridge/BonjourEscapesTest.kt @@ -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")) + } +} + diff --git a/apps/ios/Sources/Bridge/BridgeClient.swift b/apps/ios/Sources/Bridge/BridgeClient.swift index 8933a33bc..5b2c6fc9d 100644 --- a/apps/ios/Sources/Bridge/BridgeClient.swift +++ b/apps/ios/Sources/Bridge/BridgeClient.swift @@ -51,7 +51,9 @@ actor BridgeClient { nodeId: hello.nodeId, displayName: hello.displayName, platform: hello.platform, - version: hello.version), + version: hello.version, + deviceFamily: hello.deviceFamily, + modelIdentifier: hello.modelIdentifier), over: connection) onStatus?("Waiting for approval…") diff --git a/apps/ios/Sources/Bridge/BridgeConnectionController.swift b/apps/ios/Sources/Bridge/BridgeConnectionController.swift index 2e6e4d01a..4e654fee4 100644 --- a/apps/ios/Sources/Bridge/BridgeConnectionController.swift +++ b/apps/ios/Sources/Bridge/BridgeConnectionController.swift @@ -3,6 +3,7 @@ import Foundation import Network import Observation import SwiftUI +import UIKit @MainActor @Observable @@ -131,12 +132,43 @@ final class BridgeConnectionController { displayName: displayName, token: token, platform: self.platformString(), - version: self.appVersion()) + version: self.appVersion(), + deviceFamily: self.deviceFamily(), + modelIdentifier: self.modelIdentifier()) } private func platformString() -> String { 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 { diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index 4fe68858d..c2e155a53 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -192,7 +192,7 @@ actor GatewayChannelActor { let clientName = InstanceIdentity.displayName let reqId = UUID().uuidString - let client: [String: ProtoAnyCodable] = [ + var client: [String: ProtoAnyCodable] = [ "name": ProtoAnyCodable(clientName), "version": ProtoAnyCodable( Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), @@ -200,6 +200,10 @@ actor GatewayChannelActor { "mode": ProtoAnyCodable("app"), "instanceId": ProtoAnyCodable(InstanceIdentity.instanceId), ] + client["deviceFamily"] = ProtoAnyCodable("Mac") + if let model = InstanceIdentity.modelIdentifier { + client["modelIdentifier"] = ProtoAnyCodable(model) + } var params: [String: ProtoAnyCodable] = [ "minProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), "maxProtocol": ProtoAnyCodable(GATEWAY_PROTOCOL_VERSION), diff --git a/apps/macos/Sources/Clawdis/InstanceIdentity.swift b/apps/macos/Sources/Clawdis/InstanceIdentity.swift index cf35b88fb..35902ce00 100644 --- a/apps/macos/Sources/Clawdis/InstanceIdentity.swift +++ b/apps/macos/Sources/Clawdis/InstanceIdentity.swift @@ -1,3 +1,4 @@ +import Darwin import Foundation enum InstanceIdentity { @@ -30,4 +31,15 @@ enum InstanceIdentity { } 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 + }() } diff --git a/apps/macos/Sources/Clawdis/InstancesSettings.swift b/apps/macos/Sources/Clawdis/InstancesSettings.swift index fb656e225..5276aa5ba 100644 --- a/apps/macos/Sources/Clawdis/InstancesSettings.swift +++ b/apps/macos/Sources/Clawdis/InstancesSettings.swift @@ -70,6 +70,11 @@ struct InstancesSettings: View { if let platform = inst.platform, let prettyPlatform = self.prettyPlatform(platform) { 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) if let mode = inst.mode { self.label(icon: "network", text: mode) } 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? { let (prefix, version) = self.parsePlatform(raw) if prefix.isEmpty { return nil } diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift index e48f326fc..517185504 100644 --- a/apps/macos/Sources/Clawdis/InstancesStore.swift +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -10,6 +10,8 @@ struct InstanceInfo: Identifiable, Codable { let ip: String? let version: String? let platform: String? + let deviceFamily: String? + let modelIdentifier: String? let lastInputSeconds: Int? let mode: String? let reason: String? @@ -284,6 +286,8 @@ final class InstancesStore { ip: entry.ip, version: entry.version, platform: entry.platform, + deviceFamily: entry.devicefamily, + modelIdentifier: entry.modelidentifier, lastInputSeconds: entry.lastinputseconds, mode: entry.mode, reason: entry.reason, @@ -308,6 +312,8 @@ extension InstancesStore { ip: "10.0.0.12", version: "1.2.3", platform: "macos 26.2.0", + deviceFamily: "Mac", + modelIdentifier: "Mac16,6", lastInputSeconds: 12, mode: "local", reason: "preview", @@ -319,6 +325,8 @@ extension InstancesStore { ip: "100.64.0.2", version: "1.2.3", platform: "linux 6.6.0", + deviceFamily: "Linux", + modelIdentifier: "x86_64", lastInputSeconds: 45, mode: "remote", reason: "preview", diff --git a/apps/macos/Sources/Clawdis/PresenceReporter.swift b/apps/macos/Sources/Clawdis/PresenceReporter.swift index dfe8d5efa..4a03edd34 100644 --- a/apps/macos/Sources/Clawdis/PresenceReporter.swift +++ b/apps/macos/Sources/Clawdis/PresenceReporter.swift @@ -35,6 +35,7 @@ final class PresenceReporter { let host = InstanceIdentity.displayName let ip = Self.primaryIPv4Address() ?? "ip-unknown" let version = Self.appVersionString() + let platform = Self.platformString() let lastInput = Self.lastInputSeconds() let text = Self.composePresenceSummary(mode: mode, reason: reason) var params: [String: AnyHashable] = [ @@ -43,8 +44,11 @@ final class PresenceReporter { "ip": AnyHashable(ip), "mode": AnyHashable(mode), "version": AnyHashable(version), + "platform": AnyHashable(platform), + "deviceFamily": AnyHashable("Mac"), "reason": AnyHashable(reason), ] + if let model = InstanceIdentity.modelIdentifier { params["modelIdentifier"] = AnyHashable(model) } if let lastInput { params["lastInputSeconds"] = AnyHashable(lastInput) } do { try await ControlChannel.shared.sendSystemEvent(text, params: params) @@ -78,6 +82,11 @@ final class PresenceReporter { 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? { let anyEvent = CGEventType(rawValue: UInt32.max) ?? .null let seconds = CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: anyEvent) diff --git a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift index 65e0dc497..97ffa4cd8 100644 --- a/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift +++ b/apps/macos/Sources/ClawdisProtocol/GatewayModels.swift @@ -168,6 +168,8 @@ public struct PresenceEntry: Codable { public let ip: String? public let version: String? public let platform: String? + public let devicefamily: String? + public let modelidentifier: String? public let mode: String? public let lastinputseconds: Int? public let reason: String? @@ -181,6 +183,8 @@ public struct PresenceEntry: Codable { ip: String?, version: String?, platform: String?, + devicefamily: String?, + modelidentifier: String?, mode: String?, lastinputseconds: Int?, reason: String?, @@ -193,6 +197,8 @@ public struct PresenceEntry: Codable { self.ip = ip self.version = version self.platform = platform + self.devicefamily = devicefamily + self.modelidentifier = modelidentifier self.mode = mode self.lastinputseconds = lastinputseconds self.reason = reason @@ -206,6 +212,8 @@ public struct PresenceEntry: Codable { case ip case version case platform + case devicefamily = "deviceFamily" + case modelidentifier = "modelIdentifier" case mode case lastinputseconds = "lastInputSeconds" case reason diff --git a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift index 74bf435e3..666d2df25 100644 --- a/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift +++ b/apps/shared/ClawdisKit/Sources/ClawdisKit/BridgeFrames.swift @@ -63,6 +63,8 @@ public struct BridgeHello: Codable, Sendable { public let token: String? public let platform: String? public let version: String? + public let deviceFamily: String? + public let modelIdentifier: String? public init( type: String = "hello", @@ -70,7 +72,9 @@ public struct BridgeHello: Codable, Sendable { displayName: String?, token: String?, platform: String?, - version: String?) + version: String?, + deviceFamily: String? = nil, + modelIdentifier: String? = nil) { self.type = type self.nodeId = nodeId @@ -78,6 +82,8 @@ public struct BridgeHello: Codable, Sendable { self.token = token self.platform = platform self.version = version + self.deviceFamily = deviceFamily + self.modelIdentifier = modelIdentifier } } @@ -97,6 +103,8 @@ public struct BridgePairRequest: Codable, Sendable { public let displayName: String? public let platform: String? public let version: String? + public let deviceFamily: String? + public let modelIdentifier: String? public let remoteAddress: String? public init( @@ -105,6 +113,8 @@ public struct BridgePairRequest: Codable, Sendable { displayName: String?, platform: String?, version: String?, + deviceFamily: String? = nil, + modelIdentifier: String? = nil, remoteAddress: String? = nil) { self.type = type @@ -112,6 +122,8 @@ public struct BridgePairRequest: Codable, Sendable { self.displayName = displayName self.platform = platform self.version = version + self.deviceFamily = deviceFamily + self.modelIdentifier = modelIdentifier self.remoteAddress = remoteAddress } } diff --git a/docs/gateway.md b/docs/gateway.md index 81fb750f2..0e5bde12d 100644 --- a/docs/gateway.md +++ b/docs/gateway.md @@ -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. ## 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). - After handshake: - Requests: `{type:"req", id, method, params}` → `{type:"res", id, ok, payload|error}` - 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"`. ## Methods (initial set) diff --git a/docs/presence.md b/docs/presence.md index 9d65c7eab..01081387b 100644 --- a/docs/presence.md +++ b/docs/presence.md @@ -20,6 +20,8 @@ Presence entries are structured objects with (some) fields: - `host`: a human-readable name (often the machine name) - `ip`: best-effort IP address (may be missing or stale) - `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` - `lastInputSeconds` (optional): “seconds since last user input” for that client machine - `reason`: a short marker like `self`, `connect`, `periodic`, `instances-refresh` diff --git a/docs/refactor/new-arch.md b/docs/refactor/new-arch.md index 0217ae41b..6fcb20769 100644 --- a/docs/refactor/new-arch.md +++ b/docs/refactor/new-arch.md @@ -23,7 +23,7 @@ Goal: replace legacy gateway/stdin/TCP control with a single WebSocket Gateway, ## Phase 1 — Protocol specification - 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) - `hello-ok {type:"hello-ok", protocol:, 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?}` @@ -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) - `close` (standard WS close codes; policy uses 1008 for slow consumer/unauthorized, 1012/1001 for restart) - 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) - `AgentEvent` (streamed tool/output; `{runId, seq, stream, data, ts}`) - `TickEvent {ts}` diff --git a/src/cli/gateway.sigterm.test.ts b/src/cli/gateway.sigterm.test.ts index 9ec5d929c..be244222d 100644 --- a/src/cli/gateway.sigterm.test.ts +++ b/src/cli/gateway.sigterm.test.ts @@ -85,6 +85,9 @@ describe("gateway SIGTERM", () => { env: { ...process.env, 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"], }, diff --git a/src/gateway/protocol/schema.ts b/src/gateway/protocol/schema.ts index ffd1c025d..a31b223f8 100644 --- a/src/gateway/protocol/schema.ts +++ b/src/gateway/protocol/schema.ts @@ -8,6 +8,8 @@ export const PresenceEntrySchema = Type.Object( ip: Type.Optional(NonEmptyString), version: Type.Optional(NonEmptyString), platform: Type.Optional(NonEmptyString), + deviceFamily: Type.Optional(NonEmptyString), + modelIdentifier: Type.Optional(NonEmptyString), mode: Type.Optional(NonEmptyString), lastInputSeconds: Type.Optional(Type.Integer({ minimum: 0 })), reason: Type.Optional(NonEmptyString), @@ -63,6 +65,8 @@ export const ConnectParamsSchema = Type.Object( name: NonEmptyString, version: NonEmptyString, platform: NonEmptyString, + deviceFamily: Type.Optional(NonEmptyString), + modelIdentifier: Type.Optional(NonEmptyString), mode: NonEmptyString, instanceId: Type.Optional(NonEmptyString), }, diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index c3674f766..446cdc2a6 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -2109,6 +2109,8 @@ describe("gateway server", () => { name: "fingerprint", version: "9.9.9", platform: "test", + deviceFamily: "iPad", + modelIdentifier: "iPad16,6", mode: "ui", instanceId: "abc", }, @@ -2133,6 +2135,8 @@ describe("gateway server", () => { expect(clientEntry?.host).toBe("fingerprint"); expect(clientEntry?.version).toBe("9.9.9"); expect(clientEntry?.mode).toBe("ui"); + expect(clientEntry?.deviceFamily).toBe("iPad"); + expect(clientEntry?.modelIdentifier).toBe("iPad16,6"); ws.close(); await server.close(); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 5c1405ba8..e2aa178ae 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -1300,12 +1300,16 @@ export async function startGatewayServer( const ip = node.remoteIp?.trim(); const version = 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 iris-connected`; upsertPresence(node.nodeId, { host, ip, version, platform, + deviceFamily, + modelIdentifier, mode: "remote", reason: "iris-connected", lastInputSeconds: 0, @@ -1342,12 +1346,16 @@ export async function startGatewayServer( const ip = node.remoteIp?.trim(); const version = 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 iris-disconnected`; upsertPresence(node.nodeId, { host, ip, version, platform, + deviceFamily, + modelIdentifier, mode: "remote", reason: "iris-disconnected", lastInputSeconds: 0, @@ -1743,6 +1751,8 @@ export async function startGatewayServer( ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr, version: connectParams.client.version, platform: connectParams.client.platform, + deviceFamily: connectParams.client.deviceFamily, + modelIdentifier: connectParams.client.modelIdentifier, mode: connectParams.client.mode, instanceId: connectParams.client.instanceId, reason: "connect", @@ -2424,6 +2434,14 @@ export async function startGatewayServer( typeof params.version === "string" ? params.version : undefined; const platform = 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 = typeof params.lastInputSeconds === "number" && Number.isFinite(params.lastInputSeconds) @@ -2444,6 +2462,8 @@ export async function startGatewayServer( mode, version, platform, + deviceFamily, + modelIdentifier, lastInputSeconds, reason, tags, diff --git a/src/infra/bridge/server.test.ts b/src/infra/bridge/server.test.ts index dedeee960..194e24565 100644 --- a/src/infra/bridge/server.test.ts +++ b/src/infra/bridge/server.test.ts @@ -225,6 +225,8 @@ describe("node bridge server", () => { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; remoteIp?: string; } | null = null; @@ -233,6 +235,8 @@ describe("node bridge server", () => { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; remoteIp?: string; } | null = null; @@ -262,6 +266,8 @@ describe("node bridge server", () => { displayName: "Iris", platform: "ios", version: "1.0", + deviceFamily: "iPad", + modelIdentifier: "iPad16,6", }); // Approve the pending request from the gateway side. @@ -296,6 +302,8 @@ describe("node bridge server", () => { displayName: "Different name", platform: "ios", version: "2.0", + deviceFamily: "iPad", + modelIdentifier: "iPad99,1", }); const line3 = JSON.parse(await readLine2()) as { type: string }; expect(line3.type).toBe("hello-ok"); @@ -310,6 +318,8 @@ describe("node bridge server", () => { expect(lastAuthed?.displayName).toBe("Iris"); expect(lastAuthed?.platform).toBe("ios"); 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); socket2.destroy(); diff --git a/src/infra/bridge/server.ts b/src/infra/bridge/server.ts index 323e1d6e5..5af3faf2d 100644 --- a/src/infra/bridge/server.ts +++ b/src/infra/bridge/server.ts @@ -17,6 +17,8 @@ type BridgeHelloFrame = { token?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; }; type BridgePairRequestFrame = { @@ -25,6 +27,8 @@ type BridgePairRequestFrame = { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; remoteAddress?: string; }; @@ -108,6 +112,8 @@ export type NodeBridgeClientInfo = { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; remoteIp?: string; }; @@ -263,6 +269,8 @@ export async function startNodeBridgeServer( displayName: verified.node.displayName ?? hello.displayName, platform: verified.node.platform ?? hello.platform, version: verified.node.version ?? hello.version, + deviceFamily: verified.node.deviceFamily ?? hello.deviceFamily, + modelIdentifier: verified.node.modelIdentifier ?? hello.modelIdentifier, remoteIp: remoteAddress, }; connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); @@ -319,6 +327,8 @@ export async function startNodeBridgeServer( displayName: req.displayName, platform: req.platform, version: req.version, + deviceFamily: req.deviceFamily, + modelIdentifier: req.modelIdentifier, remoteIp: remoteAddress, }, opts.pairingBaseDir, @@ -347,6 +357,8 @@ export async function startNodeBridgeServer( displayName: req.displayName, platform: req.platform, version: req.version, + deviceFamily: req.deviceFamily, + modelIdentifier: req.modelIdentifier, remoteIp: remoteAddress, }; connections.set(nodeId, { socket, nodeInfo, invokeWaiters }); diff --git a/src/infra/node-pairing.ts b/src/infra/node-pairing.ts index c7d727f3c..1fba1ba13 100644 --- a/src/infra/node-pairing.ts +++ b/src/infra/node-pairing.ts @@ -9,6 +9,8 @@ export type NodePairingPendingRequest = { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; remoteIp?: string; isRepair?: boolean; ts: number; @@ -20,6 +22,8 @@ export type NodePairingPairedNode = { displayName?: string; platform?: string; version?: string; + deviceFamily?: string; + modelIdentifier?: string; remoteIp?: string; createdAtMs: number; approvedAtMs: number; @@ -172,6 +176,8 @@ export async function requestNodePairing( displayName: req.displayName, platform: req.platform, version: req.version, + deviceFamily: req.deviceFamily, + modelIdentifier: req.modelIdentifier, remoteIp: req.remoteIp, isRepair, ts: Date.now(), @@ -199,6 +205,8 @@ export async function approveNodePairing( displayName: pending.displayName, platform: pending.platform, version: pending.version, + deviceFamily: pending.deviceFamily, + modelIdentifier: pending.modelIdentifier, remoteIp: pending.remoteIp, createdAtMs: existing?.createdAtMs ?? now, approvedAtMs: now, diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index eb90417dc..7722b7ba9 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -5,6 +5,8 @@ export type SystemPresence = { ip?: string; version?: string; platform?: string; + deviceFamily?: string; + modelIdentifier?: string; lastInputSeconds?: number; mode?: string; reason?: string; @@ -54,12 +56,20 @@ function initSelfPresence() { if (p === "win32") return `windows ${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 selfEntry: SystemPresence = { host, ip, version, platform, + deviceFamily, mode: "gateway", reason: "self", text, @@ -123,6 +133,8 @@ type SystemPresencePayload = { ip?: string; version?: string; platform?: string; + deviceFamily?: string; + modelIdentifier?: string; lastInputSeconds?: number; mode?: string; reason?: string; @@ -147,6 +159,8 @@ export function updateSystemPresence(payload: SystemPresencePayload) { ip: payload.ip ?? parsed.ip ?? existing.ip, version: payload.version ?? parsed.version ?? existing.version, platform: payload.platform ?? existing.platform, + deviceFamily: payload.deviceFamily ?? existing.deviceFamily, + modelIdentifier: payload.modelIdentifier ?? existing.modelIdentifier, mode: payload.mode ?? parsed.mode ?? existing.mode, lastInputSeconds: payload.lastInputSeconds ??