From e9b4d86e37bf1c210700f2e4071551d0fa4b8316 Mon Sep 17 00:00:00 2001 From: Mariano <132747814+mbelinky@users.noreply.github.com> Date: Wed, 18 Feb 2026 17:39:54 +0000 Subject: [PATCH] fix(protocol): preserve AnyCodable booleans from JSON bridge (#20220) Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 1d86183e3b20723fd3401410d3a02ed49ea0f81d Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Co-authored-by: mbelinky <132747814+mbelinky@users.noreply.github.com> Reviewed-by: @mbelinky --- CHANGELOG.md | 1 + .../Sources/OpenClawProtocol/AnyCodable.swift | 15 +++++-- .../OpenClawKitTests/AnyCodableTests.swift | 40 +++++++++++++++++++ 3 files changed, 53 insertions(+), 3 deletions(-) create mode 100644 apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift diff --git a/CHANGELOG.md b/CHANGELOG.md index 470d3e9aef..827c1e2749 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- OpenClawKit/Protocol: preserve JSON boolean literals (`true`/`false`) when bridging through `AnyCodable` so Apple client RPC params no longer re-encode booleans as `1`/`0`. Thanks @mbelinky. - iOS/Onboarding: stabilize pairing and reconnect behavior by resetting stale pairing request state on manual retry, disconnecting both operator and node gateways on operator failure, and avoiding duplicate pairing loops from operator transport identity attachment. (#20056) Thanks @mbelinky. - Browser/Relay: reuse an already-running extension relay when the relay port is occupied by another OpenClaw process, while still failing on non-relay port collisions to avoid masking unrelated listeners. (#20035) Thanks @mbelinky. - Telegram/Cron/Heartbeat: honor explicit Telegram topic targets in cron and heartbeat delivery (`:topic:`) so scheduled sends land in the configured topic instead of the last active thread. (#19367) Thanks @Lukavyi. diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift index 252e6131e4..062005f1ed 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/AnyCodable.swift @@ -6,13 +6,13 @@ import Foundation public struct AnyCodable: Codable, @unchecked Sendable, Hashable { public let value: Any - public init(_ value: Any) { self.value = value } + public init(_ value: Any) { self.value = Self.normalize(value) } public init(from decoder: Decoder) throws { let container = try decoder.singleValueContainer() + if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return } if let intVal = try? container.decode(Int.self) { self.value = intVal; return } if let doubleVal = try? container.decode(Double.self) { self.value = doubleVal; return } - if let boolVal = try? container.decode(Bool.self) { self.value = boolVal; return } if let stringVal = try? container.decode(String.self) { self.value = stringVal; return } if container.decodeNil() { self.value = NSNull(); return } if let dict = try? container.decode([String: AnyCodable].self) { self.value = dict; return } @@ -23,10 +23,12 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable { public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() switch self.value { + case let boolVal as Bool: try container.encode(boolVal) case let intVal as Int: try container.encode(intVal) case let doubleVal as Double: try container.encode(doubleVal) - case let boolVal as Bool: try container.encode(boolVal) case let stringVal as String: try container.encode(stringVal) + case let number as NSNumber where CFGetTypeID(number) == CFBooleanGetTypeID(): + try container.encode(number.boolValue) case is NSNull: try container.encodeNil() case let dict as [String: AnyCodable]: try container.encode(dict) case let array as [AnyCodable]: try container.encode(array) @@ -51,6 +53,13 @@ public struct AnyCodable: Codable, @unchecked Sendable, Hashable { } } + private static func normalize(_ value: Any) -> Any { + if let number = value as? NSNumber, CFGetTypeID(number) == CFBooleanGetTypeID() { + return number.boolValue + } + return value + } + public static func == (lhs: AnyCodable, rhs: AnyCodable) -> Bool { switch (lhs.value, rhs.value) { case let (l as Int, r as Int): l == r diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift new file mode 100644 index 0000000000..3835f1186c --- /dev/null +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/AnyCodableTests.swift @@ -0,0 +1,40 @@ +import Foundation +import Testing +import OpenClawProtocol + +struct AnyCodableTests { + @Test + func encodesNSNumberBooleansAsJSONBooleans() throws { + let trueData = try JSONEncoder().encode(AnyCodable(NSNumber(value: true))) + let falseData = try JSONEncoder().encode(AnyCodable(NSNumber(value: false))) + + #expect(String(data: trueData, encoding: .utf8) == "true") + #expect(String(data: falseData, encoding: .utf8) == "false") + } + + @Test + func preservesBooleanLiteralsFromJSONSerializationBridge() throws { + let raw = try #require( + JSONSerialization.jsonObject(with: Data(#"{"enabled":true,"nested":{"active":false}}"#.utf8)) + as? [String: Any] + ) + let enabled = try #require(raw["enabled"]) + let nested = try #require(raw["nested"]) + + struct RequestEnvelope: Codable { + let params: [String: AnyCodable] + } + + let envelope = RequestEnvelope( + params: [ + "enabled": AnyCodable(enabled), + "nested": AnyCodable(nested), + ] + ) + let data = try JSONEncoder().encode(envelope) + let json = try #require(String(data: data, encoding: .utf8)) + + #expect(json.contains(#""enabled":true"#)) + #expect(json.contains(#""active":false"#)) + } +}