From d44676a3c5227879cd4755dd6b3f6d2c59237a9c Mon Sep 17 00:00:00 2001 From: Joshua Mouch Date: Wed, 10 Jun 2026 19:54:44 -0400 Subject: [PATCH 1/2] fix(swift): correct AnyCodable NSNumber Int and Double encode fidelity When a [String: Any] dictionary comes from JSONSerialization, numeric and boolean values are boxed as NSNumber. Swift pattern-matching bridges NSNumber to Bool, Int, and Double indiscriminately, so the existing switch hit `case let bool as Bool` for any NSNumber, encoding integer 1 as `true` and double 1.5 as integer 1. Add an NSNumber guard at the top of encode(to:) that inspects objCType and dispatches faithfully: 'c'/'B' to boolValue, 'f'/'d' to doubleValue, all others to int64Value. The `type(of:) != Bool.self` check keeps native Swift Bool values (which are not NSNumber) on the existing path. Apply the same fix to the generator's anyCodableContent() template and reconcile its pre-existing drift from the on-disk file (the @unchecked Sendable rationale and the [Any]/[String: Any] equality arms) so a fresh scaffold reproduces AnyCodable.swift byte-for-byte. Tests cover Int, Bool, and Double from JSONSerialization, plus a native Swift Bool and a Float-backed NSNumber. Fixes #123. --- .../AgentHostProtocol/AnyCodable.swift | 21 +++++++ .../AnyCodableTests.swift | 57 +++++++++++++++++++ clients/swift/CHANGELOG.md | 4 ++ scripts/generate-swift.ts | 37 +++++++++++- 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/AnyCodableTests.swift diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift index 3350f8e3..cd6db3e5 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift @@ -41,6 +41,27 @@ public struct AnyCodable: Codable, @unchecked Sendable, Equatable { public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() + + // NSNumber bridges promiscuously to Bool/Int/Double — pattern matching + // alone can't distinguish a Bool-backed NSNumber from an Int-backed one. + // Inspect objCType to dispatch faithfully to the underlying type. + // ('c' is also Int8's encoding, but JSONSerialization only ever produces + // 'c' for a Bool, so the JSON-decode path this type serves is unambiguous.) + if let number = value as? NSNumber, type(of: value) != Bool.self { + let objCType = number.objCType[0] + switch objCType { + case 0x63 /* 'c' */, 0x42 /* 'B' */: + try container.encode(number.boolValue) + return + case 0x66 /* 'f' */, 0x64 /* 'd' */: + try container.encode(number.doubleValue) + return + default: + try container.encode(number.int64Value) + return + } + } + switch value { case is NSNull: try container.encodeNil() diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/AnyCodableTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/AnyCodableTests.swift new file mode 100644 index 00000000..9e193d69 --- /dev/null +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/AnyCodableTests.swift @@ -0,0 +1,57 @@ +// AnyCodableTests.swift — Tests for AnyCodable encode/decode correctness. +// +// Focused on the NSNumber-bridging bug: when a [String: Any] dictionary is +// produced by JSONSerialization (which boxes numeric/bool values as NSNumber), +// AnyCodable must re-encode each value to its original JSON type rather than +// the first matching Swift pattern-match arm. + +import XCTest +@testable import AgentHostProtocol + +final class AnyCodableTests: XCTestCase { + + func testAnyCodableEncodePreservesIntFromNSNumber() throws { + let object = try JSONSerialization.jsonObject( + with: #"{"x":1}"#.data(using: .utf8)! + ) + let wrapped = AnyCodable(object) + let bytes = try JSONEncoder().encode(wrapped) + XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":1}"#) + } + + func testAnyCodableEncodePreservesBoolFromNSNumber() throws { + let object = try JSONSerialization.jsonObject( + with: #"{"x":true}"#.data(using: .utf8)! + ) + let wrapped = AnyCodable(object) + let bytes = try JSONEncoder().encode(wrapped) + XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":true}"#) + } + + func testAnyCodableEncodePreservesDoubleFromNSNumber() throws { + let object = try JSONSerialization.jsonObject( + with: #"{"x":1.5}"#.data(using: .utf8)! + ) + let wrapped = AnyCodable(object) + let bytes = try JSONEncoder().encode(wrapped) + XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":1.5}"#) + } + + func testAnyCodableEncodePreservesNativeSwiftBool() throws { + // A native Swift Bool (NOT NSNumber-backed) must encode as `true`, not `1`. + // Exercises the `type(of:) != Bool.self` guard, which routes a native Swift + // Bool past the objCType dispatch to the `case let bool as Bool` arm. + let wrapped = AnyCodable(["x": true] as [String: Any]) + let bytes = try JSONEncoder().encode(wrapped) + XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":true}"#) + } + + func testAnyCodableEncodePreservesFloatBackedNSNumber() throws { + // A Float-backed NSNumber (objCType 'f') must encode as a decimal, not an + // integer. This exercises the 'f' dispatch arm; the JSONSerialization path + // boxes JSON numbers as 'd'/'q' and never produces it. + let wrapped = AnyCodable(["x": NSNumber(value: Float(1.5))] as [String: Any]) + let bytes = try JSONEncoder().encode(wrapped) + XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":1.5}"#) + } +} diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index dd65deb4..e8defd78 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -79,6 +79,10 @@ the tag matches the version pinned in [`VERSION`](VERSION). ### Fixed +- `AnyCodable.encode(to:)` now dispatches `NSNumber`-backed values by + `objCType` before the Swift pattern-match arms, preventing `Int` from being + corrupted to `Bool` and `Double` from being corrupted to `Int` when the + wrapped value originated from `JSONSerialization`. - Encode-fidelity: an unknown `StateAction` variant no longer re-encodes to `{}` (dropping its `type` discriminant and extra fields); the raw payload is preserved on decode and re-emitted verbatim. diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index b28ed1da..3770a1fe 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -1696,7 +1696,12 @@ function anyCodableContent(): string { import Foundation /// A type-erased \`Codable\` value for handling \`unknown\` and \`Record\` types. -public struct AnyCodable: Codable, Sendable, Equatable { +/// +/// Marked \`@unchecked Sendable\` because the stored \`Any\` is only ever set to +/// immutable, \`Sendable\`-safe types during decoding (Bool, Int, Double, String, +/// NSNull, and recursive \`[Any]\`/\`[String: Any]\` of those). The value is \`let\`, +/// so it cannot be mutated after initialization. +public struct AnyCodable: Codable, @unchecked Sendable, Equatable { public let value: Any public init(_ value: Any) { @@ -1729,6 +1734,27 @@ public struct AnyCodable: Codable, Sendable, Equatable { public func encode(to encoder: Encoder) throws { var container = encoder.singleValueContainer() + + // NSNumber bridges promiscuously to Bool/Int/Double — pattern matching + // alone can't distinguish a Bool-backed NSNumber from an Int-backed one. + // Inspect objCType to dispatch faithfully to the underlying type. + // ('c' is also Int8's encoding, but JSONSerialization only ever produces + // 'c' for a Bool, so the JSON-decode path this type serves is unambiguous.) + if let number = value as? NSNumber, type(of: value) != Bool.self { + let objCType = number.objCType[0] + switch objCType { + case 0x63 /* 'c' */, 0x42 /* 'B' */: + try container.encode(number.boolValue) + return + case 0x66 /* 'f' */, 0x64 /* 'd' */: + try container.encode(number.doubleValue) + return + default: + try container.encode(number.int64Value) + return + } + } + switch value { case is NSNull: try container.encodeNil() @@ -1765,6 +1791,15 @@ public struct AnyCodable: Codable, Sendable, Equatable { return lhs == rhs case let (lhs as String, rhs as String): return lhs == rhs + case let (lhs as [Any], rhs as [Any]): + guard lhs.count == rhs.count else { return false } + return zip(lhs, rhs).allSatisfy { AnyCodable($0) == AnyCodable($1) } + case let (lhs as [String: Any], rhs as [String: Any]): + guard lhs.count == rhs.count else { return false } + return lhs.allSatisfy { key, val in + guard let other = rhs[key] else { return false } + return AnyCodable(val) == AnyCodable(other) + } default: return false } From 088ee7684619eb323ba960c0c645695529ca2191 Mon Sep 17 00:00:00 2001 From: Joshua Mouch Date: Mon, 15 Jun 2026 16:30:23 -0400 Subject: [PATCH 2/2] fix(swift): handle unsigned NSNumber above Int64.max in AnyCodable.encode Address the Copilot review on #215: the objCType dispatch sent every non-bool/non-float value through int64Value, which corrupts an unsigned NSNumber above Int64.max (a JSON integer JSONSerialization boxes as unsigned 'Q'). Add a C/I/S/L/Q arm that encodes uint64Value, mirror it in the generate-swift.ts scaffold, and add a regression test for UInt64(Int64.max)+1. --- .../Sources/AgentHostProtocol/AnyCodable.swift | 6 ++++++ .../AgentHostProtocolTests/AnyCodableTests.swift | 11 +++++++++++ clients/swift/CHANGELOG.md | 5 +++-- scripts/generate-swift.ts | 6 ++++++ 4 files changed, 26 insertions(+), 2 deletions(-) diff --git a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift index cd6db3e5..8bc91507 100644 --- a/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift +++ b/clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift @@ -47,6 +47,9 @@ public struct AnyCodable: Codable, @unchecked Sendable, Equatable { // Inspect objCType to dispatch faithfully to the underlying type. // ('c' is also Int8's encoding, but JSONSerialization only ever produces // 'c' for a Bool, so the JSON-decode path this type serves is unambiguous.) + // Unsigned integer types ('C'/'I'/'S'/'L'/'Q') encode via uint64Value: a + // JSON integer above Int64.max is boxed as an unsigned 'Q' NSNumber, and + // int64Value would silently corrupt it (it does not round-trip). if let number = value as? NSNumber, type(of: value) != Bool.self { let objCType = number.objCType[0] switch objCType { @@ -56,6 +59,9 @@ public struct AnyCodable: Codable, @unchecked Sendable, Equatable { case 0x66 /* 'f' */, 0x64 /* 'd' */: try container.encode(number.doubleValue) return + case 0x43 /* 'C' */, 0x49 /* 'I' */, 0x53 /* 'S' */, 0x4C /* 'L' */, 0x51 /* 'Q' */: + try container.encode(number.uint64Value) + return default: try container.encode(number.int64Value) return diff --git a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/AnyCodableTests.swift b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/AnyCodableTests.swift index 9e193d69..0ac73042 100644 --- a/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/AnyCodableTests.swift +++ b/clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/AnyCodableTests.swift @@ -54,4 +54,15 @@ final class AnyCodableTests: XCTestCase { let bytes = try JSONEncoder().encode(wrapped) XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":1.5}"#) } + + func testAnyCodableEncodePreservesUnsignedNSNumberAboveInt64Max() throws { + // A JSON integer above Int64.max is boxed by JSONSerialization as an + // unsigned 'Q' NSNumber. The int64Value fallback would corrupt it (it does + // not round-trip); the 'Q' dispatch arm encodes via uint64Value so the + // value survives. + let big = UInt64(Int64.max) + 1 // 9223372036854775808 + let wrapped = AnyCodable(["x": NSNumber(value: big)] as [String: Any]) + let bytes = try JSONEncoder().encode(wrapped) + XCTAssertEqual(String(data: bytes, encoding: .utf8), #"{"x":9223372036854775808}"#) + } } diff --git a/clients/swift/CHANGELOG.md b/clients/swift/CHANGELOG.md index e8defd78..76e65bac 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -81,8 +81,9 @@ the tag matches the version pinned in [`VERSION`](VERSION). - `AnyCodable.encode(to:)` now dispatches `NSNumber`-backed values by `objCType` before the Swift pattern-match arms, preventing `Int` from being - corrupted to `Bool` and `Double` from being corrupted to `Int` when the - wrapped value originated from `JSONSerialization`. + corrupted to `Bool`, `Double` from being corrupted to `Int`, and unsigned + integers above `Int64.max` from being corrupted by the signed `int64Value` + fallback, when the wrapped value originated from `JSONSerialization`. - Encode-fidelity: an unknown `StateAction` variant no longer re-encodes to `{}` (dropping its `type` discriminant and extra fields); the raw payload is preserved on decode and re-emitted verbatim. diff --git a/scripts/generate-swift.ts b/scripts/generate-swift.ts index 3770a1fe..077281ac 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -1740,6 +1740,9 @@ public struct AnyCodable: Codable, @unchecked Sendable, Equatable { // Inspect objCType to dispatch faithfully to the underlying type. // ('c' is also Int8's encoding, but JSONSerialization only ever produces // 'c' for a Bool, so the JSON-decode path this type serves is unambiguous.) + // Unsigned integer types ('C'/'I'/'S'/'L'/'Q') encode via uint64Value: a + // JSON integer above Int64.max is boxed as an unsigned 'Q' NSNumber, and + // int64Value would silently corrupt it (it does not round-trip). if let number = value as? NSNumber, type(of: value) != Bool.self { let objCType = number.objCType[0] switch objCType { @@ -1749,6 +1752,9 @@ public struct AnyCodable: Codable, @unchecked Sendable, Equatable { case 0x66 /* 'f' */, 0x64 /* 'd' */: try container.encode(number.doubleValue) return + case 0x43 /* 'C' */, 0x49 /* 'I' */, 0x53 /* 'S' */, 0x4C /* 'L' */, 0x51 /* 'Q' */: + try container.encode(number.uint64Value) + return default: try container.encode(number.int64Value) return