From debcf19199555ea56980355a1f7fa9d8d3446984 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 12 Dec 2025 16:47:07 +0000 Subject: [PATCH] fix(presence): stabilize instance identity --- .../Sources/Clawdis/GatewayChannel.swift | 5 ++- .../Sources/Clawdis/InstanceIdentity.swift | 22 ++++++++++ .../Sources/Clawdis/InstancesStore.swift | 2 +- .../Sources/Clawdis/PresenceReporter.swift | 6 +-- src/gateway/server.ts | 11 ++++- src/infra/system-presence.test.ts | 40 +++++++++++++++++++ src/infra/system-presence.ts | 19 ++++++--- 7 files changed, 93 insertions(+), 12 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/InstanceIdentity.swift create mode 100644 src/infra/system-presence.test.ts diff --git a/apps/macos/Sources/Clawdis/GatewayChannel.swift b/apps/macos/Sources/Clawdis/GatewayChannel.swift index db37c6a06..ee7c6bd79 100644 --- a/apps/macos/Sources/Clawdis/GatewayChannel.swift +++ b/apps/macos/Sources/Clawdis/GatewayChannel.swift @@ -158,18 +158,19 @@ actor GatewayChannelActor { let osVersion = ProcessInfo.processInfo.operatingSystemVersion let platform = "macos \(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)" let primaryLocale = Locale.preferredLanguages.first ?? Locale.current.identifier + let clientName = InstanceIdentity.displayName let hello = Hello( type: "hello", minprotocol: GATEWAY_PROTOCOL_VERSION, maxprotocol: GATEWAY_PROTOCOL_VERSION, client: [ - "name": ClawdisProtocol.AnyCodable("clawdis-mac"), + "name": ClawdisProtocol.AnyCodable(clientName), "version": ClawdisProtocol.AnyCodable( Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "dev"), "platform": ClawdisProtocol.AnyCodable(platform), "mode": ClawdisProtocol.AnyCodable("app"), - "instanceId": ClawdisProtocol.AnyCodable(Host.current().localizedName ?? UUID().uuidString), + "instanceId": ClawdisProtocol.AnyCodable(InstanceIdentity.instanceId), ], caps: [], auth: self.token.map { ["token": ClawdisProtocol.AnyCodable($0)] }, diff --git a/apps/macos/Sources/Clawdis/InstanceIdentity.swift b/apps/macos/Sources/Clawdis/InstanceIdentity.swift new file mode 100644 index 000000000..f8762af82 --- /dev/null +++ b/apps/macos/Sources/Clawdis/InstanceIdentity.swift @@ -0,0 +1,22 @@ +import Foundation + +enum InstanceIdentity { + static let instanceId: String = { + if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + { + return name + } + return UUID().uuidString + }() + + static let displayName: String = { + if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), + !name.isEmpty + { + return name + } + return "clawdis-mac" + }() +} + diff --git a/apps/macos/Sources/Clawdis/InstancesStore.swift b/apps/macos/Sources/Clawdis/InstancesStore.swift index c7584b935..cf6e26a70 100644 --- a/apps/macos/Sources/Clawdis/InstancesStore.swift +++ b/apps/macos/Sources/Clawdis/InstancesStore.swift @@ -290,7 +290,7 @@ final class InstancesStore: ObservableObject { private func normalizePresence(_ entries: [PresenceEntry]) -> [InstanceInfo] { entries.map { entry -> InstanceInfo in - let key = entry.host ?? entry.ip ?? entry.text ?? entry.instanceid ?? "entry-\(entry.ts)" + let key = entry.instanceid ?? entry.host ?? entry.ip ?? entry.text ?? "entry-\(entry.ts)" return InstanceInfo( id: key, host: entry.host, diff --git a/apps/macos/Sources/Clawdis/PresenceReporter.swift b/apps/macos/Sources/Clawdis/PresenceReporter.swift index 426708195..ec502903c 100644 --- a/apps/macos/Sources/Clawdis/PresenceReporter.swift +++ b/apps/macos/Sources/Clawdis/PresenceReporter.swift @@ -10,7 +10,7 @@ final class PresenceReporter { private let logger = Logger(subsystem: "com.steipete.clawdis", category: "presence") private var task: Task? private let interval: TimeInterval = 180 // a few minutes - private let instanceId: String = Host.current().localizedName ?? UUID().uuidString + private let instanceId: String = InstanceIdentity.instanceId func start() { guard self.task == nil else { return } @@ -32,7 +32,7 @@ final class PresenceReporter { @Sendable private func push(reason: String) async { let mode = await MainActor.run { AppStateStore.shared.connectionMode.rawValue } - let host = Host.current().localizedName ?? "unknown-host" + let host = InstanceIdentity.displayName let ip = Self.primaryIPv4Address() ?? "ip-unknown" let version = Self.appVersionString() let lastInput = Self.lastInputSeconds() @@ -59,7 +59,7 @@ final class PresenceReporter { } private static func composePresenceSummary(mode: String, reason: String) -> String { - let host = Host.current().localizedName ?? "unknown-host" + let host = InstanceIdentity.displayName let ip = Self.primaryIPv4Address() ?? "ip-unknown" let version = Self.appVersionString() let lastInput = Self.lastInputSeconds() diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 43b3ca613..780c888d9 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -78,6 +78,15 @@ export type GatewayServer = { close: () => Promise; }; +function isLoopbackAddress(ip: string | undefined): boolean { + if (!ip) return false; + if (ip === "127.0.0.1") return true; + if (ip.startsWith("127.")) return true; + if (ip === "::1") return true; + if (ip.startsWith("::ffff:127.")) return true; + return false; +} + let presenceVersion = 1; let healthVersion = 1; let seq = 0; @@ -648,7 +657,7 @@ export async function startGatewayServer( } upsertPresence(presenceKey, { host: hello.client.name || os.hostname(), - ip: remoteAddr, + ip: isLoopbackAddress(remoteAddr) ? undefined : remoteAddr, version: hello.client.version, mode: hello.client.mode, instanceId: hello.client.instanceId, diff --git a/src/infra/system-presence.test.ts b/src/infra/system-presence.test.ts new file mode 100644 index 000000000..606f325c8 --- /dev/null +++ b/src/infra/system-presence.test.ts @@ -0,0 +1,40 @@ +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + listSystemPresence, + updateSystemPresence, + upsertPresence, +} from "./system-presence.js"; + +describe("system-presence", () => { + it("dedupes entries across sources by case-insensitive instanceId key", () => { + const instanceIdUpper = `AaBb-${randomUUID()}`.toUpperCase(); + const instanceIdLower = instanceIdUpper.toLowerCase(); + + upsertPresence(instanceIdUpper, { + host: "clawdis-mac", + mode: "app", + instanceId: instanceIdUpper, + reason: "connect", + }); + + updateSystemPresence({ + text: "Node: Peter-Mac-Studio (10.0.0.1) · app 2.0.0 · last input 5s ago · mode app · reason beacon", + instanceId: instanceIdLower, + host: "Peter-Mac-Studio", + ip: "10.0.0.1", + mode: "app", + version: "2.0.0", + lastInputSeconds: 5, + reason: "beacon", + }); + + const matches = listSystemPresence().filter( + (e) => (e.instanceId ?? "").toLowerCase() === instanceIdLower, + ); + expect(matches).toHaveLength(1); + expect(matches[0]?.host).toBe("Peter-Mac-Studio"); + expect(matches[0]?.ip).toBe("10.0.0.1"); + expect(matches[0]?.lastInputSeconds).toBe(5); + }); +}); diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index 7dc0c2329..ae2e0a9d6 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -16,6 +16,13 @@ const entries = new Map(); const TTL_MS = 5 * 60 * 1000; // 5 minutes const MAX_ENTRIES = 200; +function normalizePresenceKey(key: string | undefined): string | undefined { + if (!key) return undefined; + const trimmed = key.trim(); + if (!trimmed) return undefined; + return trimmed.toLowerCase(); +} + function resolvePrimaryIPv4(): string | undefined { const nets = os.networkInterfaces(); const prefer = ["en0", "eth0"]; @@ -116,9 +123,9 @@ export function updateSystemPresence(payload: SystemPresencePayload) { ensureSelfPresence(); const parsed = parsePresence(payload.text); const key = - payload.instanceId?.toLowerCase() || - parsed.instanceId?.toLowerCase() || - parsed.host?.toLowerCase() || + normalizePresenceKey(payload.instanceId) || + normalizePresenceKey(parsed.instanceId) || + normalizePresenceKey(parsed.host) || parsed.ip || parsed.text.slice(0, 64) || os.hostname().toLowerCase(); @@ -144,7 +151,9 @@ export function updateSystemPresence(payload: SystemPresencePayload) { export function upsertPresence(key: string, presence: Partial) { ensureSelfPresence(); - const existing = entries.get(key) ?? ({} as SystemPresence); + const normalizedKey = + normalizePresenceKey(key) ?? os.hostname().toLowerCase(); + const existing = entries.get(normalizedKey) ?? ({} as SystemPresence); const merged: SystemPresence = { ...existing, ...presence, @@ -156,7 +165,7 @@ export function upsertPresence(key: string, presence: Partial) { presence.mode ?? existing.mode ?? "unknown" }`, }; - entries.set(key, merged); + entries.set(normalizedKey, merged); } export function listSystemPresence(): SystemPresence[] {