From 4102f8d28d8930bda05e3d8c8a4bc7ea76ed6cc1 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 27 Apr 2026 20:16:51 -0700 Subject: [PATCH] fix(macos): parse model catalog without JavaScriptCore Replaces JavaScriptCore catalog evaluation with a bounded fail-closed object-literal parser for the generated macOS model catalog.\n\nValidation: macos-node, macos-swift, security-fast, security-scm-fast, security-dependency-audit, workflow sanity checks passed on PR #73112. --- .../Sources/OpenClaw/ModelCatalogLoader.swift | 496 ++++++++++++++++-- .../ModelCatalogLoaderTests.swift | 54 +- 2 files changed, 514 insertions(+), 36 deletions(-) diff --git a/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift b/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift index b320c84d232..96543c2423d 100644 --- a/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift +++ b/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift @@ -1,11 +1,11 @@ import Foundation -import JavaScriptCore enum ModelCatalogLoader { static var defaultPath: String { self.resolveDefaultPath() } + private static let maxCatalogBytes: UInt64 = 2 * 1024 * 1024 private static let logger = Logger(subsystem: "ai.openclaw", category: "models") private nonisolated static let appSupportDir: URL = { let base = FileManager().urls(for: .applicationSupportDirectory, in: .userDomainMask).first! @@ -26,23 +26,8 @@ enum ModelCatalogLoader { userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"]) } self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)") - let source = try String(contentsOfFile: resolved.path, encoding: .utf8) - let sanitized = self.sanitize(source: source) - - let ctx = JSContext() - ctx?.exceptionHandler = { _, exception in - if let exception { - self.logger.warning("model catalog JS exception: \(exception)") - } - } - ctx?.evaluateScript(sanitized) - guard let rawModels = ctx?.objectForKeyedSubscript("MODELS")?.toDictionary() as? [String: Any] else { - self.logger.error("model catalog parse failed: MODELS missing") - throw NSError( - domain: "ModelCatalogLoader", - code: 1, - userInfo: [NSLocalizedDescriptionKey: "Failed to parse models.generated.ts"]) - } + let source = try self.readCatalogSource(path: resolved.path) + let rawModels = try self.parseModels(source: source) var choices: [ModelChoice] = [] for (provider, value) in rawModels { @@ -138,22 +123,465 @@ enum ModelCatalogLoader { } } - private static func sanitize(source: String) -> String { - guard let exportRange = source.range(of: "export const MODELS"), - let firstBrace = source[exportRange.upperBound...].firstIndex(of: "{"), - let lastBrace = source.lastIndex(of: "}") - else { - return "var MODELS = {}" + private static func readCatalogSource(path: String) throws -> String { + let attrs = try FileManager().attributesOfItem(atPath: path) + if let size = attrs[.size] as? NSNumber, + size.uint64Value > self.maxCatalogBytes + { + throw NSError( + domain: "ModelCatalogLoader", + code: 2, + userInfo: [NSLocalizedDescriptionKey: "Model catalog file is too large"]) } - var body = String(source[firstBrace...lastBrace]) - body = body.replacingOccurrences( - of: #"(?m)\bsatisfies\s+[^,}\n]+"#, - with: "", - options: .regularExpression) - body = body.replacingOccurrences( - of: #"(?m)\bas\s+[^;,\n]+"#, - with: "", - options: .regularExpression) - return "var MODELS = \(body);" + return try String(contentsOfFile: path, encoding: .utf8) + } + + private static func parseModels(source: String) throws -> [String: Any] { + guard let assignmentEnd = self.findModelsAssignmentEnd(in: source) else { + throw ModelCatalogParseError.missingModelsExport + } + var parser = ModelCatalogObjectParser(source: String(source[assignmentEnd...])) + return try parser.parseObject() + } + + private static func findModelsAssignmentEnd(in source: String) -> String.Index? { + var index = source.startIndex + while index < source.endIndex { + if self.consumeIf("//", in: source, at: &index) { + self.skipLineComment(in: source, from: &index) + continue + } + if self.consumeIf("/*", in: source, at: &index) { + self.skipBlockComment(in: source, from: &index) + continue + } + if source[index] == "\"" || source[index] == "'" || source[index] == "`" { + self.skipString(in: source, quote: source[index], from: &index) + continue + } + + var cursor = index + if self.consumeKeyword("export", in: source, at: &cursor) { + self.skipWhitespaceAndComments(in: source, from: &cursor) + if self.consumeKeyword("const", in: source, at: &cursor) { + self.skipWhitespaceAndComments(in: source, from: &cursor) + if self.consumeKeyword("MODELS", in: source, at: &cursor) { + self.skipWhitespaceAndComments(in: source, from: &cursor) + if self.consumeIf("=", in: source, at: &cursor) { + return cursor + } + } + } + } + + index = source.index(after: index) + } + return nil + } + + private static func skipWhitespaceAndComments(in source: String, from index: inout String.Index) { + while index < source.endIndex { + if source[index].isWhitespace { + index = source.index(after: index) + continue + } + if self.consumeIf("//", in: source, at: &index) { + self.skipLineComment(in: source, from: &index) + continue + } + if self.consumeIf("/*", in: source, at: &index) { + self.skipBlockComment(in: source, from: &index) + continue + } + return + } + } + + private static func skipLineComment(in source: String, from index: inout String.Index) { + while index < source.endIndex, source[index] != "\n" { + index = source.index(after: index) + } + } + + private static func skipBlockComment(in source: String, from index: inout String.Index) { + while index < source.endIndex, !self.consumeIf("*/", in: source, at: &index) { + index = source.index(after: index) + } + } + + private static func skipString(in source: String, quote: Character, from index: inout String.Index) { + index = source.index(after: index) + while index < source.endIndex { + let char = source[index] + index = source.index(after: index) + if char == "\\" { + if index < source.endIndex { + index = source.index(after: index) + } + continue + } + if char == quote { + return + } + } + } + + private static func consumeKeyword(_ keyword: String, in source: String, at index: inout String.Index) -> Bool { + guard source[index...].hasPrefix(keyword) else { + return false + } + let end = source.index(index, offsetBy: keyword.count) + if index > source.startIndex { + let previous = source[source.index(before: index)] + if self.isIdentifierCharacter(previous) { + return false + } + } + if end < source.endIndex, self.isIdentifierCharacter(source[end]) { + return false + } + index = end + return true + } + + private static func consumeIf(_ token: String, in source: String, at index: inout String.Index) -> Bool { + guard source[index...].hasPrefix(token) else { + return false + } + index = source.index(index, offsetBy: token.count) + return true + } + + private static func isIdentifierCharacter(_ char: Character) -> Bool { + char.isLetter || char.isNumber || char == "_" || char == "$" + } +} + +private enum ModelCatalogParseError: Error { + case expectedObject + case expectedKey + case expectedColon + case expectedValue + case maxDepthExceeded + case missingModelsExport + case unterminatedString + case invalidNumber + case unexpectedToken +} + +private struct ModelCatalogObjectParser { + private let maxDepth: Int + private let source: String + private var index: String.Index + + init(source: String, maxDepth: Int = 80) { + self.maxDepth = maxDepth + self.source = source + self.index = source.startIndex + } + + mutating func parseObject(depth: Int = 0) throws -> [String: Any] { + guard depth <= self.maxDepth else { + throw ModelCatalogParseError.maxDepthExceeded + } + try self.consume("{", or: .expectedObject) + var result: [String: Any] = [:] + + while true { + self.skipWhitespaceAndComments() + if self.consumeIf("}") { + return result + } + + let key = try self.parseKey() + self.skipWhitespaceAndComments() + try self.consume(":", or: .expectedColon) + let value = try self.parseValue(depth: depth) + self.skipTypeAssertion() + result[key] = value + + self.skipWhitespaceAndComments() + if self.consumeIf(",") { + continue + } + if self.consumeIf("}") { + return result + } + throw ModelCatalogParseError.unexpectedToken + } + } + + private mutating func parseArray(depth: Int) throws -> [Any] { + guard depth <= self.maxDepth else { + throw ModelCatalogParseError.maxDepthExceeded + } + try self.consume("[", or: .expectedValue) + var result: [Any] = [] + + while true { + self.skipWhitespaceAndComments() + if self.consumeIf("]") { + return result + } + + try result.append(self.parseValue(depth: depth)) + self.skipTypeAssertion() + self.skipWhitespaceAndComments() + if self.consumeIf(",") { + continue + } + if self.consumeIf("]") { + return result + } + throw ModelCatalogParseError.unexpectedToken + } + } + + private mutating func parseValue(depth: Int) throws -> Any { + self.skipWhitespaceAndComments() + guard let char = self.current else { + throw ModelCatalogParseError.expectedValue + } + + switch char { + case "{": + return try self.parseObject(depth: depth + 1) + case "[": + return try self.parseArray(depth: depth + 1) + case "\"", "'": + return try self.parseString() + case "-", "0"..."9": + return try self.parseNumber() + default: + let identifier = try self.parseIdentifier() + switch identifier { + case "true": + return true + case "false": + return false + case "null", "undefined": + return NSNull() + default: + throw ModelCatalogParseError.unexpectedToken + } + } + } + + private mutating func parseKey() throws -> String { + self.skipWhitespaceAndComments() + guard let char = self.current else { + throw ModelCatalogParseError.expectedKey + } + if char == "\"" || char == "'" { + return try self.parseString() + } + return try self.parseIdentifier() + } + + private mutating func parseIdentifier() throws -> String { + self.skipWhitespaceAndComments() + let start = self.index + while let char = self.current, self.isIdentifierCharacter(char) { + self.advance() + } + guard start != self.index else { + throw ModelCatalogParseError.expectedKey + } + return String(self.source[start.. String { + guard let quote = self.current, quote == "\"" || quote == "'" else { + throw ModelCatalogParseError.expectedValue + } + self.advance() + + var result = "" + while let char = self.current { + self.advance() + if char == quote { + return result + } + if char == "\\" { + try result.append(self.parseEscapedCharacter()) + } else { + result.append(char) + } + } + throw ModelCatalogParseError.unterminatedString + } + + private mutating func parseEscapedCharacter() throws -> Character { + guard let char = self.current else { + throw ModelCatalogParseError.unterminatedString + } + self.advance() + + switch char { + case "\"", "'", "\\", "/": + return char + case "b": + return "\u{08}" + case "f": + return "\u{0c}" + case "n": + return "\n" + case "r": + return "\r" + case "t": + return "\t" + case "u": + return try self.parseUnicodeEscape() + default: + return char + } + } + + private mutating func parseUnicodeEscape() throws -> Character { + var hex = "" + for _ in 0..<4 { + guard let char = self.current else { + throw ModelCatalogParseError.unterminatedString + } + hex.append(char) + self.advance() + } + guard let value = UInt32(hex, radix: 16), + let scalar = UnicodeScalar(value) + else { + throw ModelCatalogParseError.unterminatedString + } + return Character(scalar) + } + + private mutating func parseNumber() throws -> Any { + let start = self.index + if self.current == "-" { + self.advance() + } + while let char = self.current, ("0"..."9").contains(char) { + self.advance() + } + var isFloatingPoint = false + if self.current == "." { + isFloatingPoint = true + self.advance() + while let char = self.current, ("0"..."9").contains(char) { + self.advance() + } + } + if self.current == "e" || self.current == "E" { + isFloatingPoint = true + self.advance() + if self.current == "-" || self.current == "+" { + self.advance() + } + while let char = self.current, ("0"..."9").contains(char) { + self.advance() + } + } + + let raw = String(self.source[start..", angleDepth > 0 { + angleDepth -= 1 + self.advance() + continue + } + if angleDepth == 0, char == "," || char == "}" || char == "]" { + return + } + self.advance() + } + } + + private mutating func skipWhitespaceAndComments() { + while true { + while let char = self.current, char.isWhitespace { + self.advance() + } + if self.consumeIf("//") { + while let char = self.current, char != "\n" { + self.advance() + } + continue + } + if self.consumeIf("/*") { + while self.index < self.source.endIndex, !self.consumeIf("*/") { + self.advance() + } + continue + } + return + } + } + + private mutating func consume(_ token: String, or error: ModelCatalogParseError) throws { + self.skipWhitespaceAndComments() + guard self.consumeIf(token) else { + throw error + } + } + + private mutating func consumeIf(_ token: String) -> Bool { + guard self.source[self.index...].hasPrefix(token) else { + return false + } + self.index = self.source.index(self.index, offsetBy: token.count) + return true + } + + private mutating func consumeKeyword(_ keyword: String) -> Bool { + guard self.source[self.index...].hasPrefix(keyword) else { + return false + } + let end = self.source.index(self.index, offsetBy: keyword.count) + if end < self.source.endIndex, self.isIdentifierCharacter(self.source[end]) { + return false + } + self.index = end + return true + } + + private var current: Character? { + guard self.index < self.source.endIndex else { + return nil + } + return self.source[self.index] + } + + private mutating func advance() { + self.index = self.source.index(after: self.index) + } + + private func isIdentifierCharacter(_ char: Character) -> Bool { + char.isLetter || char.isNumber || char == "_" || char == "$" } } diff --git a/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift b/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift index f3ddc6287c8..b0e263f13f8 100644 --- a/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift +++ b/apps/macos/Tests/OpenClawIPCTests/ModelCatalogLoaderTests.swift @@ -39,14 +39,64 @@ struct ModelCatalogLoaderTests { } @Test - func `load with no export returns empty choices`() async throws { + func `load with no export rejects catalog`() async throws { let src = "const NOPE = 1;" let tmp = FileManager().temporaryDirectory .appendingPathComponent("models-\(UUID().uuidString).ts") defer { try? FileManager().removeItem(at: tmp) } try src.write(to: tmp, atomically: true, encoding: .utf8) + do { + _ = try await ModelCatalogLoader.load(from: tmp.path) + Issue.record("expected missing MODELS export rejection") + } catch { + #expect(String(describing: error).isEmpty == false) + } + } + + @Test + func `load ignores fake exports in comments and strings`() async throws { + let src = #""" + // export const MODELS = { bad: { "bad": { name: "Bad", contextWindow: 1 } } }; + const text = "export const MODELS = { alsoBad: {} }"; + export const MODELS = { + openai: { + "gpt-4o": { name: "GPT-4o", contextWindow: 128000 } satisfies ModelConfig, + }, + }; + """# + + let tmp = FileManager().temporaryDirectory + .appendingPathComponent("models-\(UUID().uuidString).ts") + defer { try? FileManager().removeItem(at: tmp) } + try src.write(to: tmp, atomically: true, encoding: .utf8) + let choices = try await ModelCatalogLoader.load(from: tmp.path) - #expect(choices.isEmpty) + #expect(choices.count == 1) + #expect(choices.first?.id == "gpt-4o") + #expect(choices.first?.provider == "openai") + } + + @Test + func `load rejects executable catalog expressions`() async throws { + let src = """ + export const MODELS = { + openai: { + "gpt-4o": { name: (() => { throw new Error("nope") })(), contextWindow: 128000 }, + }, + }; + """ + + let tmp = FileManager().temporaryDirectory + .appendingPathComponent("models-\(UUID().uuidString).ts") + defer { try? FileManager().removeItem(at: tmp) } + try src.write(to: tmp, atomically: true, encoding: .utf8) + + do { + _ = try await ModelCatalogLoader.load(from: tmp.path) + Issue.record("expected executable catalog expression rejection") + } catch { + #expect(String(describing: error).isEmpty == false) + } } }