import Foundation import OpenClawProtocol enum OpenClawConfigFile { private static let logger = Logger(subsystem: "ai.openclaw", category: "config") private static let configAuditFileName = "config-audit.jsonl" static func url() -> URL { OpenClawPaths.configURL } static func stateDirURL() -> URL { OpenClawPaths.stateDirURL } static func defaultWorkspaceURL() -> URL { OpenClawPaths.workspaceURL } static func loadDict() -> [String: Any] { let url = self.url() guard FileManager().fileExists(atPath: url.path) else { return [:] } do { let data = try Data(contentsOf: url) guard let root = self.parseConfigData(data) else { self.logger.warning("config JSON root invalid") return [:] } return root } catch { self.logger.warning("config read failed: \(error.localizedDescription)") return [:] } } static func saveDict(_ dict: [String: Any]) { // Nix mode disables config writes in production, but tests rely on saving temp configs. if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return } let url = self.url() let previousData = try? Data(contentsOf: url) let previousRoot = previousData.flatMap { self.parseConfigData($0) } let previousBytes = previousData?.count let hadMetaBefore = self.hasMeta(previousRoot) let gatewayModeBefore = self.gatewayMode(previousRoot) var output = dict self.stampMeta(&output) do { let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys]) try FileManager().createDirectory( at: url.deletingLastPathComponent(), withIntermediateDirectories: true) try data.write(to: url, options: [.atomic]) let nextBytes = data.count let gatewayModeAfter = self.gatewayMode(output) let suspicious = self.configWriteSuspiciousReasons( existsBefore: previousData != nil, previousBytes: previousBytes, nextBytes: nextBytes, hadMetaBefore: hadMetaBefore, gatewayModeBefore: gatewayModeBefore, gatewayModeAfter: gatewayModeAfter) if !suspicious.isEmpty { self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)") } self.appendConfigWriteAudit([ "result": "success", "configPath": url.path, "existsBefore": previousData != nil, "previousBytes": previousBytes ?? NSNull(), "nextBytes": nextBytes, "hasMetaBefore": hadMetaBefore, "hasMetaAfter": self.hasMeta(output), "gatewayModeBefore": gatewayModeBefore ?? NSNull(), "gatewayModeAfter": gatewayModeAfter ?? NSNull(), "suspicious": suspicious, ]) } catch { self.logger.error("config save failed: \(error.localizedDescription)") self.appendConfigWriteAudit([ "result": "failed", "configPath": url.path, "existsBefore": previousData != nil, "previousBytes": previousBytes ?? NSNull(), "nextBytes": NSNull(), "hasMetaBefore": hadMetaBefore, "hasMetaAfter": self.hasMeta(output), "gatewayModeBefore": gatewayModeBefore ?? NSNull(), "gatewayModeAfter": self.gatewayMode(output) ?? NSNull(), "suspicious": [], "error": error.localizedDescription, ]) } } static func loadGatewayDict() -> [String: Any] { let root = self.loadDict() return root["gateway"] as? [String: Any] ?? [:] } static func updateGatewayDict(_ mutate: (inout [String: Any]) -> Void) { var root = self.loadDict() var gateway = root["gateway"] as? [String: Any] ?? [:] mutate(&gateway) if gateway.isEmpty { root.removeValue(forKey: "gateway") } else { root["gateway"] = gateway } self.saveDict(root) } static func browserControlEnabled(defaultValue: Bool = true) -> Bool { let root = self.loadDict() let browser = root["browser"] as? [String: Any] return browser?["enabled"] as? Bool ?? defaultValue } static func setBrowserControlEnabled(_ enabled: Bool) { var root = self.loadDict() var browser = root["browser"] as? [String: Any] ?? [:] browser["enabled"] = enabled root["browser"] = browser self.saveDict(root) self.logger.debug("browser control updated enabled=\(enabled)") } static func agentWorkspace() -> String? { let root = self.loadDict() let agents = root["agents"] as? [String: Any] let defaults = agents?["defaults"] as? [String: Any] return defaults?["workspace"] as? String } static func setAgentWorkspace(_ workspace: String?) { var root = self.loadDict() var agents = root["agents"] as? [String: Any] ?? [:] var defaults = agents["defaults"] as? [String: Any] ?? [:] let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" if trimmed.isEmpty { defaults.removeValue(forKey: "workspace") } else { defaults["workspace"] = trimmed } if defaults.isEmpty { agents.removeValue(forKey: "defaults") } else { agents["defaults"] = defaults } if agents.isEmpty { root.removeValue(forKey: "agents") } else { root["agents"] = agents } self.saveDict(root) self.logger.debug("agents.defaults.workspace updated set=\(!trimmed.isEmpty)") } static func gatewayPassword() -> String? { let root = self.loadDict() guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any] else { return nil } return remote["password"] as? String } static func gatewayPort() -> Int? { let root = self.loadDict() guard let gateway = root["gateway"] as? [String: Any] else { return nil } if let port = gateway["port"] as? Int, port > 0 { return port } if let number = gateway["port"] as? NSNumber, number.intValue > 0 { return number.intValue } if let raw = gateway["port"] as? String, let parsed = Int(raw.trimmingCharacters(in: .whitespacesAndNewlines)), parsed > 0 { return parsed } return nil } static func remoteGatewayPort() -> Int? { guard let url = self.remoteGatewayUrl(), let port = url.port, port > 0 else { return nil } return port } static func remoteGatewayPort(matchingHost sshHost: String) -> Int? { let trimmedSshHost = sshHost.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedSshHost.isEmpty, let url = self.remoteGatewayUrl(), let port = url.port, port > 0, let urlHost = url.host?.trimmingCharacters(in: .whitespacesAndNewlines), !urlHost.isEmpty else { return nil } let sshKey = Self.hostKey(trimmedSshHost) let urlKey = Self.hostKey(urlHost) guard !sshKey.isEmpty, !urlKey.isEmpty, sshKey == urlKey else { return nil } return port } static func setRemoteGatewayUrl(host: String, port: Int?) { guard let port, port > 0 else { return } let trimmedHost = host.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedHost.isEmpty else { return } self.updateGatewayDict { gateway in var remote = gateway["remote"] as? [String: Any] ?? [:] let existingUrl = (remote["url"] as? String)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" let scheme = URL(string: existingUrl)?.scheme ?? "ws" remote["url"] = "\(scheme)://\(trimmedHost):\(port)" gateway["remote"] = remote } } static func clearRemoteGatewayUrl() { self.updateGatewayDict { gateway in guard var remote = gateway["remote"] as? [String: Any] else { return } guard remote["url"] != nil else { return } remote.removeValue(forKey: "url") if remote.isEmpty { gateway.removeValue(forKey: "remote") } else { gateway["remote"] = remote } } } private static func remoteGatewayUrl() -> URL? { let root = self.loadDict() guard let gateway = root["gateway"] as? [String: Any], let remote = gateway["remote"] as? [String: Any], let raw = remote["url"] as? String else { return nil } let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty, let url = URL(string: trimmed) else { return nil } return url } private static func hostKey(_ host: String) -> String { let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() guard !trimmed.isEmpty else { return "" } if trimmed.contains(":") { return trimmed } let digits = CharacterSet(charactersIn: "0123456789.") if trimmed.rangeOfCharacter(from: digits.inverted) == nil { return trimmed } return trimmed.split(separator: ".").first.map(String.init) ?? trimmed } private static func parseConfigData(_ data: Data) -> [String: Any]? { if let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] { return root } let decoder = JSONDecoder() if #available(macOS 12.0, *) { decoder.allowsJSON5 = true } if let decoded = try? decoder.decode([String: AnyCodable].self, from: data) { self.logger.notice("config parsed with JSON5 decoder") return decoded.mapValues { $0.foundationValue } } return nil } private static func stampMeta(_ root: inout [String: Any]) { var meta = root["meta"] as? [String: Any] ?? [:] let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "macos-app" meta["lastTouchedVersion"] = version meta["lastTouchedAt"] = ISO8601DateFormatter().string(from: Date()) root["meta"] = meta } private static func hasMeta(_ root: [String: Any]?) -> Bool { guard let root else { return false } return root["meta"] is [String: Any] } private static func hasMeta(_ root: [String: Any]) -> Bool { root["meta"] is [String: Any] } private static func gatewayMode(_ root: [String: Any]?) -> String? { guard let root else { return nil } return self.gatewayMode(root) } private static func gatewayMode(_ root: [String: Any]) -> String? { guard let gateway = root["gateway"] as? [String: Any], let mode = gateway["mode"] as? String else { return nil } let trimmed = mode.trimmingCharacters(in: .whitespacesAndNewlines) return trimmed.isEmpty ? nil : trimmed } private static func configWriteSuspiciousReasons( existsBefore: Bool, previousBytes: Int?, nextBytes: Int, hadMetaBefore: Bool, gatewayModeBefore: String?, gatewayModeAfter: String?) -> [String] { var reasons: [String] = [] if !existsBefore { return reasons } if let previousBytes, previousBytes >= 512, nextBytes < max(1, previousBytes / 2) { reasons.append("size-drop:\(previousBytes)->\(nextBytes)") } if !hadMetaBefore { reasons.append("missing-meta-before-write") } if gatewayModeBefore != nil, gatewayModeAfter == nil { reasons.append("gateway-mode-removed") } return reasons } private static func configAuditLogURL() -> URL { self.stateDirURL() .appendingPathComponent("logs", isDirectory: true) .appendingPathComponent(self.configAuditFileName, isDirectory: false) } private static func appendConfigWriteAudit(_ fields: [String: Any]) { var record: [String: Any] = [ "ts": ISO8601DateFormatter().string(from: Date()), "source": "macos-openclaw-config-file", "event": "config.write", "pid": ProcessInfo.processInfo.processIdentifier, "argv": Array(ProcessInfo.processInfo.arguments.prefix(8)), ] for (key, value) in fields { record[key] = value is NSNull ? NSNull() : value } guard JSONSerialization.isValidJSONObject(record), let data = try? JSONSerialization.data(withJSONObject: record) else { return } var line = Data() line.append(data) line.append(0x0A) let logURL = self.configAuditLogURL() do { try FileManager().createDirectory( at: logURL.deletingLastPathComponent(), withIntermediateDirectories: true) if !FileManager().fileExists(atPath: logURL.path) { FileManager().createFile(atPath: logURL.path, contents: nil) } let handle = try FileHandle(forWritingTo: logURL) defer { try? handle.close() } try handle.seekToEnd() try handle.write(contentsOf: line) } catch { // best-effort } } }