Skip to content

Commit d93287c

Browse files
committed
fix(mac): guard browser.proxy JSON serialization against non-serializable values
1 parent 0c926a2 commit d93287c

File tree

5 files changed

+167
-11
lines changed

5 files changed

+167
-11
lines changed

apps/macos/Package.resolved

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/macos/Sources/OpenClaw/CronModels.swift

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,38 @@ struct CronJob: Identifiable, Codable, Equatable {
237237
let delivery: CronDelivery?
238238
let state: CronJobState
239239

240+
init(
241+
id: String,
242+
agentId: String? = nil,
243+
name: String,
244+
description: String? = nil,
245+
enabled: Bool,
246+
deleteAfterRun: Bool? = nil,
247+
createdAtMs: Int,
248+
updatedAtMs: Int,
249+
schedule: CronSchedule,
250+
sessionTarget: CronSessionTarget = .isolated,
251+
wakeMode: CronWakeMode,
252+
payload: CronPayload,
253+
delivery: CronDelivery? = nil,
254+
state: CronJobState
255+
) {
256+
self.id = id
257+
self.agentId = agentId
258+
self.name = name
259+
self.description = description
260+
self.enabled = enabled
261+
self.deleteAfterRun = deleteAfterRun
262+
self.createdAtMs = createdAtMs
263+
self.updatedAtMs = updatedAtMs
264+
self.schedule = schedule
265+
self.sessionTargetRaw = sessionTarget.rawValue
266+
self.wakeMode = wakeMode
267+
self.payload = payload
268+
self.delivery = delivery
269+
self.state = state
270+
}
271+
240272
enum CodingKeys: String, CodingKey {
241273
case id
242274
case agentId

apps/macos/Sources/OpenClaw/CronSettings+Testing.swift

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,10 @@ struct CronSettings_Previews: PreviewProvider {
2121
message: "Summarize inbox",
2222
thinking: "low",
2323
timeoutSeconds: 600,
24-
deliver: nil,
25-
channel: nil,
26-
to: nil,
27-
bestEffortDeliver: nil),
24+
deliver: nil as Bool?,
25+
channel: nil as String?,
26+
to: nil as String?,
27+
bestEffortDeliver: nil as Bool?),
2828
delivery: CronDelivery(mode: .announce, channel: "last", to: nil, bestEffort: true),
2929
state: CronJobState(
3030
nextRunAtMs: Int(Date().addingTimeInterval(3600).timeIntervalSince1970 * 1000),
@@ -75,10 +75,10 @@ extension CronSettings {
7575
message: "Summarize",
7676
thinking: "low",
7777
timeoutSeconds: 120,
78-
deliver: nil,
79-
channel: nil,
80-
to: nil,
81-
bestEffortDeliver: nil),
78+
deliver: nil as Bool?,
79+
channel: nil as String?,
80+
to: nil as String?,
81+
bestEffortDeliver: nil as Bool?),
8282
delivery: CronDelivery(mode: .announce, channel: "whatsapp", to: "+15551234567", bestEffort: true),
8383
state: CronJobState(
8484
nextRunAtMs: 1_700_000_200_000,

apps/macos/Sources/OpenClaw/NodeMode/MacNodeBrowserProxy.swift

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,8 @@ actor MacNodeBrowserProxy {
147147
}
148148

149149
if method != "GET", let body = params.body {
150-
request.httpBody = try JSONSerialization.data(withJSONObject: body.foundationValue, options: [.fragmentsAllowed])
150+
let jsonValue = Self.sanitizeForJSON(body.foundationValue)
151+
request.httpBody = try JSONSerialization.data(withJSONObject: jsonValue, options: [.fragmentsAllowed])
151152
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
152153
}
153154

@@ -187,6 +188,33 @@ actor MacNodeBrowserProxy {
187188
return String(describing: value)
188189
}
189190

191+
/// Recursively walks a value tree and converts any type that
192+
/// `NSJSONSerialization` cannot handle into a `String` representation.
193+
/// This prevents crashes when `AnyCodable.foundationValue` produces
194+
/// opaque Swift values (e.g. structs/classes) that are not valid JSON.
195+
static func sanitizeForJSON(_ value: Any) -> Any {
196+
if JSONSerialization.isValidJSONObject([value]) {
197+
return value
198+
}
199+
switch value {
200+
case let dict as [String: Any]:
201+
return dict.mapValues { sanitizeForJSON($0) }
202+
case let array as [Any]:
203+
return array.map { sanitizeForJSON($0) }
204+
case let codable as AnyCodable:
205+
return sanitizeForJSON(codable.foundationValue)
206+
case is String, is Int, is Double, is Bool, is NSNull:
207+
return value
208+
case let number as NSNumber:
209+
if CFGetTypeID(number) == CFBooleanGetTypeID() {
210+
return number.boolValue
211+
}
212+
return number
213+
default:
214+
return String(describing: value)
215+
}
216+
}
217+
190218
private static func loadProxyFiles(from result: Any) throws -> [ProxyFilePayload] {
191219
let paths = self.collectProxyPaths(from: result)
192220
return try paths.map(self.loadProxyFile)

apps/macos/Tests/OpenClawIPCTests/MacNodeBrowserProxyTests.swift

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,100 @@ struct MacNodeBrowserProxyTests {
8383
let arr = try #require(parsed["arr"] as? [Any])
8484
#expect(arr.count == 2)
8585
}
86+
87+
// MARK: - sanitizeForJSON
88+
89+
@Test func sanitizeForJSONConvertsNonSerializableValuesToStrings() {
90+
// A custom Swift struct that NSJSONSerialization cannot handle.
91+
struct OpaqueValue: CustomStringConvertible {
92+
let id: Int
93+
var description: String { "OpaqueValue(\(id))" }
94+
}
95+
96+
let result = MacNodeBrowserProxy.sanitizeForJSON(OpaqueValue(id: 42))
97+
#expect(result as? String == "OpaqueValue(42)")
98+
}
99+
100+
@Test func sanitizeForJSONRecursesIntoDictionaries() throws {
101+
struct Opaque: CustomStringConvertible {
102+
var description: String { "opaque" }
103+
}
104+
105+
let input: [String: Any] = [
106+
"ok": true,
107+
"count": 3,
108+
"nested": ["inner": Opaque()],
109+
]
110+
let result = MacNodeBrowserProxy.sanitizeForJSON(input)
111+
let dict = try #require(result as? [String: Any])
112+
#expect(dict["ok"] as? Bool == true)
113+
#expect(dict["count"] as? Int == 3)
114+
let nested = try #require(dict["nested"] as? [String: Any])
115+
#expect(nested["inner"] as? String == "opaque")
116+
}
117+
118+
@Test func sanitizeForJSONRecursesIntoArrays() throws {
119+
struct Opaque: CustomStringConvertible {
120+
var description: String { "item" }
121+
}
122+
123+
let input: [Any] = [1, "two", Opaque()]
124+
let result = MacNodeBrowserProxy.sanitizeForJSON(input)
125+
let arr = try #require(result as? [Any])
126+
#expect(arr.count == 3)
127+
#expect(arr[0] as? Int == 1)
128+
#expect(arr[1] as? String == "two")
129+
#expect(arr[2] as? String == "item")
130+
}
131+
132+
@Test func sanitizeForJSONPreservesJSONSafeValues() throws {
133+
let dict: [String: Any] = ["a": 1, "b": "hello", "c": true, "d": NSNull()]
134+
let result = MacNodeBrowserProxy.sanitizeForJSON(dict)
135+
// Should be passable to NSJSONSerialization without throwing.
136+
let data = try JSONSerialization.data(withJSONObject: result)
137+
let parsed = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
138+
#expect(parsed["a"] as? Int == 1)
139+
#expect(parsed["b"] as? String == "hello")
140+
#expect(parsed["c"] as? Bool == true)
141+
}
142+
143+
// Regression: a POST request whose body produces non-JSON-serializable
144+
// foundation values must NOT crash with SIGABRT.
145+
@Test func postRequestWithNonSerializableBodyDoesNotCrash() async throws {
146+
actor BodyCapture {
147+
private var body: Data?
148+
func set(_ body: Data?) { self.body = body }
149+
func get() -> Data? { self.body }
150+
}
151+
152+
let capturedBody = BodyCapture()
153+
let proxy = MacNodeBrowserProxy(
154+
endpointProvider: {
155+
MacNodeBrowserProxy.Endpoint(
156+
baseURL: URL(string: "http://127.0.0.1:18791")!,
157+
token: nil,
158+
password: nil)
159+
},
160+
performRequest: { request in
161+
await capturedBody.set(request.httpBody)
162+
let url = try #require(request.url)
163+
let response = try #require(
164+
HTTPURLResponse(
165+
url: url,
166+
statusCode: 200,
167+
httpVersion: nil,
168+
headerFields: nil))
169+
return (Data(#"{"ok":true}"#.utf8), response)
170+
})
171+
172+
// Encode a body that, after AnyCodable decoding, is fine.
173+
// The sanitization layer ensures no crash even if the gateway
174+
// sends something unexpected in the future.
175+
_ = try await proxy.request(
176+
paramsJSON: #"{"method":"POST","path":"/action","body":{"key":"val"}}"#)
177+
178+
let bodyData = try #require(await capturedBody.get())
179+
let parsed = try #require(JSONSerialization.jsonObject(with: bodyData) as? [String: Any])
180+
#expect(parsed["key"] as? String == "val")
181+
}
86182
}

0 commit comments

Comments
 (0)