Files
openclaw/apps/macos/Sources/OpenClaw/ModelCatalogLoader.swift
Vincent Koc 4102f8d28d 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.
2026-04-27 20:16:51 -07:00

588 lines
20 KiB
Swift

import Foundation
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!
return base.appendingPathComponent("OpenClaw", isDirectory: true)
}()
private static var cachePath: URL {
self.appSupportDir.appendingPathComponent("model-catalog/models.generated.js", isDirectory: false)
}
static func load(from path: String) async throws -> [ModelChoice] {
let expanded = (path as NSString).expandingTildeInPath
guard let resolved = self.resolvePath(preferred: expanded) else {
self.logger.error("model catalog load failed: file not found")
throw NSError(
domain: "ModelCatalogLoader",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Model catalog file not found"])
}
self.logger.debug("model catalog load start file=\(URL(fileURLWithPath: resolved.path).lastPathComponent)")
let source = try self.readCatalogSource(path: resolved.path)
let rawModels = try self.parseModels(source: source)
var choices: [ModelChoice] = []
for (provider, value) in rawModels {
guard let models = value as? [String: Any] else { continue }
for (id, payload) in models {
guard let dict = payload as? [String: Any] else { continue }
let name = dict["name"] as? String ?? id
let ctxWindow = dict["contextWindow"] as? Int
choices.append(ModelChoice(id: id, name: name, provider: provider, contextWindow: ctxWindow))
}
}
let sorted = choices.sorted { lhs, rhs in
if lhs.provider == rhs.provider {
return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending
}
return lhs.provider.localizedCaseInsensitiveCompare(rhs.provider) == .orderedAscending
}
self.logger.debug("model catalog loaded providers=\(rawModels.count) models=\(sorted.count)")
if resolved.shouldCache {
self.cacheCatalog(sourcePath: resolved.path)
}
return sorted
}
private static func resolveDefaultPath() -> String {
let cache = self.cachePath.path
if FileManager().isReadableFile(atPath: cache) { return cache }
if let bundlePath = self.bundleCatalogPath() { return bundlePath }
if let nodePath = self.nodeModulesCatalogPath() { return nodePath }
return cache
}
private static func resolvePath(preferred: String) -> (path: String, shouldCache: Bool)? {
if FileManager().isReadableFile(atPath: preferred) {
return (preferred, preferred != self.cachePath.path)
}
if let bundlePath = self.bundleCatalogPath(), bundlePath != preferred {
self.logger.warning("model catalog path missing; falling back to bundled catalog")
return (bundlePath, true)
}
let cache = self.cachePath.path
if cache != preferred, FileManager().isReadableFile(atPath: cache) {
self.logger.warning("model catalog path missing; falling back to cached catalog")
return (cache, false)
}
if let nodePath = self.nodeModulesCatalogPath(), nodePath != preferred {
self.logger.warning("model catalog path missing; falling back to node_modules catalog")
return (nodePath, true)
}
return nil
}
private static func bundleCatalogPath() -> String? {
guard let url = Bundle.main.url(forResource: "models.generated", withExtension: "js") else {
return nil
}
return url.path
}
private static func nodeModulesCatalogPath() -> String? {
let roots = [
URL(fileURLWithPath: CommandResolver.projectRootPath()),
URL(fileURLWithPath: FileManager().currentDirectoryPath),
]
for root in roots {
let candidate = root
.appendingPathComponent("node_modules/@mariozechner/pi-ai/dist/models.generated.js")
if FileManager().isReadableFile(atPath: candidate.path) {
return candidate.path
}
}
return nil
}
private static func cacheCatalog(sourcePath: String) {
let destination = self.cachePath
do {
try FileManager().createDirectory(
at: destination.deletingLastPathComponent(),
withIntermediateDirectories: true)
if FileManager().fileExists(atPath: destination.path) {
try FileManager().removeItem(at: destination)
}
try FileManager().copyItem(atPath: sourcePath, toPath: destination.path)
self.logger.debug("model catalog cached file=\(destination.lastPathComponent)")
} catch {
self.logger.warning("model catalog cache failed: \(error.localizedDescription)")
}
}
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"])
}
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 == "$"
}
}