diff --git a/Package.swift b/Package.swift index 5d7dee351c..abad887799 100644 --- a/Package.swift +++ b/Package.swift @@ -69,10 +69,15 @@ let package = Package( .product(name: "Logging", package: "swift-log"), .product(name: "SweetCookieKit", package: "SweetCookieKit"), ], + resources: [ + .process("Resources"), + ], swiftSettings: [ .enableUpcomingFeature("StrictConcurrency"), ], - linkerSettings: sqlite3LinkerSettings), + linkerSettings: sqlite3LinkerSettings + [ + .linkedFramework("JavaScriptCore", .when(platforms: [.macOS])), + ]), .executableTarget( name: "CodexBarCLI", dependencies: [ diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift b/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift new file mode 100644 index 0000000000..4de9529a9a --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginManifest.swift @@ -0,0 +1,236 @@ +#if canImport(JavaScriptCore) +import Foundation +@preconcurrency import JavaScriptCore + +public struct ProviderPluginSetting: Equatable, Sendable { + public enum Kind: String, Sendable { + case plain + case secure + } + + public let key: String + public let title: String + public let subtitle: String? + public let kind: Kind + + public init(key: String, title: String, subtitle: String? = nil, kind: Kind = .secure) { + self.key = key + self.title = title + self.subtitle = subtitle + self.kind = kind + } +} + +public struct ProviderPluginAuth: Equatable, Sendable { + public enum Kind: String, Sendable { + case bearer + case xAPIKey = "x-api-key" + case header + } + + public let type: Kind + public let header: String + public let secret: String + + public init(type: Kind, header: String, secret: String) { + self.type = type + self.header = header + self.secret = secret + } +} + +public struct ProviderPluginManifest: @unchecked Sendable { + public let id: UsageProvider + public let name: String + public let endpoints: Set + public let auth: ProviderPluginAuth + public let settings: [ProviderPluginSetting] + + let fetchUsage: JSValue + + init(definition: JSValue) throws { + guard definition.isObject else { + throw ProviderPluginError.invalidManifest("defineProvider(...) requires an object") + } + + let rawID = try Self.requiredString(definition, property: "id") + guard let id = UsageProvider(rawValue: rawID) else { + throw ProviderPluginError.invalidManifest( + "provider id '\(rawID)' must match an existing UsageProvider raw value") + } + self.id = id + self.name = try Self.boundedString(definition, property: "name", maximumLength: 80) + + let endpointValue = definition.forProperty("endpoints") + guard let endpointValue, endpointValue.isArray else { + throw ProviderPluginError.invalidManifest("'endpoints' must be a non-empty array of HTTPS origins") + } + let endpointCount = Int(endpointValue.forProperty("length")?.toInt32() ?? 0) + guard endpointCount > 0 else { + throw ProviderPluginError.invalidManifest("'endpoints' must not be empty") + } + var endpoints: Set = [] + for index in 0.. = [] + for index in 0.. String { + guard let value = object.forProperty(property), value.isString else { + throw ProviderPluginError.invalidManifest("'\(property)' must be a string") + } + let string = value.toString().trimmingCharacters(in: .whitespacesAndNewlines) + guard !string.isEmpty else { + throw ProviderPluginError.invalidManifest("'\(property)' must not be empty") + } + return string + } + + private static func optionalString(_ object: JSValue, property: String) throws -> String? { + guard let value = object.forProperty(property), !value.isUndefined, !value.isNull else { return nil } + guard value.isString else { + throw ProviderPluginError.invalidManifest("'\(property)' must be a string when present") + } + return value.toString() + } + + private static func boundedString(_ object: JSValue, property: String, maximumLength: Int) throws -> String { + let value = try Self.requiredString(object, property: property) + guard value.utf8.count <= maximumLength else { + throw ProviderPluginError.invalidManifest("'\(property)' exceeds \(maximumLength) UTF-8 bytes") + } + return value + } + + private static func optionalBoundedString( + _ object: JSValue, + property: String, + maximumLength: Int) throws -> String? + { + guard let raw = try optionalString(object, property: property) else { return nil } + let value = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard value.utf8.count <= maximumLength else { + throw ProviderPluginError.invalidManifest("'\(property)' exceeds \(maximumLength) UTF-8 bytes") + } + return value.isEmpty ? nil : value + } + + private static func isValidHeaderName(_ value: String) -> Bool { + !value.isEmpty && value.unicodeScalars.allSatisfy { scalar in + scalar.isASCII && (scalar.properties.isAlphabetic || scalar.properties.numericType != nil + || "!#$%&'*+-.^_`|~".unicodeScalars.contains(scalar)) + } + } +} + +enum ProviderPluginOrigin { + static func normalizedOrigin(_ rawValue: String) throws -> String { + guard let components = URLComponents(string: rawValue), + components.scheme?.lowercased() == "https", + let host = components.host?.lowercased(), + !host.isEmpty, + components.user == nil, + components.password == nil, + components.query == nil, + components.fragment == nil, + components.path.isEmpty || components.path == "/" + else { + throw ProviderPluginError.invalidManifest("endpoint '\(rawValue)' must be an HTTPS origin") + } + let port = components.port + return "https://\(host)\(port == nil || port == 443 ? "" : ":\(port!)")" + } + + static func normalizedOrigin(of url: URL) throws -> String { + guard url.scheme?.lowercased() == "https", url.user == nil, url.password == nil else { + throw ProviderPluginError.networkPolicy("only HTTPS URLs without user info are allowed") + } + guard let host = url.host?.lowercased(), !host.isEmpty else { + throw ProviderPluginError.networkPolicy("request URL has no host") + } + let port = url.port + return "https://\(host)\(port == nil || port == 443 ? "" : ":\(port!)")" + } +} + +public enum ProviderPluginError: LocalizedError, Sendable, Equatable { + case load(String) + case invalidManifest(String) + case networkPolicy(String) + case http(String) + case secretAccess(String) + case invalidSnapshot(String) + case script(String) + case timedOut + + public var errorDescription: String? { + switch self { + case let .load(message): "Provider plugin load failed: \(message)" + case let .invalidManifest(message): "Invalid provider plugin manifest: \(message)" + case let .networkPolicy(message): "Provider plugin network policy rejected the request: \(message)" + case let .http(message): "Provider plugin HTTP error: \(message)" + case let .secretAccess(message): "Provider plugin secret access denied: \(message)" + case let .invalidSnapshot(message): "Invalid provider plugin snapshot: \(message)" + case let .script(message): "Provider plugin script failed: \(message)" + case .timedOut: "Provider plugin timed out" + } + } +} +#endif diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift b/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift new file mode 100644 index 0000000000..0dc3c0adb0 --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginRuntime.swift @@ -0,0 +1,536 @@ +#if canImport(JavaScriptCore) +import Foundation +#if canImport(FoundationNetworking) +import FoundationNetworking +#endif +@preconcurrency import JavaScriptCore + +public final class ProviderPluginRuntime: @unchecked Sendable { + public static let defaultTimeout: TimeInterval = 20 + public static let maximumResponseBytes = 5 * 1024 * 1024 + + public let manifest: ProviderPluginManifest + + private let source: String + private let preludeSource: String + private let transport: any ProviderHTTPTransport + private let timeout: TimeInterval + private let responseSizeLimit: Int + private let lock = NSLock() + private var worker: ProviderPluginWorker? + + public convenience init( + bundledPlugin name: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + timeout: TimeInterval = ProviderPluginRuntime.defaultTimeout) throws + { + guard let url = Bundle.module.url(forResource: name, withExtension: "js") else { + throw ProviderPluginError.load("bundled plugin '\(name).js' was not found") + } + let source = try String(contentsOf: url, encoding: .utf8) + try self.init(source: source, transport: transport, timeout: timeout) + } + + public init( + source: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + timeout: TimeInterval = ProviderPluginRuntime.defaultTimeout, + responseSizeLimit: Int = ProviderPluginRuntime.maximumResponseBytes) throws + { + guard timeout > 0 else { throw ProviderPluginError.load("timeout must be positive") } + guard responseSizeLimit > 0 else { throw ProviderPluginError.load("response size limit must be positive") } + guard let preludeURL = Bundle.module.url( + forResource: "provider-plugin-prelude", + withExtension: "js") + else { + throw ProviderPluginError.load("provider plugin prelude was not found") + } + + self.source = source + self.preludeSource = try String(contentsOf: preludeURL, encoding: .utf8) + self.transport = transport + self.timeout = timeout + self.responseSizeLimit = responseSizeLimit + + let worker = try ProviderPluginWorker.make( + source: source, + preludeSource: self.preludeSource, + transport: transport, + responseSizeLimit: responseSizeLimit) + self.worker = worker + self.manifest = worker.manifest + } + + public func fetchUsage(secrets: [String: String], now: Date = Date()) async throws -> UsageSnapshot { + let sanitizedSecrets = secrets.mapValues { + $0.trimmingCharacters(in: .whitespacesAndNewlines) + } + guard let authSecret = sanitizedSecrets[self.manifest.auth.secret], !authSecret.isEmpty else { + throw ProviderPluginError.secretAccess("required secret '\(self.manifest.auth.secret)' is unavailable") + } + + let worker = try self.currentWorker() + let gate = ProviderPluginCompletionGate() + return try await withCheckedThrowingContinuation { continuation in + gate.install(continuation) + worker.fetch(secrets: sanitizedSecrets, now: now) { result in + gate.finish(result.mapError { self.redactedError($0, secrets: sanitizedSecrets.values) }) + } + Task.detached { [weak self, weak worker] in + guard let self, let worker else { return } + let nanoseconds = UInt64(self.timeout * 1_000_000_000) + try? await Task.sleep(nanoseconds: nanoseconds) + if gate.finish(.failure(ProviderPluginError.timedOut)) { + self.discard(worker) + } + } + } + } + + public func globalType(of name: String) throws -> String { + try self.currentWorker().globalType(of: name) + } + + private func currentWorker() throws -> ProviderPluginWorker { + self.lock.lock() + defer { self.lock.unlock() } + if let worker = self.worker { + return worker + } + let worker = try ProviderPluginWorker.make( + source: self.source, + preludeSource: self.preludeSource, + transport: self.transport, + responseSizeLimit: self.responseSizeLimit) + guard worker.manifest.id == self.manifest.id else { + throw ProviderPluginError.load("reloaded plugin changed provider id") + } + self.worker = worker + return worker + } + + private func discard(_ worker: ProviderPluginWorker) { + self.lock.lock() + if self.worker === worker { + self.worker = nil + } + self.lock.unlock() + } + + private func redactedError(_ error: Error, secrets: Dictionary.Values) -> Error { + var message = error.localizedDescription + for secret in secrets where !secret.isEmpty { + message = message.replacingOccurrences(of: secret, with: "") + } + if let pluginError = error as? ProviderPluginError { + switch pluginError { + case .timedOut: return pluginError + case .load: return ProviderPluginError.load(message.removingPluginErrorPrefix) + case .invalidManifest: return ProviderPluginError.invalidManifest(message.removingPluginErrorPrefix) + case .networkPolicy: return ProviderPluginError.networkPolicy(message.removingPluginErrorPrefix) + case .http: return ProviderPluginError.http(message.removingPluginErrorPrefix) + case .secretAccess: return ProviderPluginError.secretAccess(message.removingPluginErrorPrefix) + case .invalidSnapshot: return ProviderPluginError.invalidSnapshot(message.removingPluginErrorPrefix) + case .script: return ProviderPluginError.script(message.removingPluginErrorPrefix) + } + } + return ProviderPluginError.script(message) + } +} + +extension String { + fileprivate var removingPluginErrorPrefix: String { + guard let separator = self.firstIndex(of: ":") else { return self } + return String(self[self.index(after: separator)...]).trimmingCharacters(in: .whitespaces) + } +} + +private final class ProviderPluginCompletionGate: @unchecked Sendable { + private let lock = NSLock() + private var continuation: CheckedContinuation? + private var pendingResult: Result? + private var finished = false + + func install(_ continuation: CheckedContinuation) { + self.lock.lock() + if let result = self.pendingResult { + self.pendingResult = nil + self.lock.unlock() + continuation.resume(with: result) + return + } + self.continuation = continuation + self.lock.unlock() + } + + @discardableResult + func finish(_ result: Result) -> Bool { + self.lock.lock() + guard !self.finished else { + self.lock.unlock() + return false + } + self.finished = true + guard let continuation = self.continuation else { + self.pendingResult = result + self.lock.unlock() + return true + } + self.continuation = nil + self.lock.unlock() + continuation.resume(with: result) + return true + } +} + +private final class ProviderPluginJSValueBox: @unchecked Sendable { + let value: JSValue + + init(_ value: JSValue) { + self.value = value + } +} + +private final class ProviderPluginObjectBox: @unchecked Sendable { + let value: [String: Any] + + init(_ value: [String: Any]) { + self.value = value + } +} + +private struct ProviderPluginHTTPRequestCallbacks: @unchecked Sendable { + let wantsJSON: Bool + let resolve: ProviderPluginJSValueBox + let reject: ProviderPluginJSValueBox +} + +private final class ProviderPluginWorker: @unchecked Sendable { + private typealias HTTPBlock = @convention(block) (String, JSValue, Bool, JSValue, JSValue) -> Void + + let manifest: ProviderPluginManifest + + private let queue: DispatchQueue + private let context: JSContext + private let applyPrelude: JSValue + private let transport: any ProviderHTTPTransport + private let responseSizeLimit: Int + private var cache: [String: (value: JSValue, expiresAt: Date)] = [:] + private var retainedCallbacks: [UUID: [Any]] = [:] + + static func make( + source: String, + preludeSource: String, + transport: any ProviderHTTPTransport, + responseSizeLimit: Int) throws -> ProviderPluginWorker + { + let queue = DispatchQueue(label: "com.steipete.codexbar.provider-plugin.\(UUID().uuidString)") + return try queue.sync { + try ProviderPluginWorker( + queue: queue, + source: source, + preludeSource: preludeSource, + transport: transport, + responseSizeLimit: responseSizeLimit) + } + } + + private init( + queue: DispatchQueue, + source: String, + preludeSource: String, + transport: any ProviderHTTPTransport, + responseSizeLimit: Int) throws + { + guard let context = JSContext() else { + throw ProviderPluginError.load("JavaScriptCore could not create a context") + } + self.queue = queue + self.context = context + self.transport = transport + self.responseSizeLimit = responseSizeLimit + + var definition: JSValue? + let defineProvider: @convention(block) (JSValue) -> Void = { value in + definition = value + } + context.setObject(defineProvider, forKeyedSubscript: "defineProvider" as NSString) + + context.exception = nil + guard let applyPrelude = context.evaluateScript(preludeSource), context.exception == nil else { + throw ProviderPluginError.load(Self.exceptionMessage(context) ?? "prelude evaluation failed") + } + self.applyPrelude = applyPrelude + + context.exception = nil + _ = context.evaluateScript(source) + if let message = Self.exceptionMessage(context) { + throw ProviderPluginError.load(message) + } + guard let definition else { + throw ProviderPluginError.invalidManifest("plugin did not call defineProvider(...)") + } + self.manifest = try ProviderPluginManifest(definition: definition) + } + + func globalType(of name: String) throws -> String { + try self.queue.sync { + self.context.exception = nil + let escaped = name.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "'", with: "\\'") + let result = self.context.evaluateScript("typeof globalThis['\(escaped)']") + if let message = Self.exceptionMessage(self.context) { + throw ProviderPluginError.script(message) + } + return result?.toString() ?? "undefined" + } + } + + func fetch( + secrets: [String: String], + now: Date, + completion: @escaping @Sendable (Result) -> Void) + { + self.queue.async { + self.beginFetch(secrets: secrets, now: now, completion: completion) + } + } + + private func beginFetch( + secrets: [String: String], + now: Date, + completion: @escaping @Sendable (Result) -> Void) + { + self.context.exception = nil + let ctx = self.makeContext(secrets: secrets) + guard self.context.exception == nil else { + completion(.failure(ProviderPluginError.script(Self.exceptionMessage(self.context) ?? "ctx setup failed"))) + return + } + + let callbackID = UUID() + let resolve: @convention(block) (JSValue) -> Void = { [weak self] value in + guard let self else { return } + defer { self.retainedCallbacks[callbackID] = nil } + do { + let snapshot = try ProviderPluginSnapshotMapper.map(value, provider: self.manifest.id, now: now) + completion(.success(snapshot)) + } catch { + completion(.failure(error)) + } + } + let reject: @convention(block) (JSValue) -> Void = { [weak self] value in + guard let self else { return } + defer { self.retainedCallbacks[callbackID] = nil } + completion(.failure(ProviderPluginError.script(self.message(from: value)))) + } + self.retainedCallbacks[callbackID] = [resolve, reject] + + guard let result = self.manifest.fetchUsage.call(withArguments: [ctx]) else { + self.retainedCallbacks[callbackID] = nil + completion(.failure(ProviderPluginError + .script(Self.exceptionMessage(self.context) ?? "fetchUsage returned no value"))) + return + } + if let message = Self.exceptionMessage(self.context) { + self.retainedCallbacks[callbackID] = nil + completion(.failure(ProviderPluginError.script(message))) + return + } + + guard let then = result.forProperty("then"), then.isObject else { + resolve(result) + return + } + _ = result.invokeMethod("then", withArguments: [resolve, reject]) + if let message = Self.exceptionMessage(self.context) { + self.retainedCallbacks[callbackID] = nil + completion(.failure(ProviderPluginError.script(message))) + } + } + + private func makeContext(secrets: [String: String]) -> JSValue { + let ctx = JSValue(newObjectIn: self.context)! + let host = JSValue(newObjectIn: self.context)! + + let secretGet: @convention(block) (String) -> JSValue = { [weak self] key in + guard let self else { return JSValue(undefinedIn: nil) } + guard self.manifest.settings.contains(where: { $0.key == key }) else { + self.context.exception = JSValue( + newErrorFromMessage: "secret key '\(key)' is not declared in settings", + in: self.context) + return JSValue(undefinedIn: self.context) + } + guard let secret = secrets[key], !secret.isEmpty else { + return JSValue(nullIn: self.context) + } + return JSValue(object: secret, in: self.context) + } + host.setObject(secretGet, forKeyedSubscript: "secretGet" as NSString) + + let http = self.makeHTTPBlock(secrets: secrets) + host.setObject(http, forKeyedSubscript: "http" as NSString) + + let cacheGet: @convention(block) (String) -> JSValue = { [weak self] key in + guard let self else { return JSValue(undefinedIn: nil) } + guard let entry = self.cache[key], entry.expiresAt > Date() else { + self.cache[key] = nil + return JSValue(undefinedIn: self.context) + } + return entry.value + } + let cacheSet: @convention(block) (String, JSValue, Double) -> Void = { [weak self] key, value, ttl in + guard let self, ttl.isFinite, ttl > 0 else { return } + self.cache[key] = (value, Date().addingTimeInterval(min(ttl, 86400))) + } + host.setObject(cacheGet, forKeyedSubscript: "cacheGet" as NSString) + host.setObject(cacheSet, forKeyedSubscript: "cacheSet" as NSString) + + let log: @convention(block) (String) -> Void = { [manifest] message in + let logger = CodexBarLog.logger(LogCategories.provider(manifest.id, scope: "plugin")) + logger.debug("\(message)") + } + host.setObject(log, forKeyedSubscript: "log" as NSString) + + _ = self.applyPrelude.call(withArguments: [ctx, host]) + return ctx + } + + private func makeHTTPBlock(secrets: [String: String]) -> HTTPBlock { + { [weak self] rawURL, options, wantsJSON, resolve, reject in + self?.startHTTPRequest( + rawURL: rawURL, + options: options, + secrets: secrets, + callbacks: ProviderPluginHTTPRequestCallbacks( + wantsJSON: wantsJSON, + resolve: ProviderPluginJSValueBox(resolve), + reject: ProviderPluginJSValueBox(reject))) + } + } + + private func startHTTPRequest( + rawURL: String, + options: JSValue, + secrets: [String: String], + callbacks: ProviderPluginHTTPRequestCallbacks) + { + let request: URLRequest + do { + request = try self.makeRequest(rawURL: rawURL, options: options, secrets: secrets) + } catch { + self.reject(callbacks.reject, error: error) + return + } + + let worker = self + let transport = self.transport + let responseSizeLimit = self.responseSizeLimit + Task.detached { + do { + let response = try await transport.response(for: request) + guard response.data.count <= responseSizeLimit else { + throw ProviderPluginError.http("response exceeded the 5 MiB limit") + } + let payload = try ProviderPluginObjectBox(Self.responsePayload( + response, + wantsJSON: callbacks.wantsJSON)) + worker.queue.async { + let value = JSValue(object: payload.value, in: worker.context) ?? JSValue(nullIn: worker.context) + _ = callbacks.resolve.value.call(withArguments: [value as Any]) + } + } catch { + let failure = ProviderPluginError.http(error.localizedDescription) + worker.queue.async { + worker.reject(callbacks.reject, error: failure) + } + } + } + } + + private func makeRequest(rawURL: String, options: JSValue, secrets: [String: String]) throws -> URLRequest { + guard let url = URL(string: rawURL) else { + throw ProviderPluginError.networkPolicy("request URL is invalid") + } + let origin = try ProviderPluginOrigin.normalizedOrigin(of: url) + guard self.manifest.endpoints.contains(origin) else { + throw ProviderPluginError.networkPolicy("origin '\(origin)' is not declared") + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.timeoutInterval = 15 + request.setValue("application/json", forHTTPHeaderField: "Accept") + if options.isObject, + let headers = options.forProperty("headers"), + headers.isObject, + let dictionary = headers.toDictionary() as? [String: Any] + { + for (name, rawValue) in dictionary { + guard let value = rawValue as? String else { + throw ProviderPluginError.http("request header '\(name)' must be a string") + } + if name.caseInsensitiveCompare(self.manifest.auth.header) == .orderedSame { + throw ProviderPluginError.networkPolicy("plugins may not override the auth header") + } + request.setValue(value, forHTTPHeaderField: name) + } + } + + guard let secret = secrets[self.manifest.auth.secret], !secret.isEmpty else { + throw ProviderPluginError.secretAccess("required auth secret is unavailable") + } + let authValue = self.manifest.auth.type == .bearer ? "Bearer \(secret)" : secret + request.setValue(authValue, forHTTPHeaderField: self.manifest.auth.header) + return request + } + + private static func responsePayload(_ response: ProviderHTTPResponse, wantsJSON: Bool) throws -> [String: Any] { + var headers: [String: String] = [:] + for (key, value) in response.response.allHeaderFields { + headers[String(describing: key).lowercased()] = String(describing: value) + } + var payload: [String: Any] = [ + "status": response.statusCode, + "headers": headers, + ] + if wantsJSON { + do { + payload["json"] = try JSONSerialization.jsonObject(with: response.data) + } catch { + throw ProviderPluginError.http("response was not valid JSON") + } + } else { + guard let text = String(data: response.data, encoding: .utf8) else { + throw ProviderPluginError.http("response body was not valid UTF-8") + } + payload["bodyText"] = text + } + return payload + } + + private func reject(_ reject: ProviderPluginJSValueBox, error: Error) { + let value = JSValue(newErrorFromMessage: error.localizedDescription, in: self.context) + _ = reject.value.call(withArguments: [value as Any]) + } + + private func message(from value: JSValue) -> String { + if value.isObject, + let message = value.forProperty("message"), + message.isString + { + return message.toString() + } + return value.toString() + } + + private static func exceptionMessage(_ context: JSContext) -> String? { + defer { context.exception = nil } + guard let exception = context.exception else { return nil } + if let message = exception.forProperty("message"), message.isString { + return message.toString() + } + return exception.toString() + } +} +#endif diff --git a/Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift b/Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift new file mode 100644 index 0000000000..b4317c56a0 --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ProviderPluginSnapshotMapper.swift @@ -0,0 +1,203 @@ +#if canImport(JavaScriptCore) +import Foundation +@preconcurrency import JavaScriptCore + +enum ProviderPluginSnapshotMapper { + private static let maximumStringBytes = 256 + + static func map(_ value: JSValue, provider: UsageProvider, now: Date = Date()) throws -> UsageSnapshot { + guard value.isObject, !value.isArray, !value.isNull else { + throw ProviderPluginError.invalidSnapshot("fetchUsage must resolve to an object") + } + + let primary = try self.window(value, property: "primary") + let secondary = try self.window(value, property: "secondary") + let tertiary = try self.window(value, property: "tertiary") + let extraRateWindows = try self.extraWindows(value) + let providerCost = try self.cost(value, now: now) + let identity = try self.identity(value, provider: provider) + let subscriptionRenewsAt = try self.optionalDate(value, property: "subscriptionRenewsAt") + let subscriptionExpiresAt = try self.optionalDate(value, property: "subscriptionExpiresAt") + + guard primary != nil || secondary != nil || tertiary != nil || !(extraRateWindows?.isEmpty ?? true) + || providerCost != nil + else { + throw ProviderPluginError.invalidSnapshot("snapshot must contain at least one rate window or cost") + } + + return UsageSnapshot( + primary: primary, + secondary: secondary, + tertiary: tertiary, + extraRateWindows: extraRateWindows, + providerCost: providerCost, + subscriptionExpiresAt: subscriptionExpiresAt, + subscriptionRenewsAt: subscriptionRenewsAt, + updatedAt: now, + identity: identity) + } + + private static func window(_ root: JSValue, property: String) throws -> RateWindow? { + guard let value = root.forProperty(property), !value.isUndefined, !value.isNull else { return nil } + return try self.window(value, path: property) + } + + private static func window(_ value: JSValue, path: String) throws -> RateWindow { + guard value.isObject, !value.isArray else { + throw ProviderPluginError.invalidSnapshot("\(path) must be an object") + } + let rawPercent = try self.requiredFiniteNumber(value, property: "usedPercent", path: path) + let usedPercent = min(100, max(0, rawPercent)) + let windowMinutes = try self.optionalPositiveInteger(value, property: "windowMinutes", path: path) + let resetsAt = try self.optionalDate(value, property: "resetsAt", path: path) + let resetDescription = try self.optionalString(value, property: "resetDescription", path: path) + let nextRegenPercent = try self.optionalFiniteNumber(value, property: "nextRegenPercent", path: path) + return RateWindow( + usedPercent: usedPercent, + windowMinutes: windowMinutes, + resetsAt: resetsAt, + resetDescription: resetDescription, + nextRegenPercent: nextRegenPercent.map { min(100, max(0, $0)) }) + } + + private static func extraWindows(_ root: JSValue) throws -> [NamedRateWindow]? { + guard let value = root.forProperty("extraWindows"), !value.isUndefined, !value.isNull else { return nil } + guard value.isArray else { + throw ProviderPluginError.invalidSnapshot("extraWindows must be an array") + } + let count = Int(value.forProperty("length")?.toInt32() ?? 0) + guard count <= 64 else { + throw ProviderPluginError.invalidSnapshot("extraWindows exceeds 64 entries") + } + return try (0.. ProviderCostSnapshot? { + guard let value = root.forProperty("cost"), !value.isUndefined, !value.isNull else { return nil } + guard value.isObject, !value.isArray else { + throw ProviderPluginError.invalidSnapshot("cost must be an object") + } + let used = try self.requiredFiniteNumber(value, property: "used", path: "cost") + let limit = try self.optionalFiniteNumber(value, property: "limit", path: "cost") ?? 0 + let currency = try self.requiredString(value, property: "currency", path: "cost") + guard currency.range(of: "^[A-Z]{3}$", options: .regularExpression) != nil else { + throw ProviderPluginError.invalidSnapshot("cost.currency must be a three-letter uppercase currency literal") + } + let period = try self.optionalString(value, property: "period", path: "cost") + let resetsAt = try self.optionalDate(value, property: "resetsAt", path: "cost") + let nextRegenAmount = try self.optionalFiniteNumber(value, property: "nextRegenAmount", path: "cost") + let balance = try self.optionalFiniteNumber(value, property: "balance", path: "cost") + return ProviderCostSnapshot( + used: used, + limit: limit, + currencyCode: currency, + period: period, + resetsAt: resetsAt, + nextRegenAmount: nextRegenAmount, + balance: balance, + updatedAt: now) + } + + private static func identity(_ root: JSValue, provider: UsageProvider) throws -> ProviderIdentitySnapshot? { + guard let value = root.forProperty("identity"), !value.isUndefined, !value.isNull else { return nil } + guard value.isObject, !value.isArray else { + throw ProviderPluginError.invalidSnapshot("identity must be an object") + } + return try ProviderIdentitySnapshot( + providerID: provider, + accountEmail: self.optionalString(value, property: "email", path: "identity"), + accountOrganization: self.optionalString(value, property: "organization", path: "identity"), + loginMethod: self.optionalString(value, property: "loginMethod", path: "identity"), + accountID: self.optionalString(value, property: "accountID", path: "identity")) + } + + private static func requiredFiniteNumber(_ value: JSValue, property: String, path: String) throws -> Double { + guard let result = try self.optionalFiniteNumber(value, property: property, path: path) else { + throw ProviderPluginError.invalidSnapshot("\(path).\(property) is required") + } + return result + } + + private static func optionalFiniteNumber(_ value: JSValue, property: String, path: String) throws -> Double? { + guard let propertyValue = value.forProperty(property), + !propertyValue.isUndefined, + !propertyValue.isNull + else { return nil } + guard propertyValue.isNumber else { + throw ProviderPluginError.invalidSnapshot("\(path).\(property) must be a number") + } + let number = propertyValue.toDouble() + guard number.isFinite else { + throw ProviderPluginError.invalidSnapshot("\(path).\(property) must be finite") + } + return number + } + + private static func optionalPositiveInteger(_ value: JSValue, property: String, path: String) throws -> Int? { + guard let number = try self.optionalFiniteNumber(value, property: property, path: path) else { return nil } + guard number.rounded() == number, number > 0, number <= Double(Int.max) else { + throw ProviderPluginError.invalidSnapshot("\(path).\(property) must be a positive integer") + } + return Int(number) + } + + private static func requiredString(_ value: JSValue, property: String, path: String) throws -> String { + guard let string = try self.optionalString(value, property: property, path: path) else { + throw ProviderPluginError.invalidSnapshot("\(path).\(property) is required") + } + return string + } + + private static func optionalString(_ value: JSValue, property: String, path: String) throws -> String? { + guard let propertyValue = value.forProperty(property), + !propertyValue.isUndefined, + !propertyValue.isNull + else { return nil } + guard propertyValue.isString else { + throw ProviderPluginError.invalidSnapshot("\(path).\(property) must be a string") + } + let string = propertyValue.toString().trimmingCharacters(in: .whitespacesAndNewlines) + guard string.utf8.count <= self.maximumStringBytes else { + throw ProviderPluginError.invalidSnapshot( + "\(path).\(property) exceeds \(self.maximumStringBytes) UTF-8 bytes") + } + return string.isEmpty ? nil : string + } + + private static func optionalDate(_ value: JSValue, property: String, path: String = "snapshot") throws -> Date? { + guard let propertyValue = value.forProperty(property), + !propertyValue.isUndefined, + !propertyValue.isNull + else { return nil } + if propertyValue.isDate, let date = propertyValue.toDate() { + return date + } + guard propertyValue.isString else { + throw ProviderPluginError.invalidSnapshot("\(path).\(property) must be a Date or ISO-8601 string") + } + guard let text = propertyValue.toString() else { + throw ProviderPluginError.invalidSnapshot("\(path).\(property) must be an ISO-8601 string") + } + let fractional = ISO8601DateFormatter() + fractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + guard let date = fractional.date(from: text) ?? plain.date(from: text) else { + throw ProviderPluginError.invalidSnapshot("\(path).\(property) is not a valid ISO-8601 date") + } + return date + } +} +#endif diff --git a/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift b/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift new file mode 100644 index 0000000000..a48f705f65 --- /dev/null +++ b/Sources/CodexBarCore/Plugins/ScriptFetchStrategy.swift @@ -0,0 +1,145 @@ +#if canImport(JavaScriptCore) +import Foundation + +public enum ProviderPluginPrototype { + public static let environmentKey = "CODEXBAR_JS_PROVIDERS" + + public static func isEnabled(environment: [String: String] = ProcessInfo.processInfo.environment) -> Bool { + environment[self.environmentKey] == "1" + } +} + +public final class ScriptFetchStrategy: ProviderFetchStrategy, @unchecked Sendable { + public typealias SecretResolver = @Sendable ([String: String]) -> String? + public typealias EnabledResolver = @Sendable ([String: String]) -> Bool + + public let id: String + public let kind: ProviderFetchKind = .apiToken + + private let provider: UsageProvider + private let bundledPlugin: String + private let secretKey: String + private let resolveSecret: SecretResolver + private let isEnabled: EnabledResolver + private let transport: any ProviderHTTPTransport + private let timeout: TimeInterval + private let lock = NSLock() + private var runtime: ProviderPluginRuntime? + + public init( + id: String, + provider: UsageProvider, + bundledPlugin: String, + secretKey: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared, + timeout: TimeInterval = ProviderPluginRuntime.defaultTimeout, + resolveSecret: @escaping SecretResolver, + isEnabled: @escaping EnabledResolver = { ProviderPluginPrototype.isEnabled(environment: $0) }) + { + self.id = id + self.provider = provider + self.bundledPlugin = bundledPlugin + self.secretKey = secretKey + self.transport = transport + self.timeout = timeout + self.resolveSecret = resolveSecret + self.isEnabled = isEnabled + } + + public func isAvailable(_ context: ProviderFetchContext) async -> Bool { + self.isEnabled(context.env) && self.resolveSecret(context.env) != nil + } + + public func fetch(_ context: ProviderFetchContext) async throws -> ProviderFetchResult { + guard self.isEnabled(context.env) else { + throw ProviderPluginError.load("JavaScript provider prototype is disabled") + } + guard let secret = self.resolveSecret(context.env) else { + throw ProviderPluginError.secretAccess("required provider secret is unavailable") + } + let runtime = try self.loadedRuntime() + guard runtime.manifest.id == self.provider else { + throw ProviderPluginError.invalidManifest( + "bundled plugin id '\(runtime.manifest.id.rawValue)' does not match '\(self.provider.rawValue)'") + } + let usage = try await runtime.fetchUsage(secrets: [self.secretKey: secret]) + return self.makeResult(usage: usage, sourceLabel: "js") + } + + public func shouldFallback(on _: Error, context _: ProviderFetchContext) -> Bool { + false + } + + private func loadedRuntime() throws -> ProviderPluginRuntime { + self.lock.lock() + defer { self.lock.unlock() } + if let runtime = self.runtime { + return runtime + } + let runtime = try ProviderPluginRuntime( + bundledPlugin: self.bundledPlugin, + transport: self.transport, + timeout: self.timeout) + self.runtime = runtime + return runtime + } +} + +extension ProviderFetchPlan { + struct ScriptPrototypeAPIConfiguration: Sendable { + let provider: UsageProvider + let plugin: String + let secretKey: String + let strategyID: String + let sourceLabel: String + let reportsMissingCredentials: Bool + + init( + provider: UsageProvider, + plugin: String, + secretKey: String, + strategyID: String, + sourceLabel: String = "api", + reportsMissingCredentials: Bool = false) + { + self.provider = provider + self.plugin = plugin + self.secretKey = secretKey + self.strategyID = strategyID + self.sourceLabel = sourceLabel + self.reportsMissingCredentials = reportsMissingCredentials + } + } + + static func scriptPrototypeAPI( + configuration: ScriptPrototypeAPIConfiguration, + resolveToken: @escaping APITokenFetchStrategy.TokenResolver, + missingCredentialsError: @escaping APITokenFetchStrategy.MissingCredentialsError, + loadUsage: @escaping APITokenFetchStrategy.UsageLoader) -> ProviderFetchPlan + { + ProviderFetchPlan( + sourceModes: [.auto, .api], + pipeline: ProviderFetchPipeline(resolveStrategies: { context in + let swift = APITokenFetchStrategy( + id: configuration.strategyID, + sourceLabel: configuration.sourceLabel, + reportsMissingCredentials: configuration.reportsMissingCredentials, + resolveToken: resolveToken, + missingCredentialsError: missingCredentialsError, + loadUsage: loadUsage) + guard ProviderPluginPrototype.isEnabled(environment: context.env) else { + return [swift] + } + return [ + ScriptFetchStrategy( + id: "\(configuration.provider.rawValue).js", + provider: configuration.provider, + bundledPlugin: configuration.plugin, + secretKey: configuration.secretKey, + resolveSecret: resolveToken), + swift, + ] + })) + } +} +#endif diff --git a/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift index 5ca3be1de0..248cabf818 100644 --- a/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Crof/CrofProviderDescriptor.swift @@ -37,19 +37,37 @@ public enum CrofProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Crof cost summary is not available via API." }), - fetchPlan: .apiToken( - strategyID: "crof.api", - resolveToken: { ProviderTokenResolver.crofToken(environment: $0) }, - missingCredentialsError: { CrofUsageError.missingCredentials }, - loadUsage: { apiKey, _ in - try await CrofUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() - }), + fetchPlan: self.fetchPlan(), cli: ProviderCLIConfig( name: "crof", aliases: ["crofai"], versionDetector: nil)) } + private static func fetchPlan() -> ProviderFetchPlan { + #if canImport(JavaScriptCore) + .scriptPrototypeAPI( + configuration: .init( + provider: .crof, + plugin: "crof", + secretKey: CrofSettingsReader.apiKeyEnvironmentKeys[0], + strategyID: "crof.api"), + resolveToken: { ProviderTokenResolver.crofToken(environment: $0) }, + missingCredentialsError: { CrofUsageError.missingCredentials }, + loadUsage: { apiKey, _ in + try await CrofUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }) + #else + .apiToken( + strategyID: "crof.api", + resolveToken: { ProviderTokenResolver.crofToken(environment: $0) }, + missingCredentialsError: { CrofUsageError.missingCredentials }, + loadUsage: { apiKey, _ in + try await CrofUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }) + #endif + } + public static func primaryLabel(snapshot: UsageSnapshot) -> String { snapshot.secondary == nil ? "Credits" : "Requests" } diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift index e54bb978ae..37cf149082 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticProviderDescriptor.swift @@ -35,16 +35,34 @@ public enum SyntheticProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Synthetic cost summary is not supported." }), - fetchPlan: .apiToken( - strategyID: "synthetic.api", - resolveToken: { ProviderTokenResolver.syntheticToken(environment: $0) }, - missingCredentialsError: { SyntheticSettingsError.missingToken }, - loadUsage: { apiKey, _ in - try await SyntheticUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() - }), + fetchPlan: self.fetchPlan(), cli: ProviderCLIConfig( name: "synthetic", aliases: ["synthetic.new"], versionDetector: nil)) } + + private static func fetchPlan() -> ProviderFetchPlan { + #if canImport(JavaScriptCore) + .scriptPrototypeAPI( + configuration: .init( + provider: .synthetic, + plugin: "synthetic", + secretKey: SyntheticSettingsReader.apiKeyKey, + strategyID: "synthetic.api"), + resolveToken: { ProviderTokenResolver.syntheticToken(environment: $0) }, + missingCredentialsError: { SyntheticSettingsError.missingToken }, + loadUsage: { apiKey, _ in + try await SyntheticUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }) + #else + .apiToken( + strategyID: "synthetic.api", + resolveToken: { ProviderTokenResolver.syntheticToken(environment: $0) }, + missingCredentialsError: { SyntheticSettingsError.missingToken }, + loadUsage: { apiKey, _ in + try await SyntheticUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }) + #endif + } } diff --git a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift index a1ff84f954..70c4ee900d 100644 --- a/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift +++ b/Sources/CodexBarCore/Providers/Synthetic/SyntheticUsageStats.swift @@ -94,7 +94,11 @@ public struct SyntheticUsageFetcher: Sendable { private static let log = CodexBarLog.logger(LogCategories.syntheticUsage) private static let quotaAPIURL = "https://api.synthetic.new/v2/quotas" - public static func fetchUsage(apiKey: String, now: Date = Date()) async throws -> SyntheticUsageSnapshot { + public static func fetchUsage( + apiKey: String, + now: Date = Date(), + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> SyntheticUsageSnapshot + { guard !apiKey.isEmpty else { throw SyntheticUsageError.invalidCredentials } @@ -104,7 +108,7 @@ public struct SyntheticUsageFetcher: Sendable { request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization") request.setValue("application/json", forHTTPHeaderField: "Accept") - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await transport.response(for: request) let data = response.data guard response.statusCode == 200 else { let errorMessage = String(data: data, encoding: .utf8) ?? "Unknown error" @@ -156,8 +160,12 @@ enum SyntheticUsageParser { let object = try JSONSerialization.jsonObject(with: data, options: []) let root: [String: Any] = { - if let dict = object as? [String: Any] { return dict } - if let array = object as? [Any] { return ["quotas": array] } + if let dict = object as? [String: Any] { + return dict + } + if let array = object as? [Any] { + return ["quotas": array] + } return [:] }() @@ -221,13 +229,17 @@ enum SyntheticUsageParser { for candidate in candidates { let quotas = self.extractQuotaObjects(from: candidate) - if !quotas.isEmpty { return quotas } + if !quotas.isEmpty { + return quotas + } } return [] } private static func planName(from root: [String: Any]) -> String? { - if let direct = self.firstString(in: root, keys: planKeys) { return direct } + if let direct = self.firstString(in: root, keys: planKeys) { + return direct + } if let dataDict = root["data"] as? [String: Any], let plan = self.firstString(in: dataDict, keys: planKeys) { @@ -304,7 +316,9 @@ enum SyntheticUsageParser { } private static func windowMinutes(from payload: [String: Any]) -> Int? { - if let minutes = self.firstInt(in: payload, keys: windowMinutesKeys) { return minutes } + if let minutes = self.firstInt(in: payload, keys: windowMinutesKeys) { + return minutes + } if let hours = self.firstDouble(in: payload, keys: windowHoursKeys) { return Int((hours * 60).rounded()) } @@ -453,27 +467,35 @@ enum SyntheticUsageParser { private static func normalizedPercent(_ value: Double?) -> Double? { guard let value else { return nil } - if value <= 1 { return value * 100 } + if value <= 1 { + return value * 100 + } return value } private static func firstString(in payload: [String: Any], keys: [String]) -> String? { for key in keys { - if let value = self.stringValue(payload[key]) { return value } + if let value = self.stringValue(payload[key]) { + return value + } } return nil } private static func firstDouble(in payload: [String: Any], keys: [String]) -> Double? { for key in keys { - if let value = self.doubleValue(payload[key]) { return value } + if let value = self.doubleValue(payload[key]) { + return value + } } return nil } private static func firstInt(in payload: [String: Any], keys: [String]) -> Int? { for key in keys { - if let value = self.intValue(payload[key]) { return value } + if let value = self.intValue(payload[key]) { + return value + } } return nil } diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift index c9cf985689..90656bac50 100644 --- a/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift +++ b/Sources/CodexBarCore/Providers/Venice/VeniceProviderDescriptor.swift @@ -37,16 +37,34 @@ public enum VeniceProviderDescriptor { tokenCost: ProviderTokenCostConfig( supportsTokenCost: false, noDataMessage: { "Venice per-day cost history is not available via API." }), - fetchPlan: .apiToken( - strategyID: "venice.api", - resolveToken: { ProviderTokenResolver.veniceToken(environment: $0) }, - missingCredentialsError: { VeniceUsageError.missingCredentials }, - loadUsage: { apiKey, _ in - try await VeniceUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() - }), + fetchPlan: self.fetchPlan(), cli: ProviderCLIConfig( name: "venice", aliases: ["ven"], versionDetector: nil)) } + + private static func fetchPlan() -> ProviderFetchPlan { + #if canImport(JavaScriptCore) + .scriptPrototypeAPI( + configuration: .init( + provider: .venice, + plugin: "venice", + secretKey: VeniceSettingsReader.apiKeyEnvironmentKey, + strategyID: "venice.api"), + resolveToken: { ProviderTokenResolver.veniceToken(environment: $0) }, + missingCredentialsError: { VeniceUsageError.missingCredentials }, + loadUsage: { apiKey, _ in + try await VeniceUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }) + #else + .apiToken( + strategyID: "venice.api", + resolveToken: { ProviderTokenResolver.veniceToken(environment: $0) }, + missingCredentialsError: { VeniceUsageError.missingCredentials }, + loadUsage: { apiKey, _ in + try await VeniceUsageFetcher.fetchUsage(apiKey: apiKey).toUsageSnapshot() + }) + #endif + } } diff --git a/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift b/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift index 0326a40ddf..11d60bf908 100644 --- a/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift +++ b/Sources/CodexBarCore/Providers/Venice/VeniceUsageFetcher.swift @@ -161,7 +161,10 @@ public struct VeniceUsageFetcher: Sendable { private static let balanceURL = URL(string: "https://api.venice.ai/api/v1/billing/balance")! private static let timeoutSeconds: TimeInterval = 15 - public static func fetchUsage(apiKey: String) async throws -> VeniceUsageSnapshot { + public static func fetchUsage( + apiKey: String, + transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> VeniceUsageSnapshot + { guard !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { throw VeniceUsageError.missingCredentials } @@ -172,7 +175,7 @@ public struct VeniceUsageFetcher: Sendable { request.setValue("application/json", forHTTPHeaderField: "Accept") request.timeoutInterval = Self.timeoutSeconds - let response = try await ProviderHTTPClient.shared.response(for: request) + let response = try await transport.response(for: request) guard response.statusCode == 200 else { Self.log.error("Venice API returned \(response.statusCode)") throw VeniceUsageError.apiError("HTTP \(response.statusCode)") diff --git a/Sources/CodexBarCore/Resources/Plugins/crof.js b/Sources/CodexBarCore/Resources/Plugins/crof.js new file mode 100644 index 0000000000..76fc630d0b --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/crof.js @@ -0,0 +1,68 @@ +defineProvider({ + id: "crof", + name: "Crof", + endpoints: ["https://crof.ai"], + auth: { type: "bearer", secret: "CROF_API_KEY" }, + settings: [ + { + key: "CROF_API_KEY", + title: "API key", + subtitle: "Crof API key used for the public usage endpoint.", + type: "secure", + }, + ], + + async fetchUsage(ctx) { + const response = await ctx.http.getJSON("https://crof.ai/usage_api/"); + if (response.status !== 200) throw new Error(`Crof API error: HTTP ${response.status}`); + const payload = response.json; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("Failed to parse Crof response: expected an object"); + } + if (typeof payload.credits !== "number" || !Number.isFinite(payload.credits)) { + throw new Error("Failed to parse Crof response: credits must be a number"); + } + + function optionalNumber(value, field) { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`Failed to parse Crof response: ${field} must be a number`); + } + return value; + } + + const requestsPlan = optionalNumber(payload.requests_plan, "requests_plan"); + const usableRequests = optionalNumber(payload.usable_requests, "usable_requests"); + const credits = Math.max(0, payload.credits); + const creditsWindow = { + usedPercent: credits > 0 ? 0 : 100, + resetDescription: `$${(Math.floor(credits * 100) / 100).toFixed(2)}`, + }; + + if (requestsPlan === null || usableRequests === null) { + return { + primary: creditsWindow, + identity: { loginMethod: "API key" }, + }; + } + + const clampedRemaining = Math.max(0, Math.min(requestsPlan, usableRequests)); + const remainingPercent = requestsPlan > 0 + ? Math.max(0, Math.min(100, Math.floor(clampedRemaining / requestsPlan * 100))) + : 0; + const displayedRequests = Math.max(0, usableRequests); + const requestText = Number.isInteger(displayedRequests) + ? displayedRequests.toFixed(0) + : displayedRequests.toFixed(2); + return { + primary: { + usedPercent: 100 - remainingPercent, + windowMinutes: 1440, + resetsAt: ctx.date.nextDailyReset("America/Chicago", 0), + resetDescription: `${requestText} requests left`, + }, + secondary: creditsWindow, + identity: { loginMethod: "API key" }, + }; + }, +}); diff --git a/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js b/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js new file mode 100644 index 0000000000..d0c6dc6c13 --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/provider-plugin-prelude.js @@ -0,0 +1,131 @@ +(function applyProviderPluginPrelude(ctx, host) { + "use strict"; + + ctx.http = Object.freeze({ + getJSON(url, opts) { + return new Promise((resolve, reject) => host.http(String(url), opts || {}, true, resolve, reject)); + }, + get(url, opts) { + return new Promise((resolve, reject) => host.http(String(url), opts || {}, false, resolve, reject)); + }, + }); + + ctx.secrets = Object.freeze({ + get(key) { + return host.secretGet(String(key)); + }, + }); + + ctx.log = (...args) => host.log(args.map(value => { + if (typeof value === "string") return value; + try { return JSON.stringify(value); } catch (_) { return String(value); } + }).join(" ")); + + ctx.cache = Object.freeze({ + get(key) { + return host.cacheGet(String(key)); + }, + set(key, value, ttlSeconds) { + host.cacheSet(String(key), value, Number(ttlSeconds)); + }, + }); + + function parseDate(value) { + const date = new Date(value); + if (!Number.isFinite(date.getTime())) throw new TypeError("invalid date"); + return date; + } + + function zonedParts(date, timeZone) { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hourCycle: "h23", + }); + const values = {}; + for (const part of formatter.formatToParts(date)) { + if (part.type !== "literal") values[part.type] = Number(part.value); + } + return values; + } + + function zonedEpoch(year, month, day, hour, timeZone) { + let guess = Date.UTC(year, month - 1, day, hour, 0, 0); + for (let iteration = 0; iteration < 3; iteration += 1) { + const parts = zonedParts(new Date(guess), timeZone); + const represented = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second); + guess += Date.UTC(year, month - 1, day, hour, 0, 0) - represented; + } + return guess; + } + + function decodeBase64URL(text) { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const normalized = String(text).replace(/-/g, "+").replace(/_/g, "/").replace(/=+$/, ""); + let bits = 0; + let bitCount = 0; + let output = ""; + for (const character of normalized) { + const value = alphabet.indexOf(character); + if (value < 0) throw new TypeError("invalid base64url data"); + bits = (bits << 6) | value; + bitCount += 6; + if (bitCount >= 8) { + bitCount -= 8; + output += String.fromCharCode((bits >> bitCount) & 0xff); + } + } + let escaped = ""; + for (let index = 0; index < output.length; index += 1) { + escaped += `%${output.charCodeAt(index).toString(16).padStart(2, "0")}`; + } + return decodeURIComponent(escaped); + } + + ctx.date = Object.freeze({ + iso(value) { return parseDate(String(value)); }, + unixSeconds(value) { return parseDate(Number(value) * 1000); }, + unixMillis(value) { return parseDate(Number(value)); }, + nextDailyReset(timeZone, hour) { + const resetHour = Number(hour); + if (!Number.isInteger(resetHour) || resetHour < 0 || resetHour > 23) { + throw new TypeError("reset hour must be an integer from 0 through 23"); + } + const now = new Date(); + const parts = zonedParts(now, String(timeZone)); + let candidate = zonedEpoch(parts.year, parts.month, parts.day, resetHour, String(timeZone)); + if (candidate <= now.getTime()) { + const tomorrow = new Date(Date.UTC(parts.year, parts.month - 1, parts.day) + 86400000); + candidate = zonedEpoch( + tomorrow.getUTCFullYear(), + tomorrow.getUTCMonth() + 1, + tomorrow.getUTCDate(), + resetHour, + String(timeZone)); + } + return new Date(candidate); + }, + }); + + ctx.jwt = Object.freeze({ + decode(token) { + const parts = String(token).split("."); + if (parts.length < 2) throw new TypeError("JWT must contain a payload segment"); + return JSON.parse(decodeBase64URL(parts[1])); + }, + }); + + ctx.pct = (used, limit) => { + const numericUsed = Number(used); + const numericLimit = Number(limit); + if (!Number.isFinite(numericUsed) || !Number.isFinite(numericLimit) || numericLimit <= 0) return 100; + return Math.max(0, Math.min(100, numericUsed / numericLimit * 100)); + }; + + return ctx; +}) diff --git a/Sources/CodexBarCore/Resources/Plugins/synthetic.js b/Sources/CodexBarCore/Resources/Plugins/synthetic.js new file mode 100644 index 0000000000..f8b9704e97 --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/synthetic.js @@ -0,0 +1,261 @@ +defineProvider({ + id: "synthetic", + name: "Synthetic", + endpoints: ["https://api.synthetic.new"], + auth: { type: "bearer", secret: "SYNTHETIC_API_KEY" }, + settings: [ + { + key: "SYNTHETIC_API_KEY", + title: "API key", + subtitle: "Synthetic API key used for the quota endpoint.", + type: "secure", + }, + ], + + async fetchUsage(ctx) { + const response = await ctx.http.getJSON("https://api.synthetic.new/v2/quotas"); + if (response.status === 401 || response.status === 403) { + throw new Error("Invalid Synthetic API credentials"); + } + if (response.status !== 200) throw new Error(`Synthetic API error: HTTP ${response.status}`); + + const object = response.json; + const root = Array.isArray(object) ? { quotas: object } : object; + if (!root || typeof root !== "object") { + throw new Error("Failed to parse Synthetic response: expected an object or array"); + } + + const labelKeys = ["name", "label", "type", "period", "scope", "title", "id"]; + const percentUsedKeys = [ + "percentUsed", "usedPercent", "usagePercent", "usage_percent", "used_percent", "percent_used", "percent", + ]; + const percentRemainingKeys = [ + "percentRemaining", "remainingPercent", "remaining_percent", "percent_remaining", + ]; + const limitKeys = [ + "limit", "messageLimit", "message_limit", "messages", "maxRequests", "max_requests", "requestLimit", + "request_limit", "quota", "max", "total", "capacity", "allowance", + ]; + const usedKeys = [ + "used", "usage", "usedMessages", "used_messages", "messagesUsed", "messages_used", "requests", + "requestCount", "request_count", "consumed", "spent", + ]; + const remainingKeys = ["remaining", "left", "available", "balance"]; + const resetKeys = [ + "resetAt", "reset_at", "resetsAt", "resets_at", "renewAt", "renew_at", "renewsAt", "renews_at", + "nextTickAt", "next_tick_at", "nextRegenAt", "next_regen_at", "periodEnd", "period_end", "expiresAt", + "expires_at", "endAt", "end_at", + ]; + const planKeys = [ + "plan", "planName", "plan_name", "subscription", "subscriptionPlan", "tier", "package", "packageName", + ]; + + function stringValue(value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; + } + + function numberValue(value) { + if (typeof value === "number") return Number.isFinite(value) ? value : null; + if (typeof value === "string" && value.trim().length) { + const number = Number(value.trim()); + return Number.isFinite(number) ? number : null; + } + return null; + } + + function firstString(payload, keys) { + for (const key of keys) { + const value = stringValue(payload[key]); + if (value !== null) return value; + } + return null; + } + + function firstNumber(payload, keys) { + for (const key of keys) { + const value = numberValue(payload[key]); + if (value !== null) return value; + } + return null; + } + + function parseDate(value) { + const number = numberValue(value); + if (number !== null) { + if (number > 1000000000000) return ctx.date.unixMillis(number); + if (number > 1000000000) return ctx.date.unixSeconds(number); + } + if (typeof value === "string") { + try { return ctx.date.iso(value); } catch (_) { return null; } + } + return null; + } + + function firstDate(payload, keys) { + for (const key of keys) { + if (payload[key] === null || payload[key] === undefined) continue; + const date = parseDate(payload[key]); + if (date !== null) return date; + } + return null; + } + + function normalizedPercent(value) { + if (value === null) return null; + return value <= 1 ? value * 100 : value; + } + + function currencyValue(value) { + if (typeof value === "string") { + const parsed = Number(value.trim().replace(/\$/g, "").replace(/,/g, "")); + return Number.isFinite(parsed) ? parsed : null; + } + return numberValue(value); + } + + function firstCurrency(payload, keys) { + for (const key of keys) { + const value = currencyValue(payload[key]); + if (value !== null) return value; + } + return null; + } + + function windowMinutes(payload) { + const minutes = firstNumber(payload, ["windowMinutes", "window_minutes", "periodMinutes", "period_minutes"]); + if (minutes !== null) return Math.round(minutes); + const hours = firstNumber(payload, ["windowHours", "window_hours", "periodHours", "period_hours"]); + if (hours !== null) return Math.round(hours * 60); + const days = firstNumber(payload, ["windowDays", "window_days", "periodDays", "period_days"]); + if (days !== null) return Math.round(days * 1440); + const seconds = firstNumber(payload, ["windowSeconds", "window_seconds", "periodSeconds", "period_seconds"]); + if (seconds !== null) return Math.round(seconds / 60); + const text = firstString(payload, ["window", "windowLabel", "window_label", "period", "periodLabel", "period_label"]); + if (text === null) return null; + const match = text.toLowerCase().replace(/\s/g, "").match(/^([0-9]*\.?[0-9]+)(minutes?|mins?|m|hours?|hrs?|hr|h|days?|d)$/); + if (!match) return null; + const multipliers = { m: 1, min: 1, mins: 1, minute: 1, minutes: 1, + h: 60, hr: 60, hrs: 60, hour: 60, hours: 60, + d: 1440, day: 1440, days: 1440 }; + return Math.round(Number(match[1]) * multipliers[match[2]]); + } + + function windowDescription(minutes) { + if (!minutes || minutes <= 0) return null; + if (minutes % 1440 === 0) { + const days = minutes / 1440; + return `${days} day${days === 1 ? "" : "s"} window`; + } + if (minutes % 60 === 0) { + const hours = minutes / 60; + return `${hours} hour${hours === 1 ? "" : "s"} window`; + } + return `${minutes} minute${minutes === 1 ? "" : "s"} window`; + } + + function isQuota(payload) { + return payload && typeof payload === "object" && !Array.isArray(payload) && + [limitKeys, usedKeys, remainingKeys, percentUsedKeys, percentRemainingKeys] + .some(keys => firstNumber(payload, keys) !== null); + } + + function parseQuota(payload) { + let usedPercent = normalizedPercent(firstNumber(payload, percentUsedKeys)); + const percentRemaining = normalizedPercent(firstNumber(payload, percentRemainingKeys)); + if (usedPercent === null && percentRemaining !== null) usedPercent = 100 - percentRemaining; + + if (usedPercent === null) { + let limit = firstNumber(payload, limitKeys); + let used = firstNumber(payload, usedKeys); + let remaining = firstNumber(payload, remainingKeys); + if (limit === null && used !== null && remaining !== null) limit = used + remaining; + if (used === null && limit !== null && remaining !== null) used = limit - remaining; + if (remaining === null && limit !== null && used !== null) remaining = Math.max(0, limit - used); + if (limit !== null && used !== null && limit > 0) usedPercent = used / limit * 100; + } + if (usedPercent === null) return null; + usedPercent = Math.max(0, Math.min(100, usedPercent)); + + const minutes = windowMinutes(payload); + const resetsAt = firstDate(payload, resetKeys); + const window = { usedPercent }; + if (minutes !== null) window.windowMinutes = minutes; + if (resetsAt !== null) window.resetsAt = resetsAt; + else { + const description = windowDescription(minutes); + if (description !== null) window.resetDescription = description; + } + const tickPercent = normalizedPercent(firstNumber( + payload, + ["tickPercent", "tick_percent", "nextTickPercent", "next_tick_percent"])); + if (tickPercent !== null) window.nextRegenPercent = tickPercent; + + const costLimit = firstCurrency(payload, ["maxCredits", "max_credits"]); + let cost = null; + if (costLimit !== null) { + const remaining = firstCurrency(payload, ["remainingCredits", "remaining_credits"]); + const explicitUsed = firstCurrency(payload, ["usedCredits", "used_credits"]); + const used = explicitUsed !== null ? explicitUsed : + remaining !== null ? Math.max(0, costLimit - remaining) : usedPercent / 100 * costLimit; + cost = { used, limit: costLimit, currency: "USD", period: "Weekly" }; + if (resetsAt !== null) cost.resetsAt = resetsAt; + const regen = firstCurrency(payload, ["nextRegenCredits", "next_regen_credits"]); + if (regen !== null) cost.nextRegenAmount = regen; + } + return { label: firstString(payload, labelKeys), window, cost }; + } + + function namedQuota(candidate, label) { + if (!isQuota(candidate)) return null; + return Object.assign({ label }, candidate); + } + + function collect(candidate) { + if (Array.isArray(candidate)) return candidate.flatMap(collect); + if (!candidate || typeof candidate !== "object") return []; + if (isQuota(candidate)) return [candidate]; + return Object.keys(candidate).sort().flatMap(key => collect(candidate[key])); + } + + const data = root.data && typeof root.data === "object" ? root.data : null; + const slots = [ + namedQuota(root.rollingFiveHourLimit, "Rolling five-hour limit") || + namedQuota(data && data.rollingFiveHourLimit, "Rolling five-hour limit"), + namedQuota(root.weeklyTokenLimit, "Weekly token limit") || + namedQuota(data && data.weeklyTokenLimit, "Weekly token limit"), + namedQuota(root.search && root.search.hourly, "Search hourly") || + namedQuota(data && data.search && data.search.hourly, "Search hourly"), + ]; + + let parsed; + if (slots.some(Boolean)) { + parsed = slots.map(value => value ? parseQuota(value) : null); + } else { + const candidates = [ + root.quotas, root.quota, root.limits, root.usage, root.entries, root.subscription, root.data, + data && data.quotas, data && data.quota, data && data.limits, data && data.usage, + data && data.entries, data && data.subscription, + ]; + let values = []; + for (const candidate of candidates) { + values = collect(candidate); + if (values.length) break; + } + parsed = values.map(parseQuota).filter(Boolean); + } + if (!parsed.some(Boolean)) throw new Error("Failed to parse Synthetic response: Missing quota data."); + + const plan = firstString(root, planKeys) || (data ? firstString(data, planKeys) : null); + const snapshot = { + primary: parsed[0] ? parsed[0].window : null, + secondary: parsed[1] ? parsed[1].window : null, + tertiary: parsed[2] ? parsed[2].window : null, + identity: plan ? { loginMethod: plan } : {}, + }; + const withCost = parsed.find(value => value && value.cost); + if (withCost) snapshot.cost = withCost.cost; + return snapshot; + }, +}); diff --git a/Sources/CodexBarCore/Resources/Plugins/venice.js b/Sources/CodexBarCore/Resources/Plugins/venice.js new file mode 100644 index 0000000000..6782f2a8ee --- /dev/null +++ b/Sources/CodexBarCore/Resources/Plugins/venice.js @@ -0,0 +1,77 @@ +defineProvider({ + id: "venice", + name: "Venice", + endpoints: ["https://api.venice.ai"], + auth: { type: "bearer", secret: "VENICE_API_KEY" }, + settings: [ + { + key: "VENICE_API_KEY", + title: "API key", + subtitle: "Venice API key used for the billing balance endpoint.", + type: "secure", + }, + ], + + async fetchUsage(ctx) { + const response = await ctx.http.getJSON("https://api.venice.ai/api/v1/billing/balance"); + if (response.status !== 200) throw new Error(`Venice API error: HTTP ${response.status}`); + + const payload = response.json; + if (!payload || typeof payload !== "object" || Array.isArray(payload)) { + throw new Error("Failed to parse Venice response: expected an object"); + } + if (typeof payload.canConsume !== "boolean") { + throw new Error("Failed to parse Venice response: canConsume must be a boolean"); + } + if (!payload.balances || typeof payload.balances !== "object" || Array.isArray(payload.balances)) { + throw new Error("Failed to parse Venice response: balances must be an object"); + } + + function optionalNumber(value, field) { + if (value === null || value === undefined || value === "") return null; + const number = typeof value === "number" ? value : + typeof value === "string" ? Number(value.trim()) : Number.NaN; + if (!Number.isFinite(number)) throw new Error(`Failed to parse Venice response: ${field} must be numeric`); + return number; + } + + if (payload.consumptionCurrency !== null && payload.consumptionCurrency !== undefined && + typeof payload.consumptionCurrency !== "string") { + throw new Error("Failed to parse Venice response: consumptionCurrency must be a string"); + } + const currency = payload.consumptionCurrency ? payload.consumptionCurrency.toUpperCase() : null; + const diem = optionalNumber(payload.balances.diem, "balances.diem"); + const usd = optionalNumber(payload.balances.usd, "balances.usd"); + const allocation = optionalNumber(payload.diemEpochAllocation, "diemEpochAllocation"); + + let usedPercent; + let resetDescription; + if (!payload.canConsume) { + usedPercent = 100; + resetDescription = "Balance unavailable for API calls"; + } else if (currency === "USD" && usd !== null && usd > 0) { + usedPercent = 0; + resetDescription = `$${usd.toFixed(2)} USD remaining`; + } else if (currency !== "USD" && diem !== null && allocation !== null && allocation > 0) { + usedPercent = ctx.pct(allocation - diem, allocation); + resetDescription = `DIEM ${diem.toFixed(2)} / ${allocation.toFixed(2)} epoch allocation`; + } else if (currency === "DIEM" && diem !== null && diem > 0) { + usedPercent = 0; + resetDescription = `DIEM ${diem.toFixed(2)} remaining`; + } else if (diem !== null && diem > 0) { + usedPercent = 0; + resetDescription = `DIEM ${diem.toFixed(2)} remaining`; + } else if (usd !== null && usd > 0) { + usedPercent = 0; + resetDescription = `$${usd.toFixed(2)} USD remaining`; + } else { + usedPercent = 100; + resetDescription = "No Venice API balance available"; + } + + return { + primary: { usedPercent, resetDescription }, + identity: {}, + }; + }, +}); diff --git a/Tests/CodexBarTests/ProviderPluginParityTests.swift b/Tests/CodexBarTests/ProviderPluginParityTests.swift new file mode 100644 index 0000000000..840fd3af48 --- /dev/null +++ b/Tests/CodexBarTests/ProviderPluginParityTests.swift @@ -0,0 +1,166 @@ +#if canImport(JavaScriptCore) +import Foundation +import Testing +@testable import CodexBarCore + +struct ProviderPluginParityTests { + @Test + func `prototype flag prepends JS without changing the default pipeline`() async { + let descriptor = ProviderDescriptorRegistry.descriptor(for: .synthetic) + let defaultStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + Self.context(environment: ["SYNTHETIC_API_KEY": "fixture-key"])) + let prototypeStrategies = await descriptor.fetchPlan.pipeline.resolveStrategies( + Self.context(environment: [ + "SYNTHETIC_API_KEY": "fixture-key", + ProviderPluginPrototype.environmentKey: "1", + ])) + + #expect(defaultStrategies.map(\.id) == ["synthetic.api"]) + #expect(prototypeStrategies.map(\.id) == ["synthetic.js", "synthetic.api"]) + #expect(prototypeStrategies[0].shouldFallback( + on: ProviderPluginError.script("fixture"), + context: Self.context(environment: [:])) == false) + } + + @Test + func `Synthetic fixture has Swift and JS snapshot parity`() async throws { + let body = """ + { + "plan": "Starter", + "weeklyTokenLimit": { + "nextRegenAt": "2026-04-17T05:19:30.000Z", + "percentRemaining": 98.05884722222223, + "maxCredits": "$36.00", + "remainingCredits": "$35.30", + "nextRegenCredits": "$0.72" + }, + "rollingFiveHourLimit": { + "nextTickAt": "2026-04-17T03:44:11.000Z", + "tickPercent": 0.05, + "remaining": 600, + "max": 750, + "limited": false + }, + "search": { + "hourly": { + "limit": 250, + "requests": 2, + "renewsAt": "2026-04-17T04:30:01.494Z" + } + } + } + """ + let transport = Self.transport(body: body) + let now = Date(timeIntervalSince1970: 1_775_000_000) + + let swift = try await SyntheticUsageFetcher.fetchUsage( + apiKey: "fixture-key", + now: now, + transport: transport).toUsageSnapshot() + let runtime = try ProviderPluginRuntime(bundledPlugin: "synthetic", transport: transport) + let script = try await runtime.fetchUsage(secrets: ["SYNTHETIC_API_KEY": "fixture-key"], now: now) + + Self.expectCoreParity(swift, script) + } + + @Test + func `Venice fixture has Swift and JS snapshot parity`() async throws { + let body = """ + { + "canConsume": true, + "consumptionCurrency": "BUNDLED_CREDITS", + "balances": { "diem": "50.0", "usd": "10.0" }, + "diemEpochAllocation": "100.0" + } + """ + let transport = Self.transport(body: body) + let now = Date(timeIntervalSince1970: 1_775_000_000) + + let swift = try await VeniceUsageFetcher.fetchUsage( + apiKey: "fixture-key", + transport: transport).toUsageSnapshot() + let runtime = try ProviderPluginRuntime(bundledPlugin: "venice", transport: transport) + let script = try await runtime.fetchUsage(secrets: ["VENICE_API_KEY": "fixture-key"], now: now) + + Self.expectCoreParity(swift, script) + } + + @Test + func `Crof fixture has Swift and JS snapshot parity`() async throws { + let body = #"{"credits":9.9999,"requests_plan":1000,"usable_requests":998}"# + let transport = Self.transport(body: body) + let now = Date() + + let swift = try await CrofUsageFetcher.fetchUsage( + apiKey: "fixture-key", + session: transport).toUsageSnapshot() + let runtime = try ProviderPluginRuntime(bundledPlugin: "crof", transport: transport) + let script = try await runtime.fetchUsage(secrets: ["CROF_API_KEY": "fixture-key"], now: now) + + Self.expectCoreParity(swift, script) + } + + private static func transport(body: String) -> ProviderHTTPTransportHandler { + ProviderHTTPTransportHandler { request in + #expect(request.httpMethod == "GET") + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer fixture-key") + #expect(request.value(forHTTPHeaderField: "Accept") == "application/json") + let response = try #require(HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } + } + + private static func context(environment: [String: String]) -> ProviderFetchContext { + ProviderFetchContext( + runtime: .app, + sourceMode: .api, + includeCredits: false, + webTimeout: 1, + webDebugDumpHTML: false, + verbose: false, + env: environment, + settings: nil, + fetcher: UsageFetcher(environment: environment), + claudeFetcher: ProviderPluginParityClaudeFetcher(), + browserDetection: BrowserDetection(cacheTTL: 0)) + } + + private static func expectCoreParity(_ swift: UsageSnapshot, _ script: UsageSnapshot) { + #expect(swift.primary == script.primary) + #expect(swift.secondary == script.secondary) + #expect(swift.tertiary == script.tertiary) + #expect(swift.extraRateWindows == script.extraRateWindows) + #expect(swift.subscriptionRenewsAt == script.subscriptionRenewsAt) + #expect(swift.subscriptionExpiresAt == script.subscriptionExpiresAt) + #expect(swift.providerCost?.used == script.providerCost?.used) + #expect(swift.providerCost?.limit == script.providerCost?.limit) + #expect(swift.providerCost?.currencyCode == script.providerCost?.currencyCode) + #expect(swift.providerCost?.period == script.providerCost?.period) + #expect(swift.providerCost?.resetsAt == script.providerCost?.resetsAt) + #expect(swift.providerCost?.nextRegenAmount == script.providerCost?.nextRegenAmount) + #expect(swift.identity?.providerID == script.identity?.providerID) + #expect(swift.identity?.accountEmail == script.identity?.accountEmail) + #expect(swift.identity?.accountOrganization == script.identity?.accountOrganization) + #expect(swift.identity?.loginMethod == script.identity?.loginMethod) + #expect(swift.identity?.accountID == script.identity?.accountID) + } +} + +private struct ProviderPluginParityClaudeFetcher: ClaudeUsageFetching { + func loadLatestUsage(model _: String) async throws -> ClaudeUsageSnapshot { + throw ProviderPluginError.script("unused") + } + + func debugRawProbe(model _: String) async -> String { + "unused" + } + + func detectVersion() -> String? { + nil + } +} +#endif diff --git a/Tests/CodexBarTests/ProviderPluginRuntimeTests.swift b/Tests/CodexBarTests/ProviderPluginRuntimeTests.swift new file mode 100644 index 0000000000..fabe76d195 --- /dev/null +++ b/Tests/CodexBarTests/ProviderPluginRuntimeTests.swift @@ -0,0 +1,192 @@ +#if canImport(JavaScriptCore) +import Foundation +import Testing +@testable import CodexBarCore + +struct ProviderPluginRuntimeTests { + @Test + func `context exposes no browser or timer globals`() throws { + let runtime = try ProviderPluginRuntime(source: Self.plugin()) + + #expect(try runtime.globalType(of: "fetch") == "undefined") + #expect(try runtime.globalType(of: "XMLHttpRequest") == "undefined") + #expect(try runtime.globalType(of: "setTimeout") == "undefined") + #expect(try runtime.globalType(of: "setInterval") == "undefined") + #expect(try runtime.globalType(of: "ctx") == "undefined") + } + + @Test + func `origin allowlist rejects before issuing request`() async throws { + let requests = RequestRecorder() + let runtime = try ProviderPluginRuntime( + source: Self.plugin(fetchBody: """ + const response = await ctx.http.getJSON("https://other.example/usage"); + return { primary: { usedPercent: response.status } }; + """), + transport: Self.transport(recorder: requests)) + + await #expect(throws: ProviderPluginError.self) { + _ = try await runtime.fetchUsage(secrets: ["TEST_KEY": "secret"]) + } + #expect(await requests.isEmpty) + } + + @Test + func `HTTP broker injects auth and returns JSON`() async throws { + let requests = RequestRecorder() + let runtime = try ProviderPluginRuntime( + source: Self.plugin(fetchBody: """ + const response = await ctx.http.getJSON("https://api.example.test/usage", { + headers: { "X-Client": "plugin-test" }, + }); + return { primary: { usedPercent: response.json.used } }; + """), + transport: Self.transport(recorder: requests, body: #"{"used":42}"#)) + + let snapshot = try await runtime.fetchUsage(secrets: ["TEST_KEY": "secret-value"]) + + #expect(snapshot.primary?.usedPercent == 42) + let request = try #require(await requests.first) + #expect(request.value(forHTTPHeaderField: "Authorization") == "Bearer secret-value") + #expect(request.value(forHTTPHeaderField: "X-Client") == "plugin-test") + } + + @Test + func `undeclared secret access fails`() async throws { + let runtime = try ProviderPluginRuntime(source: Self.plugin(fetchBody: """ + ctx.secrets.get("OTHER_KEY"); + return { primary: { usedPercent: 1 } }; + """)) + + await #expect(throws: ProviderPluginError.self) { + _ = try await runtime.fetchUsage(secrets: ["TEST_KEY": "secret"]) + } + } + + @Test(arguments: [ + "defineProvider({", + "defineProvider({ id: 'synthetic' });", + """ + defineProvider({ + id: "not-a-provider", + name: "Bad", + endpoints: ["https://api.example.test"], + auth: { type: "bearer", secret: "TEST_KEY" }, + settings: [{ key: "TEST_KEY", title: "Key" }], + fetchUsage: async () => ({ primary: { usedPercent: 0 } }), + }); + """, + ]) + func `malformed plugins have descriptive load errors`(source: String) { + #expect(throws: ProviderPluginError.self) { + _ = try ProviderPluginRuntime(source: source) + } + } + + @Test + func `wrong typed snapshot field fails`() async throws { + let runtime = try ProviderPluginRuntime(source: Self.plugin(fetchBody: """ + return { primary: { usedPercent: "42" } }; + """)) + + await #expect(throws: ProviderPluginError.self) { + _ = try await runtime.fetchUsage(secrets: ["TEST_KEY": "secret"]) + } + } + + @Test + func `promise rejection preserves message`() async throws { + let runtime = try ProviderPluginRuntime(source: Self.plugin(fetchBody: """ + throw new Error("fixture rejected"); + """)) + + do { + _ = try await runtime.fetchUsage(secrets: ["TEST_KEY": "secret"]) + Issue.record("Expected rejection") + } catch { + #expect(error.localizedDescription.contains("fixture rejected")) + } + } + + @Test + func `script errors redact known secrets`() async throws { + let secret = "super-secret-fixture-value" + let runtime = try ProviderPluginRuntime(source: Self.plugin(fetchBody: """ + throw new Error(`leaked: ${ctx.secrets.get("TEST_KEY")}`); + """)) + + do { + _ = try await runtime.fetchUsage(secrets: ["TEST_KEY": secret]) + Issue.record("Expected rejection") + } catch { + #expect(!error.localizedDescription.contains(secret)) + #expect(error.localizedDescription.contains("")) + } + } + + @Test + func `hung script times out and next fetch uses a fresh context`() async throws { + let runtime = try ProviderPluginRuntime( + source: Self.plugin(fetchBody: """ + if (ctx.secrets.get("TEST_KEY") === "hang") while (true) {} + return { primary: { usedPercent: 7 } }; + """), + timeout: 0.15) + let start = Date() + + await #expect(throws: ProviderPluginError.self) { + _ = try await runtime.fetchUsage(secrets: ["TEST_KEY": "hang"]) + } + #expect(Date().timeIntervalSince(start) < 1) + + let recovered = try await runtime.fetchUsage(secrets: ["TEST_KEY": "ok"]) + #expect(recovered.primary?.usedPercent == 7) + } + + private static func plugin(fetchBody: String = "return { primary: { usedPercent: 1 } };") -> String { + """ + defineProvider({ + id: "synthetic", + name: "Fixture", + endpoints: ["https://api.example.test"], + auth: { type: "bearer", secret: "TEST_KEY" }, + settings: [{ key: "TEST_KEY", title: "API key", type: "secure" }], + async fetchUsage(ctx) { + \(fetchBody) + }, + }); + """ + } + + private static func transport( + recorder: RequestRecorder, + body: String = #"{"ok":true}"#) -> ProviderHTTPTransportHandler + { + ProviderHTTPTransportHandler { request in + await recorder.append(request) + let response = try #require(HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"])) + return (Data(body.utf8), response) + } + } +} + +private actor RequestRecorder { + private var requests: [URLRequest] = [] + + var isEmpty: Bool { + self.requests.isEmpty + } + + var first: URLRequest? { + self.requests.first + } + + func append(_ request: URLRequest) { + self.requests.append(request) + } +} +#endif diff --git a/docs/plugin-conversion-matrix.md b/docs/plugin-conversion-matrix.md new file mode 100644 index 0000000000..336e67f8c8 --- /dev/null +++ b/docs/plugin-conversion-matrix.md @@ -0,0 +1,101 @@ +--- +summary: "All-provider conversion matrix for the bundled JavaScriptCore prototype capability set." +read_when: + - Choosing another provider to convert to JavaScript + - Planning the next plugin host capability +--- + +# Provider plugin conversion matrix + +This matrix evaluates all 67 providers in the 2026-08-02 capability audit against the prototype documented in +[`plugin-prototype.md`](plugin-prototype.md). The current checkout has 66 `UsageProvider` cases; Notion is retained here +because it is the 67th audited provider explicitly requested by this work order. Each provider has one primary blocker. + +`convertible-now` means the canonical first-party flow is GET-only, uses a fixed HTTPS origin and header secret, and fits +the generic snapshot. Optional canonical-origin endpoint overrides do not change that bucket; providers whose identity +is inherently a user-chosen origin (LLM Proxy and LiteLLM) do not qualify. The convertible rows were checked against the +current Swift request methods and snapshot projections; Azure OpenAI, StepFun, and Warp were removed from the audit's +earlier “fully expressible” baseline because their current implementations issue POST requests. + +## Totals + +| Status | Count | +|---|---:| +| `convertible-now` | 11 | +| `needs-details-model` | 8 | +| `needs-cookie-import` | 23 | +| `needs-files/subprocess/oauth-broker` | 15 | +| `needs-pty/webview/native` | 10 | +| **Total** | **67** | + +## Matrix + +| Provider | Status | Reason | +|---|---|---| +| codex | `needs-pty/webview/native` | PTY CLI, OAuth files/refresh, browser cookies, WKWebView scraping, local logs, and reset-credit details exceed this host. | +| openai | `needs-details-model` | GET pagination fits, but daily/model/line-item cost and token history does not fit the generic snapshot. | +| azureopenai | `needs-pty/webview/native` | The current quota probe is a POST chat completion against a user-configured deployment origin. | +| claude | `needs-files/subprocess/oauth-broker` | Full parity needs credential files/Keychain, OAuth refresh, CLI/PTY, cookies, local logs, and admin details. | +| clinepass | `convertible-now` | Verified fixed-origin bearer GET; limits and identity map to generic windows. | +| cursor | `needs-cookie-import` | Browser cookies/app database provide auth, and integer request history also has bespoke detail. | +| opencode | `needs-cookie-import` | React server-function usage requires imported browser cookies and non-JSON text parsing. | +| opencodego | `needs-files/subprocess/oauth-broker` | Local auth/SQLite state and browser sessions are required, with an additional bespoke usage model. | +| alibaba | `needs-cookie-import` | The console path needs imported cookies, CSRF/sec-token discovery, redirects, and embedded response parsing. | +| alibabatokenplan | `needs-cookie-import` | Full parity depends on Aliyun console cookies, CSRF/sec-token acquisition, and several dependent calls. | +| qwencloud | `needs-cookie-import` | OneConsole usage needs imported cookies, CSRF, form POSTs, and redirect-aware routing. | +| factory | `needs-cookie-import` | WorkOS/browser cookies and local storage recover the session; the API-key-only path is merely partial. | +| gemini | `needs-files/subprocess/oauth-broker` | Gemini CLI credential/config files, Google OAuth refresh, and a curl fallback own the current flow. | +| antigravity | `needs-pty/webview/native` | Process/port discovery, localhost IDE RPC, OAuth files, and a persistent PTY make this a native integration. | +| copilot | `needs-cookie-import` | API-token usage fits, but billing budgets require GitHub cookies/nonces and the device flow needs POST. | +| devin | `needs-files/subprocess/oauth-broker` | Full auth discovery reads Chromium localStorage and organization state; manual bearer alone is partial. | +| zai | `needs-details-model` | Generic quota windows fit, but model/time-limit details and the hourly chart require a details schema. | +| minimax | `needs-cookie-import` | Browser cookies/storage and group discovery feed a large service/billing/history-specific payload. | +| manus | `needs-cookie-import` | Full session acquisition imports browser cookies; a manually supplied bearer covers only one path. | +| kimi | `needs-cookie-import` | Browser cookies plus Kimi credential/device files and regional identity headers exceed the current broker. | +| kilo | `needs-files/subprocess/oauth-broker` | The default source reads Kilo's local auth file and organization metadata. | +| kiro | `needs-pty/webview/native` | Usage exists only through bounded CLI pipe/PTY automation and a bespoke credit/overage model. | +| vertexai | `needs-files/subprocess/oauth-broker` | ADC/gcloud files, OAuth refresh, optional subprocess fallback, and local cost logs are required. | +| augment | `needs-files/subprocess/oauth-broker` | The preferred strategy spawns `auggie`; the alternative imports browser cookies and maintains sessions. | +| jetbrains | `needs-pty/webview/native` | There is no HTTP strategy; native IDE discovery and local XML parsing are the provider. | +| moonshot | `convertible-now` | Verified bearer GET against two fixed regional origins; balances project into generic windows. | +| amp | `needs-files/subprocess/oauth-broker` | CLI subprocess and browser-cookie strategies plus workspace credit details are outside this host. | +| t3chat | `needs-cookie-import` | Usage is authenticated by an imported browser session cookie. | +| ollama | `needs-cookie-import` | The full hosted flow imports cookies and scrapes HTML; the API-key model-count probe is only partial. | +| synthetic | `convertible-now` | Converted: fixed-origin bearer GET with generic windows, cost, dates, and identity. | +| warp | `needs-pty/webview/native` | Warp sends a POST GraphQL operation, which the GET-only HTTP broker cannot express. | +| openrouter | `needs-details-model` | The bearer GET fits, but credits, per-key budgets, and rate-limit detail require a provider detail model. | +| elevenlabs | `convertible-now` | Verified `xi-api-key` GET; heterogeneous character/minute quotas map to named generic windows. | +| windsurf | `needs-files/subprocess/oauth-broker` | Chromium localStorage, IDE databases, and binary protobuf decoding supply the current session. | +| zed | `needs-files/subprocess/oauth-broker` | Zed server settings and a named Keychain credential must be read locally. | +| perplexity | `needs-cookie-import` | The provider imports a browser session before mapping multiple credit buckets. | +| mimo | `needs-cookie-import` | Browser/Firefox session import and a local cache feed balance, plan, and token-specific details. | +| doubao | `needs-files/subprocess/oauth-broker` | Full parity needs a CLI subprocess or Volcengine HMAC signing and POST-based plan calls. | +| sakana | `needs-cookie-import` | The web flow needs a cookie session; PAYG balance/period data also needs a detail model. | +| abacus | `needs-cookie-import` | Required compute and optional billing calls are authenticated through imported browser cookies. | +| mistral | `needs-cookie-import` | Console cookies/CSRF gate wallet, credit-note, and model-history details. | +| deepseek | `needs-files/subprocess/oauth-broker` | Platform auth/profile selection reads Chromium localStorage, and the result has a bespoke history model. | +| deepinfra | `convertible-now` | Verified fixed-origin bearer GET pair; spend limit and balance project into generic cost/windows. | +| codebuff | `needs-files/subprocess/oauth-broker` | Full credential parity reads a local Manicode credential file; environment-key mode is partial. | +| crof | `convertible-now` | Converted: fixed-origin bearer GET with exact credit formatting and America/Chicago daily reset. | +| venice | `convertible-now` | Converted: fixed-origin bearer GET with DIEM/USD allocation projection. | +| commandcode | `needs-cookie-import` | Browser cookies authenticate both calls, and three live subscription/depletion flags need details. | +| qoder | `needs-cookie-import` | Global/China session selection imports cookies and sends a bespoke browser header. | +| stepfun | `needs-files/subprocess/oauth-broker` | Device registration, password login, refresh, quota, and plan operations are POST-based token-broker work. | +| bedrock | `needs-files/subprocess/oauth-broker` | AWS profiles/CLI credentials, SigV4 signing, pagination, and two services need host-owned credential/signing APIs. | +| grok | `needs-pty/webview/native` | Persistent stdio JSON-RPC, auth/session files, cookies, logs, and binary gRPC-web are strongly native. | +| groq | `needs-cookie-import` | The API-token Prometheus path is partial; console parity imports Stytch/browser sessions and history details. | +| llmproxy | `needs-pty/webview/native` | Its origin is user-selected and may be private HTTP, conflicting with the manifest's fixed HTTPS origins. | +| litellm | `needs-pty/webview/native` | Its required user-selected proxy origin and optional private HTTP cannot be declared by a bundled static manifest. | +| deepgram | `needs-details-model` | GET acquisition fits, but project and heterogeneous hours/tokens/characters/request totals need details. | +| poe | `needs-details-model` | Bearer GET pagination fits, but raw/daily/model/type point history does not fit the generic snapshot. | +| chutes | `convertible-now` | Verified bearer GET fan-out on the canonical origin; dynamic quota lanes map to named windows. | +| neuralwatt | `convertible-now` | Verified canonical bearer GET; quota lanes and prepaid cost/energy project generically. | +| clawrouter | `needs-details-model` | Bearer JSON acquisition fits, but budget ledger, request/token totals, and upstream summaries need details. | +| longcat | `needs-cookie-import` | Account, token use, and pending fuel merge behind an imported browser/manual cookie session. | +| sub2api | `needs-details-model` | Bearer JSON acquisition fits, but balance/quota/rate/subscription/today/total fields need details. | +| wayfinder | `needs-pty/webview/native` | The local unauthenticated HTTP gateway, metrics text, and routing/savings model violate HTTPS-only generic scope. | +| zenmux | `convertible-now` | Verified fixed-origin bearer GET pair; subscription and optional PAYG balance map generically. | +| aiand | `convertible-now` | Verified fixed-origin bearer GET pagination; 30-day spend maps to generic cost. | +| zoommate | `needs-cookie-import` | Host-specific cookies are exchanged for a JWT, then paginated credit history requires details. | +| xai | `needs-details-model` | Management-key GETs fit, but prepaid balance, limit state, and daily cost history need details. | +| notion | `needs-cookie-import` | Workspace selection and AI allowance calls require imported Notion cookies and forwarded session headers. | diff --git a/docs/plugin-prototype.md b/docs/plugin-prototype.md new file mode 100644 index 0000000000..cd7f7fbc1b --- /dev/null +++ b/docs/plugin-prototype.md @@ -0,0 +1,116 @@ +--- +summary: "JavaScriptCore provider-plugin prototype API, safety boundary, enablement, and limitations." +read_when: + - Working on the JavaScript provider prototype + - Converting a first-party provider to a bundled JavaScript resource + - Reviewing the plugin sandbox or parity tests +--- + +# JavaScript provider-plugin prototype + +This prototype proves that an existing first-party `UsageProvider` can define its manifest, HTTP requests, response +parsing, and generic `UsageSnapshot` projection in one bundled JavaScript file. It is deliberately not a user-plugin +system: IDs remain compile-time `UsageProvider` cases, scripts ship inside CodexBar, and the normal Swift path remains +the default. + +## Enable and test + +Set `CODEXBAR_JS_PROVIDERS=1` in CodexBar's environment. Synthetic, Venice, and Crof then prepend a script strategy to +their existing API pipeline. A missing required secret leaves the script strategy unavailable and permits the Swift +strategy to run; a loaded script that fails does not fall back, so parity defects stay visible. Without the variable, +the resolver returns the original Swift strategy only and does not load JavaScriptCore or a plugin resource. + +Run the focused proof with: + +```sh +swift test --filter ProviderPluginRuntimeTests +swift test --filter ProviderPluginParityTests +``` + +The second suite sends the same canned response through an injected `ProviderHTTPTransport` to both implementations and +compares core windows, percentages, reset dates, cost, subscription dates, and identity fields. + +## Manifest + +Every script calls `defineProvider` once: + +```js +defineProvider({ + id: "example", // must be an existing UsageProvider raw value + name: "Example", + endpoints: ["https://api.example.com"], // HTTPS origins, not URL prefixes + auth: { + type: "bearer", // bearer | x-api-key | header + header: "X-Custom-Key", // required only for type: "header" + secret: "EXAMPLE_API_KEY", // key declared below + }, + settings: [{ + key: "EXAMPLE_API_KEY", + title: "API key", + subtitle: "Where to obtain the key.", + type: "secure", // secure | plain + }], + async fetchUsage(ctx) { + const response = await ctx.http.getJSON("https://api.example.com/v1/usage"); + return { primary: { usedPercent: response.json.usedPercent } }; + }, +}); +``` + +`endpoints` accepts only normalized HTTPS origins. The broker rejects user info, non-HTTPS URLs, and any request whose +scheme, host, or effective port is not declared. `bearer` injects `Authorization: Bearer `, `x-api-key` injects +`X-API-Key`, and `header` injects the named header. A plugin cannot override its auth header in request options. + +## `ctx` reference + +`ctx` exists only as the argument to `fetchUsage`; it is not a global. JavaScriptCore supplies standard ECMAScript +built-ins, but no browser or Node host environment. Tests assert that `fetch`, `XMLHttpRequest`, `setTimeout`, and +`setInterval` are undefined. + +- `await ctx.http.getJSON(url, opts?)` performs a GET and returns `{status, headers, json}`. +- `await ctx.http.get(url, opts?)` performs a GET and returns `{status, headers, bodyText}`. +- `opts.headers` may contain string header values. Requests have a 15-second timeout, responses are capped at 5 MiB, + and transport uses `ProviderHTTPClient`, including its same-origin HTTPS redirect policy. +- `ctx.secrets.get(key)` returns a value only for a key declared in `settings`; undeclared access throws. +- `ctx.log(...values)` writes to the provider-derived `-plugin` category. Do not log credentials; known secret + values are also substring-redacted from errors crossing back to Swift. +- `ctx.cache.get(key)` and `ctx.cache.set(key, value, ttlSeconds)` provide an in-memory, per-context cache. TTLs are + positive and capped at 24 hours. +- `ctx.date.iso(text)`, `unixSeconds(number)`, and `unixMillis(number)` return JavaScript `Date` objects. +- `ctx.date.nextDailyReset(timeZoneIdentifier, hour)` returns the next wall-clock hour in an IANA time zone, including + DST transitions. Crof uses `America/Chicago` at hour `0`. +- `ctx.jwt.decode(token)` decodes the JSON payload segment without verifying a signature. +- `ctx.pct(used, limit)` returns a finite percentage clamped to 0–100; a non-positive limit maps to 100. + +## Snapshot result + +`fetchUsage` resolves to an object containing at least one window or `cost`. `primary`, `secondary`, and `tertiary` are +optional `{usedPercent, resetsAt?, windowMinutes?, resetDescription?, nextRegenPercent?}` objects. `extraWindows` is an +optional array of `{id, title, window}`. Percentages must be finite numbers and are clamped to 0–100; window minutes must +be positive integers. + +`cost` requires finite numeric `used` and a three-letter uppercase `currency`; `limit`, `period`, `resetsAt`, +`nextRegenAmount`, and `balance` are optional. A missing limit maps to zero. `identity` accepts bounded, trimmed `email`, +`organization`, `loginMethod`, and `accountID` strings; Swift always scopes it to the manifest provider ID. +`subscriptionRenewsAt` and `subscriptionExpiresAt` accept a JavaScript `Date` or ISO-8601 string. Missing optionals are +fine, while a present value of the wrong type fails the entire fetch with its property path. + +## Concurrency and execution limit + +Each runtime owns one `JSContext` confined to a dedicated serial dispatch queue; `JSContext` and every `JSValue` remain +on that executor. Promise `then`/rejection callbacks converge on a lock-protected checked continuation gate, so network, +timeout, and script completion can resume Swift exactly once. The exported `JSContextGroupSetExecutionTimeLimit` symbol +has no declaration in the public macOS JavaScriptCore headers, so the prototype does not bind that private SPI. + +Instead, a 20-second wall-clock watchdog fails the refresh and discards the poisoned worker; the next refresh creates a +new context on a fresh executor, which the hung-script recovery test proves. This keeps refresh callers responsive but +cannot interrupt the abandoned JavaScriptCore thread, which may remain alive until process exit. A production plugin +runtime needs a public interrupt API or a killable helper-process boundary before accepting untrusted scripts. + +## Current limitations + +The runtime is macOS-only and compiled out when JavaScriptCore is unavailable. It supports bundled first-party IDs and +the generic snapshot only: no runtime identities, user-installed files, install UI, TypeScript/Sucrase, provider-specific +dashboard payloads, cookies, OAuth/refresh broker, local files or databases, subprocesses, POST bodies, PTY, WebView, +binary/protobuf responses, localhost HTTP, or dynamic endpoint origins. See +[`plugin-conversion-matrix.md`](plugin-conversion-matrix.md) for the provider-by-provider impact.