From 3776de906f2978a9fb535d2773d3fcad8546a75d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 19 Jan 2026 06:22:01 +0000 Subject: [PATCH] fix: stabilize gateway ws + iOS --- apps/ios/Sources/Model/NodeAppModel.swift | 8 +++-- .../Sources/ClawdbotKit/GatewayChannel.swift | 12 ++++--- .../ClawdbotKit/GatewayNodeSession.swift | 30 ++++++++-------- .../ClawdbotKit/GatewayPayloadDecoding.swift | 16 +++++++++ .../ClawdbotKit/InstanceIdentity.swift | 36 +++++++++++++------ src/agents/sandbox/config-hash.ts | 12 ++++++- src/gateway/client.ts | 15 ++++---- src/gateway/node-registry.ts | 6 ++-- src/gateway/server-methods/nodes.ts | 1 - src/gateway/server.impl.ts | 4 ++- src/gateway/server.models-voicewake.test.ts | 4 +-- src/gateway/server.sessions-send.test.ts | 4 +++ src/gateway/server/tls.ts | 2 ++ src/node-host/runner.ts | 1 - 14 files changed, 105 insertions(+), 46 deletions(-) diff --git a/apps/ios/Sources/Model/NodeAppModel.swift b/apps/ios/Sources/Model/NodeAppModel.swift index 3e1a70ffb..7d6e191c1 100644 --- a/apps/ios/Sources/Model/NodeAppModel.swift +++ b/apps/ios/Sources/Model/NodeAppModel.swift @@ -264,8 +264,8 @@ final class NodeAppModel { self.gatewayRemoteAddress = nil self.gatewayConnected = false self.showLocalCanvasOnDisconnect() + self.gatewayStatusText = "Disconnected: \(reason)" } - self.gatewayStatusText = "Disconnected: \(reason)" }, onInvoke: { [weak self] req in guard let self else { @@ -409,8 +409,10 @@ final class NodeAppModel { for await evt in stream { if Task.isCancelled { return } guard evt.event == "voicewake.changed" else { continue } - guard let payloadJSON = evt.payloadJSON else { continue } - guard let triggers = VoiceWakePreferences.decodeGatewayTriggers(from: payloadJSON) else { continue } + guard let payload = evt.payload else { continue } + struct Payload: Decodable { var triggers: [String] } + guard let decoded = try? GatewayPayloadDecoding.decode(payload, as: Payload.self) else { continue } + let triggers = VoiceWakePreferences.sanitizeTriggerWords(decoded.triggers) VoiceWakePreferences.saveTriggerWords(triggers) } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift index 23e8fa1c2..9480c3f60 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayChannel.swift @@ -44,7 +44,7 @@ public protocol WebSocketSessioning: AnyObject { } extension URLSession: WebSocketSessioning { - func makeWebSocketTask(url: URL) -> WebSocketTaskBox { + public func makeWebSocketTask(url: URL) -> WebSocketTaskBox { let task = self.webSocketTask(with: url) // Avoid "Message too long" receive errors for large snapshots / history payloads. task.maximumMessageSize = 16 * 1024 * 1024 // 16 MB @@ -54,6 +54,10 @@ extension URLSession: WebSocketSessioning { public struct WebSocketSessionBox: @unchecked Sendable { public let session: any WebSocketSessioning + + public init(session: any WebSocketSessioning) { + self.session = session + } } public struct GatewayConnectOptions: Sendable { @@ -472,7 +476,7 @@ public actor GatewayChannelActor { public func request( method: String, - params: [String: ClawdbotProtocol.AnyCodable]?, + params: [String: AnyCodable]?, timeoutMs: Double? = nil) async throws -> Data { do { @@ -525,8 +529,8 @@ public actor GatewayChannelActor { if res.ok == false { let code = res.error?["code"]?.value as? String let msg = res.error?["message"]?.value as? String - let details: [String: ClawdbotProtocol.AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in - acc[pair.key] = ClawdbotProtocol.AnyCodable(pair.value.value) + let details: [String: AnyCodable] = (res.error ?? [:]).reduce(into: [:]) { acc, pair in + acc[pair.key] = AnyCodable(pair.value.value) } throw GatewayResponseError(method: method, code: code, message: msg, details: details) } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift index 26d758596..9b5958532 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayNodeSession.swift @@ -26,6 +26,8 @@ public actor GatewayNodeSession { private var serverEventSubscribers: [UUID: AsyncStream.Continuation] = [:] private var canvasHostUrl: String? + public init() {} + public func connect( url: URL, token: String?, @@ -107,9 +109,9 @@ public actor GatewayNodeSession { public 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()), + let params: [String: AnyCodable] = [ + "event": AnyCodable(event), + "payloadJSON": AnyCodable(payloadJSON ?? NSNull()), ] do { _ = try await channel.request(method: "node.event", params: params, timeoutMs: 8000) @@ -174,16 +176,16 @@ public actor GatewayNodeSession { 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()), + var params: [String: AnyCodable] = [ + "id": AnyCodable(request.id), + "nodeId": AnyCodable(request.nodeId), + "ok": AnyCodable(response.ok), + "payloadJSON": AnyCodable(response.payloadJSON ?? NSNull()), ] if let error = response.error { - params["error"] = ClawdbotProtocol.AnyCodable([ - "code": ClawdbotProtocol.AnyCodable(error.code.rawValue), - "message": ClawdbotProtocol.AnyCodable(error.message), + params["error"] = AnyCodable([ + "code": AnyCodable(error.code.rawValue), + "message": AnyCodable(error.message), ]) } do { @@ -194,7 +196,7 @@ public actor GatewayNodeSession { } private func decodeParamsJSON( - _ paramsJSON: String?) throws -> [String: ClawdbotProtocol.AnyCodable]? + _ paramsJSON: String?) throws -> [String: AnyCodable]? { guard let paramsJSON, !paramsJSON.isEmpty else { return nil } guard let data = paramsJSON.data(using: .utf8) else { @@ -207,13 +209,13 @@ public actor GatewayNodeSession { return nil } return dict.reduce(into: [:]) { acc, entry in - acc[entry.key] = ClawdbotProtocol.AnyCodable(entry.value) + acc[entry.key] = AnyCodable(entry.value) } } private func broadcastServerEvent(_ evt: EventFrame) { for (id, continuation) in self.serverEventSubscribers { - if continuation.yield(evt) == .terminated { + if case .terminated = continuation.yield(evt) { self.serverEventSubscribers.removeValue(forKey: id) } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift index 4b1cc1659..1895189f1 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/GatewayPayloadDecoding.swift @@ -10,6 +10,14 @@ public enum GatewayPayloadDecoding { return try JSONDecoder().decode(T.self, from: data) } + public static func decode( + _ payload: AnyCodable, + as _: T.Type = T.self) throws -> T + { + let data = try JSONEncoder().encode(payload) + return try JSONDecoder().decode(T.self, from: data) + } + public static func decodeIfPresent( _ payload: ClawdbotProtocol.AnyCodable?, as _: T.Type = T.self) throws -> T? @@ -17,4 +25,12 @@ public enum GatewayPayloadDecoding { guard let payload else { return nil } return try self.decode(payload, as: T.self) } + + public static func decodeIfPresent( + _ payload: AnyCodable?, + as _: T.Type = T.self) throws -> T? + { + guard let payload else { return nil } + return try self.decode(payload, as: T.self) + } } diff --git a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift index cbd81ba33..56be75e04 100644 --- a/apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift +++ b/apps/shared/ClawdbotKit/Sources/ClawdbotKit/InstanceIdentity.swift @@ -12,6 +12,17 @@ public enum InstanceIdentity { UserDefaults(suiteName: suiteName) ?? .standard } +#if canImport(UIKit) + private static func readMainActor(_ body: @MainActor () -> T) -> T { + if Thread.isMainThread { + return MainActor.assumeIsolated { body() } + } + return DispatchQueue.main.sync { + MainActor.assumeIsolated { body() } + } + } +#endif + public static let instanceId: String = { let defaults = Self.defaults if let existing = defaults.string(forKey: instanceIdKey)? @@ -28,7 +39,9 @@ public enum InstanceIdentity { public static let displayName: String = { #if canImport(UIKit) - let name = UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) + let name = Self.readMainActor { + UIDevice.current.name.trimmingCharacters(in: .whitespacesAndNewlines) + } return name.isEmpty ? "clawdbot" : name #else if let name = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), @@ -65,10 +78,12 @@ public enum InstanceIdentity { public static let deviceFamily: String = { #if canImport(UIKit) - switch UIDevice.current.userInterfaceIdiom { - case .pad: return "iPad" - case .phone: return "iPhone" - default: return "iOS" + return Self.readMainActor { + switch UIDevice.current.userInterfaceIdiom { + case .pad: return "iPad" + case .phone: return "iPhone" + default: return "iOS" + } } #else return "Mac" @@ -78,11 +93,12 @@ public enum InstanceIdentity { public static let platformString: String = { let v = ProcessInfo.processInfo.operatingSystemVersion #if canImport(UIKit) - let name: String - switch UIDevice.current.userInterfaceIdiom { - case .pad: name = "iPadOS" - case .phone: name = "iOS" - default: name = "iOS" + let name = Self.readMainActor { + switch UIDevice.current.userInterfaceIdiom { + case .pad: return "iPadOS" + case .phone: return "iOS" + default: return "iOS" + } } return "\(name) \(v.majorVersion).\(v.minorVersion).\(v.patchVersion)" #else diff --git a/src/agents/sandbox/config-hash.ts b/src/agents/sandbox/config-hash.ts index 07e5588a9..872bac4bc 100644 --- a/src/agents/sandbox/config-hash.ts +++ b/src/agents/sandbox/config-hash.ts @@ -20,7 +20,9 @@ function normalizeForHash(value: unknown): unknown { .filter((item): item is unknown => item !== undefined); const primitives = normalized.filter(isPrimitive); if (primitives.length === normalized.length) { - return [...primitives].sort((a, b) => String(a).localeCompare(String(b))); + return [...primitives].sort((a, b) => + primitiveToString(a).localeCompare(primitiveToString(b)), + ); } return normalized; } @@ -36,6 +38,14 @@ function normalizeForHash(value: unknown): unknown { return value; } +function primitiveToString(value: unknown): string { + if (value === null) return "null"; + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + if (typeof value === "boolean") return value ? "true" : "false"; + return JSON.stringify(value); +} + export function computeSandboxConfigHash(input: SandboxHashInput): string { const payload = normalizeForHash(input); const raw = JSON.stringify(payload); diff --git a/src/gateway/client.ts b/src/gateway/client.ts index b7c19ac1b..81d2c34da 100644 --- a/src/gateway/client.ts +++ b/src/gateway/client.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { WebSocket } from "ws"; +import { WebSocket, type ClientOptions, type CertMeta } from "ws"; import { rawDataToString } from "../infra/ws.js"; import { logDebug, logError } from "../logger.js"; import type { DeviceIdentity } from "../infra/device-identity.js"; @@ -85,18 +85,21 @@ export class GatewayClient { if (this.closed) return; const url = this.opts.url ?? "ws://127.0.0.1:18789"; // Allow node screen snapshots and other large responses. - const wsOptions: ConstructorParameters[1] = { + const wsOptions: ClientOptions = { maxPayload: 25 * 1024 * 1024, }; if (url.startsWith("wss://") && this.opts.tlsFingerprint) { wsOptions.rejectUnauthorized = false; - wsOptions.checkServerIdentity = (_host, cert) => { + wsOptions.checkServerIdentity = (_host: string, cert: CertMeta) => { + const fingerprintValue = + typeof cert === "object" && cert && "fingerprint256" in cert + ? (cert as { fingerprint256?: string }).fingerprint256 ?? "" + : ""; const fingerprint = normalizeFingerprint( - typeof cert?.fingerprint256 === "string" ? cert.fingerprint256 : "", + typeof fingerprintValue === "string" ? fingerprintValue : "", ); const expected = normalizeFingerprint(this.opts.tlsFingerprint ?? ""); - if (fingerprint && fingerprint === expected) return undefined; - return new Error("gateway tls fingerprint mismatch"); + return Boolean(fingerprint && fingerprint === expected); }; } this.ws = new WebSocket(url, wsOptions); diff --git a/src/gateway/node-registry.ts b/src/gateway/node-registry.ts index 1a19f9c65..0c272e2af 100644 --- a/src/gateway/node-registry.ts +++ b/src/gateway/node-registry.ts @@ -119,7 +119,7 @@ export class NodeRegistry { timeoutMs: params.timeoutMs, idempotencyKey: params.idempotencyKey, }; - const ok = this.sendEvent(node, "node.invoke.request", payload); + const ok = this.sendEventToSession(node, "node.invoke.request", payload); if (!ok) { return { ok: false, @@ -172,7 +172,7 @@ export class NodeRegistry { return this.sendEventToSession(node, event, payload); } - private sendEvent(node: NodeSession, event: string, payload: unknown): boolean { + private sendEventInternal(node: NodeSession, event: string, payload: unknown): boolean { try { node.client.socket.send( JSON.stringify({ @@ -188,6 +188,6 @@ export class NodeRegistry { } private sendEventToSession(node: NodeSession, event: string, payload: unknown): boolean { - return this.sendEvent(node, event, payload); + return this.sendEventInternal(node, event, payload); } } diff --git a/src/gateway/server-methods/nodes.ts b/src/gateway/server-methods/nodes.ts index 16eda4c9a..a4e033a7d 100644 --- a/src/gateway/server-methods/nodes.ts +++ b/src/gateway/server-methods/nodes.ts @@ -451,7 +451,6 @@ export const nodeHandlers: GatewayRequestHandlers = { nodeContext, "node", { - type: "event", event: p.event, payloadJSON, }, diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index 9d977143a..268d59f32 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -356,13 +356,15 @@ export async function startGatewayServer( const execApprovalManager = new ExecApprovalManager(); const execApprovalHandlers = createExecApprovalHandlers(execApprovalManager); + const canvasHostServerPort = (canvasHostServer as CanvasHostServer | null)?.port; + attachGatewayWsHandlers({ wss, clients, port, gatewayHost: bindHost ?? undefined, canvasHostEnabled: Boolean(canvasHost), - canvasHostServerPort: canvasHostServer?.port ?? undefined, + canvasHostServerPort, resolvedAuth, gatewayMethods, events: GATEWAY_EVENTS, diff --git a/src/gateway/server.models-voicewake.test.ts b/src/gateway/server.models-voicewake.test.ts index f3408e478..f5d075eb1 100644 --- a/src/gateway/server.models-voicewake.test.ts +++ b/src/gateway/server.models-voicewake.test.ts @@ -11,7 +11,7 @@ import { rpcReq, startServerWithClient, } from "./test-helpers.js"; -import { GATEWAY_CLIENT_MODES } from "../utils/message-channel.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; installGatewayTestHooks(); @@ -127,7 +127,7 @@ describe("gateway server models + voicewake", () => { await connectOk(nodeWs, { role: "node", client: { - id: "n1", + id: GATEWAY_CLIENT_NAMES.NODE_HOST, version: "1.0.0", platform: "ios", mode: GATEWAY_CLIENT_MODES.NODE, diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.test.ts index 34f335dc5..c2b9e8821 100644 --- a/src/gateway/server.sessions-send.test.ts +++ b/src/gateway/server.sessions-send.test.ts @@ -32,6 +32,7 @@ describe("sessions_send gateway loopback", () => { it("returns reply when lifecycle ends before agent.wait", async () => { const port = await getFreePort(); vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port)); + vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "test-token"); const server = await startGatewayServer(port); const spy = vi.mocked(agentCommand); @@ -105,6 +106,7 @@ describe("sessions_send label lookup", () => { it("finds session by label and sends message", { timeout: 60_000 }, async () => { const port = await getFreePort(); vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port)); + vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "test-token"); const server = await startGatewayServer(port); servers.push(server); @@ -171,6 +173,7 @@ describe("sessions_send label lookup", () => { it("returns error when label not found", { timeout: 60_000 }, async () => { const port = await getFreePort(); vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port)); + vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "test-token"); const server = await startGatewayServer(port); servers.push(server); @@ -191,6 +194,7 @@ describe("sessions_send label lookup", () => { it("returns error when neither sessionKey nor label provided", { timeout: 60_000 }, async () => { const port = await getFreePort(); vi.stubEnv("CLAWDBOT_GATEWAY_PORT", String(port)); + vi.stubEnv("CLAWDBOT_GATEWAY_TOKEN", "test-token"); const server = await startGatewayServer(port); servers.push(server); diff --git a/src/gateway/server/tls.ts b/src/gateway/server/tls.ts index 9f82aad51..b7b5388b5 100644 --- a/src/gateway/server/tls.ts +++ b/src/gateway/server/tls.ts @@ -4,6 +4,8 @@ import { loadGatewayTlsRuntime as loadGatewayTlsRuntimeConfig, } from "../../infra/tls/gateway.js"; +export type { GatewayTlsRuntime } from "../../infra/tls/gateway.js"; + export async function loadGatewayTlsRuntime( cfg: GatewayTlsConfig | undefined, log?: { info?: (msg: string) => void; warn?: (msg: string) => void }, diff --git a/src/node-host/runner.ts b/src/node-host/runner.ts index bc9a189e2..7ec51de21 100644 --- a/src/node-host/runner.ts +++ b/src/node-host/runner.ts @@ -1,7 +1,6 @@ import crypto from "node:crypto"; import { spawn } from "node:child_process"; import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import {