Summary
AnyCodable.encode(to:) corrupts Int values to Bool (and Double to Int) when the wrapped Any is an NSNumber. This breaks any client pipeline that decodes wire JSON into [String: Any] (via JSONSerialization), wraps it in AnyCodable, then re-encodes through JSONEncoder — for example, the receive path in AgentHostProtocolClient had to bypass AnyCodable entirely and route raw Data slices through JSONSerialization to avoid this. The fix is local to AnyCodable.swift.
The decode side is unaffected: JSONDecoder is strict and does not promiscuously bridge 1/true to the wrong type.
Repro
import Foundation
// AgentHostProtocol/AnyCodable.swift, verbatim — assume `AnyCodable` is in scope.
// Simulate the receive pipeline: a typed value is encoded by the peer to
// JSON, parsed back via JSONSerialization (which produces NSNumber for
// numerics), wrapped in `AnyCodable`, and re-encoded.
struct Payload: Codable { let serverSeq: Int }
let original = Payload(serverSeq: 1)
let wireBytes = try JSONEncoder().encode(original) // {"serverSeq":1}
let object = try JSONSerialization.jsonObject(with: wireBytes) // [String: Any] with NSNumber
let wrapped = AnyCodable(object)
let reEncoded = try JSONEncoder().encode(wrapped)
print(String(data: reEncoded, encoding: .utf8)!) // ⇒ {"serverSeq":true} ❌
// Decoding the corrupted bytes back into the typed shape fails:
do {
_ = try JSONDecoder().decode(Payload.self, from: reEncoded)
} catch {
print(error) // typeMismatch: Expected Int but found bool
}
Output:
{"serverSeq":true}
typeMismatch(... debugDescription: "Expected to decode Int but found bool instead." ...)
A Double-valued NSNumber is also corrupted to Int for the same reason (as Int matches before as Double).
Diagnosis
public func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
switch value {
case is NSNull:
try container.encodeNil()
case let bool as Bool: // ← matches FIRST for any NSNumber
try container.encode(bool)
case let int as Int:
try container.encode(int)
...
NSNumber bridges to Bool, Int, and Double regardless of the underlying value:
NSNumber(value: 1) as? Bool = Optional(true) // wrong
NSNumber(value: 1) as? Int = Optional(1)
NSNumber(value: true) as? Bool = Optional(true)
NSNumber(value: true) as? Int = Optional(1) // would also be wrong if Bool case were second
Reordering the cases doesn't fix it — case let int as Int would then catch NSNumber(value: true) and encode true as 1.
The reliable distinguisher is NSNumber.objCType:
| Underlying value |
objCType |
Bool (true/false) |
c (also B on some platforms) |
| Int / Int32 / Int64 |
q, l, i, s (signed) or Q, L, I, S (unsigned) |
| Float |
f |
| Double |
d |
Suggested fix
Special-case NSNumber before the as Bool / as Int / as Double pattern matches:
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.
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()
case let bool as Bool:
try container.encode(bool)
case let int as Int:
try container.encode(int)
case let double as Double:
try container.encode(double)
case let string as String:
try container.encode(string)
case let array as [Any]:
try container.encode(array.map { AnyCodable($0) })
case let dict as [String: Any]:
try container.encode(dict.mapValues { AnyCodable($0) })
default:
throw EncodingError.invalidValue(
value,
EncodingError.Context(
codingPath: encoder.codingPath,
debugDescription: "AnyCodable cannot encode value of type \(type(of: value))"
)
)
}
}
The type(of: value) != Bool.self guard preserves the existing path for native Swift Bool values (which aren't NSNumbers).
Suggested tests
Add to clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/:
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}"#)
}
Impact
Any consumer that goes through the JSONSerialization → AnyCodable → JSONEncoder path is affected. In PR #122 (AgentHostProtocolClient), the inbound and outbound JSON-RPC paths had to be restructured to bypass AnyCodable and instead carry raw Data slices through JSONSerialization round-trips (which preserves NSNumber.objCType faithfully). Once this issue is fixed, that workaround can be replaced with the simpler AnyCodable-based pipeline.
The upcoming Swift MultiHostClient (PR 3) will also use the same workaround until this is fixed.
Scope
This is a one-file fix in clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift. No protocol or wire changes; no codegen impact. Adding the suggested tests is straightforward.
Summary
AnyCodable.encode(to:)corruptsIntvalues toBool(andDoubletoInt) when the wrappedAnyis anNSNumber. This breaks any client pipeline that decodes wire JSON into[String: Any](viaJSONSerialization), wraps it inAnyCodable, then re-encodes throughJSONEncoder— for example, the receive path inAgentHostProtocolClienthad to bypassAnyCodableentirely and route rawDataslices throughJSONSerializationto avoid this. The fix is local toAnyCodable.swift.The decode side is unaffected:
JSONDecoderis strict and does not promiscuously bridge1/trueto the wrong type.Repro
Output:
A
Double-valuedNSNumberis also corrupted toIntfor the same reason (as Intmatches beforeas Double).Diagnosis
NSNumberbridges toBool,Int, andDoubleregardless of the underlying value:Reordering the
cases doesn't fix it —case let int as Intwould then catchNSNumber(value: true)and encodetrueas1.The reliable distinguisher is
NSNumber.objCType:true/false)c(alsoBon some platforms)q,l,i,s(signed) orQ,L,I,S(unsigned)fdSuggested fix
Special-case
NSNumberbefore theas Bool/as Int/as Doublepattern matches:The
type(of: value) != Bool.selfguard preserves the existing path for native SwiftBoolvalues (which aren'tNSNumbers).Suggested tests
Add to
clients/swift/AgentHostProtocol/Tests/AgentHostProtocolTests/:Impact
Any consumer that goes through the
JSONSerialization → AnyCodable → JSONEncoderpath is affected. In PR #122 (AgentHostProtocolClient), the inbound and outbound JSON-RPC paths had to be restructured to bypassAnyCodableand instead carry rawDataslices throughJSONSerializationround-trips (which preservesNSNumber.objCTypefaithfully). Once this issue is fixed, that workaround can be replaced with the simplerAnyCodable-based pipeline.The upcoming Swift
MultiHostClient(PR 3) will also use the same workaround until this is fixed.Scope
This is a one-file fix in
clients/swift/AgentHostProtocol/Sources/AgentHostProtocol/AnyCodable.swift. No protocol or wire changes; no codegen impact. Adding the suggested tests is straightforward.