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 58c1ec4f..22e72840 100644 --- a/clients/swift/CHANGELOG.md +++ b/clients/swift/CHANGELOG.md @@ -65,6 +65,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 85da8dcc..f6b815a8 100644 --- a/scripts/generate-swift.ts +++ b/scripts/generate-swift.ts @@ -1597,7 +1597,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) { @@ -1630,6 +1635,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() @@ -1666,6 +1692,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 }