import Foundation /// Drives a feed with URLSession and command execution: ask the core for requests, send them, /// hand the responses back, repeat until it reports. The loop is the same one the TypeScript host /// runs, so a provider with two dependent calls (teams, then billing) works here with no extra machinery. public enum UsageDriver { public typealias CommandRunner = @Sendable (UsageRequest) async throws -> UsageResponse public static func fetch( provider: String, credentialJSON: String, nowMs: Int64 = Int64(Date().timeIntervalSince1970 * 1000), session: URLSession = URLSession(configuration: .ephemeral), runCommand: CommandRunner? = nil, maxRounds: Int = 4 ) async throws -> UsageReport { let state = UsageState() var responses: [UsageResponse] = [] for _ in 0.. UsageResponse { guard let cmd = request.command, !cmd.isEmpty else { return UsageResponse(status: 127, body: "no command specified") } let process = Process() let stdoutPipe = Pipe() let stderrPipe = Pipe() let stdinPipe = Pipe() // Environment allowlist: PATH, HOME, LANG, LC_ALL, LC_CTYPE, TZ and proxy variables let allowedKeys: Set = [ "PATH", "HOME", "LANG", "LC_ALL", "LC_CTYPE", "TZ", "HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "all_proxy", "no_proxy", ] let systemEnv = ProcessInfo.processInfo.environment var filteredEnv: [String: String] = [:] for key in allowedKeys { if let val = systemEnv[key] { filteredEnv[key] = val } } process.environment = filteredEnv let args = request.args ?? [] if cmd.contains("/") { process.executableURL = URL(fileURLWithPath: cmd) process.arguments = args } else { let pathVar = filteredEnv["PATH"] ?? "/usr/bin:/bin:/usr/local/bin" var foundURL: URL? = nil for dir in pathVar.split(separator: ":") { let candidate = URL(fileURLWithPath: String(dir)).appendingPathComponent(cmd) if FileManager.default.isExecutableFile(atPath: candidate.path) { foundURL = candidate break } } if let foundURL { process.executableURL = foundURL process.arguments = args } else { // Not falling back to `/usr/bin/env`: it would exit 127 with the same code the protocol // reserves for "this host does not run commands", and the feed would read a missing // binary as a refusal. 126 says what actually happened. return UsageResponse(status: 126, body: "\(cmd) is not on PATH") } } process.standardOutput = stdoutPipe process.standardError = stderrPipe if request.stdin != nil { process.standardInput = stdinPipe } let timeoutSec: TimeInterval = Double(request.timeoutMs ?? 15_000) / 1000.0 let maxBytes = 64 * 1024 var stdoutData = Data() var stderrData = Data() let lock = NSLock() stdoutPipe.fileHandleForReading.readabilityHandler = { handle in let chunk = handle.availableData if chunk.isEmpty { return } lock.lock() if stdoutData.count < maxBytes { let remaining = maxBytes - stdoutData.count stdoutData.append(chunk.prefix(remaining)) } lock.unlock() } stderrPipe.fileHandleForReading.readabilityHandler = { handle in let chunk = handle.availableData if chunk.isEmpty { return } lock.lock() if stderrData.count < maxBytes { let remaining = maxBytes - stderrData.count stderrData.append(chunk.prefix(remaining)) } lock.unlock() } do { try process.run() } catch { stdoutPipe.fileHandleForReading.readabilityHandler = nil stderrPipe.fileHandleForReading.readabilityHandler = nil // 126 is "found it, could not run it", which is not the same answer as 127 ("this host does // not run commands"): a feed that treats 127 as "fall back to another path" must not be told // that when the real problem is a missing binary. return UsageResponse(status: 126, body: error.localizedDescription) } if let stdin = request.stdin, let data = stdin.data(using: .utf8) { try? stdinPipe.fileHandleForWriting.write(contentsOf: data) try? stdinPipe.fileHandleForWriting.close() } let deadline = Date().addingTimeInterval(timeoutSec) while process.isRunning && Date() < deadline { try? await Task.sleep(nanoseconds: 20_000_000) } if process.isRunning { process.terminate() var termWait = 0 while process.isRunning && termWait < 10 { try? await Task.sleep(nanoseconds: 20_000_000) termWait += 1 } } stdoutPipe.fileHandleForReading.readabilityHandler = nil stderrPipe.fileHandleForReading.readabilityHandler = nil let trailingOut = stdoutPipe.fileHandleForReading.availableData if !trailingOut.isEmpty { lock.lock() if stdoutData.count < maxBytes { let remaining = maxBytes - stdoutData.count stdoutData.append(trailingOut.prefix(remaining)) } lock.unlock() } let trailingErr = stderrPipe.fileHandleForReading.availableData _ = trailingErr var body = String(data: stdoutData, encoding: .utf8) ?? "" let exitCode = Int(process.terminationStatus) // A command that wrote nothing to stdout but failed still has something to say, and saying it // on stderr is how a CLI reports a missing argument or a bad credential. Without this the feed // only sees an empty body and cannot tell the user why nothing came back. if exitCode != 0, body.isEmpty, let errorText = String(data: stderrData, encoding: .utf8), !errorText.isEmpty { body = errorText } return UsageResponse(status: exitCode, body: body) } /// The core asks for one credential kind; a real app resolves it from its own store (or from /// prime-agent's auth.json). The shape is provider-defined and documented in usage-feeds.md. public static func credentialJSON(kind: String, value: String, accountId: String? = nil, email: String? = nil, extra: [String: String] = [:]) -> String { var object: [String: Any] = ["kind": kind] switch kind { case "oauth": object["access"] = value case "apiKey": object["apiKey"] = value case "session": object["session"] = (try? JSONSerialization.jsonObject(with: Data(value.utf8))) ?? [:] default: object[kind] = value } if let accountId { object["accountId"] = accountId } if let email { object["email"] = email } for (key, val) in extra { object[key] = val } let data = try? JSONSerialization.data(withJSONObject: object) return data.flatMap { String(data: $0, encoding: .utf8) } ?? "{}" } private static func send(_ request: UsageRequest, session: URLSession) async throws -> UsageResponse { guard let url = URL(string: request.url) else { return UsageResponse(status: 0, body: "invalid url: \(request.url)") } var urlRequest = URLRequest(url: url) urlRequest.httpMethod = request.method for (name, value) in request.headers { urlRequest.setValue(value, forHTTPHeaderField: name) } if let body = request.body { urlRequest.httpBody = Data(body.utf8) } do { let (data, response) = try await session.data(for: urlRequest) let status = (response as? HTTPURLResponse)?.statusCode ?? 0 return UsageResponse(status: status, body: String(data: data, encoding: .utf8) ?? "") } catch { // A transport failure is data for the feed, not an exception: the report carries it. return UsageResponse(status: 0, body: "transport error: \(error.localizedDescription)") } } }