mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
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.
This commit is contained in:
@@ -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..<self.index])
|
||||
}
|
||||
|
||||
private mutating func parseString() throws -> 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..<self.index])
|
||||
if !isFloatingPoint, let int = Int(raw) {
|
||||
return int
|
||||
}
|
||||
if let double = Double(raw) {
|
||||
return double
|
||||
}
|
||||
throw ModelCatalogParseError.invalidNumber
|
||||
}
|
||||
|
||||
private mutating func skipTypeAssertion() {
|
||||
while true {
|
||||
self.skipWhitespaceAndComments()
|
||||
if self.consumeKeyword("satisfies") || self.consumeKeyword("as") {
|
||||
self.skipTypeExpression()
|
||||
} else {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private mutating func skipTypeExpression() {
|
||||
var angleDepth = 0
|
||||
while let char = self.current {
|
||||
if char == "<" {
|
||||
angleDepth += 1
|
||||
self.advance()
|
||||
continue
|
||||
}
|
||||
if char == ">", 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 == "$"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, number>,
|
||||
},
|
||||
};
|
||||
"""#
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user