mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-22 06:54:04 +00:00
* refactor: remove stale file-backed shims * fix: harden sqlite state ci boundaries * refactor: store matrix idb snapshots in sqlite * fix: satisfy rebased CI guardrails * refactor: store current conversation bindings in sqlite table * refactor: store tui last sessions in sqlite table * refactor: reset sqlite schema history * refactor: drop unshipped sqlite table migration * refactor: remove plugin index file rollback * refactor: drop unshipped sqlite sidecar migrations * refactor: remove runtime commitments kv migration * refactor: preserve kysely sync result types * refactor: drop unshipped sqlite schema migration table * test: keep session usage coverage sqlite-backed * refactor: keep sqlite migration doctor-only * refactor: isolate device legacy imports * refactor: isolate push voicewake legacy imports * refactor: isolate remaining runtime legacy imports * refactor: tighten sqlite migration guardrails * test: cover sqlite persisted enum parsing * refactor: isolate legacy update and tui imports * refactor: tighten sqlite state ownership * refactor: move legacy imports behind doctor * refactor: remove legacy session row lookup * refactor: canonicalize memory transcript locators * refactor: drop transcript path scope fallbacks * refactor: drop runtime legacy session delivery pruning * refactor: store tts prefs only in sqlite * refactor: remove cron store path runtime * refactor: use cron sqlite store keys * refactor: rename telegram message cache scope * refactor: read memory dreaming status from sqlite * refactor: rename cron status store key * refactor: stop remembering transcript file paths * test: use sqlite locators in agent fixtures * refactor: remove file-shaped commitments and cron store surfaces * refactor: keep compaction transcript handles out of session rows * refactor: derive transcript handles from session identity * refactor: derive runtime transcript handles * refactor: remove gateway session locator reads * refactor: remove transcript locator from session rows * refactor: store raw stream diagnostics in sqlite * refactor: remove file-shaped transcript rotation * refactor: hide legacy trajectory paths from runtime * refactor: remove runtime transcript file bridges * refactor: repair database-first rebase fallout * refactor: align tests with database-first state * refactor: remove transcript file handoffs * refactor: sync post-compaction memory by transcript scope * refactor: run codex app-server sessions by id * refactor: bind codex runtime state by session id * refactor: pass memory transcripts by sqlite scope * refactor: remove transcript locator cleanup leftovers * test: remove stale transcript file fixtures * refactor: remove transcript locator test helper * test: make cron sqlite keys explicit * test: remove cron runtime store paths * test: remove stale session file fixtures * test: use sqlite cron keys in diagnostics * refactor: remove runtime delivery queue backfill * test: drop fake export session file mocks * refactor: rename acp session read failure flag * refactor: rename acp row session key * refactor: remove session store test seams * refactor: move legacy session parser tests to doctor * refactor: reindex managed memory in place * refactor: drop stale session store wording * refactor: rename session row helpers * refactor: rename sqlite session entry modules * refactor: remove transcript locator leftovers * refactor: trim file-era audit wording * refactor: clean managed media through sqlite * fix: prefer explicit agent for exports * fix: use prepared agent for session resets * fix: canonicalize legacy codex binding import * test: rename state cleanup helper * docs: align backup docs with sqlite state * refactor: drop legacy Pi usage auth fallback * refactor: move legacy auth profile imports to doctor * refactor: keep Pi model discovery auth in memory * refactor: remove MSTeams legacy learning key fallback * refactor: store model catalog config in sqlite * refactor: use sqlite model catalog at runtime * refactor: remove model json compatibility aliases * refactor: store auth profiles in sqlite * refactor: seed copied auth profiles in sqlite * refactor: make auth profile runtime sqlite-addressed * refactor: migrate hermes secrets into sqlite auth store * refactor: move plugin install config migration to doctor * refactor: rename plugin index audit checks * test: drop auth file assumptions * test: remove legacy transcript file assertions * refactor: drop legacy cli session aliases * refactor: store skill uploads in sqlite * refactor: keep subagent attachments in sqlite vfs * refactor: drop subagent attachment cleanup state * refactor: move legacy session aliases to doctor * refactor: require node 24 for sqlite state runtime * refactor: move provider caches into sqlite state * fix: harden virtual agent filesystem * refactor: enforce database-first runtime state * refactor: rename compaction transcript rotation setting * test: clean sqlite refactor test types * refactor: consolidate sqlite runtime state * refactor: model session conversations in sqlite * refactor: stop deriving cron delivery from session keys * refactor: stop classifying sessions from key shape * refactor: hydrate announce targets from typed delivery * refactor: route heartbeat delivery from typed sqlite context * refactor: tighten typed sqlite session routing * refactor: remove session origin routing shadow * refactor: drop session origin shadow fixtures * perf: query sqlite vfs paths by prefix * refactor: use typed conversation metadata for sessions * refactor: prefer typed session routing metadata * refactor: require typed session routing metadata * refactor: resolve group tool policy from typed sessions * refactor: delete dead session thread info bridge * Show Codex subscription reset times in channel errors (#80456) * feat(plugin-sdk): consolidate session workflow APIs * fix(agents): allow read-only agent mount reads * [codex] refresh plugin regression fixtures * fix(agents): restore compaction gateway logs * test: tighten gateway startup assertions * Redact persisted secret-shaped payloads [AI] (#79006) * test: tighten device pair notify assertions * test: tighten hermes secret assertions * test: assert matrix client error shapes * test: assert config compat warnings * fix(heartbeat): remap cron-run exec events to session keys (#80214) * fix(codex): route btw through native side threads * fix(auth): accept friendly OpenAI order for Codex profiles * fix(codex): rotate auth profiles inside harness * fix: keep browser status page probe within timeout * test: assert agents add outputs * test: pin cron read status * fix(agents): avoid Pi resource discovery stalls Co-authored-by: dataCenter430 <titan032000@gmail.com> * fix: retire timed-out codex app-server clients * test: tighten qa lab runtime assertions * test: check security fix outputs * test: verify extension runtime messages * feat(wake): expose typed sessionKey on wake protocol + system event CLI * fix(gateway): await session_end during shutdown drain and track channel + compaction lifecycle paths (#57790) * test: guard talk consult call helper * fix(codex): scale context engine projection (#80761) * fix(codex): scale context engine projection * fix: document Codex context projection scaling * fix: document Codex context projection scaling * fix: document Codex context projection scaling * fix: document Codex context projection scaling * chore: align Codex projection changelog * chore: realign Codex projection changelog * fix: isolate Codex projection patch --------- Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org> Co-authored-by: Josh Lehman <josh@martian.engineering> * refactor: move agent runtime state toward piless * refactor: remove cron session reaper * refactor: move session management to sqlite * refactor: finish database-first state migration * chore: refresh generated sqlite db types * refactor: remove stale file-backed shims * test: harden kysely type coverage # Conflicts: # .agents/skills/kysely-database-access/SKILL.md # src/infra/kysely-sync.types.test.ts # src/proxy-capture/store.sqlite.test.ts # src/state/openclaw-agent-db.test.ts # src/state/openclaw-state-db.test.ts * refactor: remove cron store path runtime * refactor: keep compaction transcript handles out of session rows * refactor: derive embedded transcripts from sqlite identity * refactor: remove embedded transcript locator handoff * refactor: remove runtime transcript file bridges * refactor: remove transcript file handoffs * refactor: remove MSTeams legacy learning key fallback * refactor: store model catalog config in sqlite * refactor: use sqlite model catalog at runtime # Conflicts: # docs/cli/secrets.md # docs/gateway/authentication.md # docs/gateway/secrets.md * fix: keep oauth sibling sync sqlite-local # Conflicts: # src/commands/onboard-auth.test.ts * refactor: remove task session store maintenance # Conflicts: # src/commands/tasks.ts * refactor: keep diagnostics in state sqlite * refactor: enforce database-first runtime state * refactor: consolidate sqlite runtime state * Show Codex subscription reset times in channel errors (#80456) * fix(codex): refresh subscription limit resets * fix(codex): format reset times for channels * Update CHANGELOG with latest changes and fixes Updated CHANGELOG with recent fixes and improvements. * fix(codex): keep command load failures on codex surface * fix(codex): format account rate limits as rows * fix(codex): summarize account limits as usage status * fix(codex): simplify account limit status * test: tighten subagent announce queue assertion * test: tighten session delete lifecycle assertions * test: tighten cron ops assertions * fix: track cron execution milestones * test: tighten hermes secret assertions * test: assert matrix sync store payloads * test: assert config compat warnings * fix(codex): align btw side thread semantics * fix(codex): honor codex fallback blocking * fix(agents): avoid Pi resource discovery stalls * test: tighten codex event assertions * test: tighten cron assertions * Fix Codex app-server OAuth harness auth * refactor: move agent runtime state toward piless * refactor: move device and push state to sqlite * refactor: move runtime json state imports to doctor * refactor: finish database-first state migration * chore: refresh generated sqlite db types * refactor: clarify cron sqlite store keys * refactor: remove stale file-backed shims * refactor: bind codex runtime state by session id * test: expect sqlite trajectory branch export * refactor: rename session row helpers * fix: keep legacy device identity import in doctor * refactor: enforce database-first runtime state * refactor: consolidate sqlite runtime state * build: align pi contract wrappers * chore: repair database-first rebase * refactor: remove session file test contracts * test: update gateway session expectations * refactor: stop routing from session compatibility shadows * refactor: stop persisting session route shadows * refactor: use typed delivery context in clients * refactor: stop echoing session route shadows * refactor: repair embedded runner rebase imports # Conflicts: # src/agents/pi-embedded-runner/run/attempt.tool-call-argument-repair.ts * refactor: align pi contract imports * refactor: satisfy kysely sync helper guard * refactor: remove file transcript bridge remnants * refactor: remove session locator compatibility * refactor: remove session file test contracts * refactor: keep rebase database-first clean * refactor: remove session file assumptions from e2e * docs: clarify database-first goal state * test: remove legacy store markers from sqlite runtime tests * refactor: remove legacy store assumptions from runtime seams * refactor: align sqlite runtime helper seams * test: update memory recall sqlite audit mock * refactor: align database-first runtime type seams * test: clarify doctor cron legacy store names * fix: preserve sqlite session route projections * test: fix copilot token cache test syntax * docs: update database-first proof status * test: align database-first test fixtures * docs: update database-first proof status * refactor: clean extension database-first drift * test: align agent session route proof * test: clarify doctor legacy path fixtures * chore: clean database-first changed checks * chore: repair database-first rebase markers * build: allow baileys git subdependency * chore: repair exp-vfs rebase drift * chore: finish exp-vfs rebase cleanup * chore: satisfy rebase lint drift * chore: fix qqbot rebase type seam * chore: fix rebase drift leftovers * fix: keep auth profile oauth secrets out of sqlite * fix: repair rebase drift tests * test: stabilize pairing request ordering * test: use source manifests in plugin contract checks * fix: restore gateway session metadata after rebase * fix: repair database-first rebase drift * fix: clean up database-first rebase fallout * test: stabilize line quick reply receipt time * fix: repair extension rebase drift * test: keep transcript redaction tests sqlite-backed * fix: carry injected transcript redaction through sqlite * chore: clean database branch rebase residue * fix: repair database branch CI drift * fix: repair database branch CI guard drift * fix: stabilize oauth tls preflight test * test: align database branch fast guards * test: repair build artifact boundary guards * chore: clean changelog rebase markers --------- Co-authored-by: pashpashpash <nik@vault77.ai> Co-authored-by: Eva <eva@100yen.org> Co-authored-by: stainlu <stainlu@newtype-ai.org> Co-authored-by: Jason Zhou <jason.zhou.design@gmail.com> Co-authored-by: Ruben Cuevas <hi@rubencu.com> Co-authored-by: Pavan Kumar Gondhi <pavangondhi@gmail.com> Co-authored-by: Shakker <shakkerdroid@gmail.com> Co-authored-by: Kaspre <36520309+Kaspre@users.noreply.github.com> Co-authored-by: dataCenter430 <titan032000@gmail.com> Co-authored-by: Kaspre <kaspre@gmail.com> Co-authored-by: pandadev66 <nova.full.stack@outlook.com> Co-authored-by: Eva <admin@100yen.org> Co-authored-by: Eva (agent) <eva+agent-78055@100yen.org> Co-authored-by: Josh Lehman <josh@martian.engineering> Co-authored-by: jeffjhunter <support@aipersonamethod.com>
592 lines
23 KiB
Swift
592 lines
23 KiB
Swift
import CryptoKit
|
|
import Foundation
|
|
import OpenClawProtocol
|
|
|
|
enum OpenClawConfigFile {
|
|
private static let logger = Logger(subsystem: "ai.openclaw", category: "config")
|
|
private static let fileLock = NSRecursiveLock()
|
|
private nonisolated(unsafe) static var configHealthState: [String: Any] = [:]
|
|
|
|
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
|
|
self.fileLock.lock()
|
|
defer { self.fileLock.unlock() }
|
|
return try body()
|
|
}
|
|
|
|
#if DEBUG
|
|
static func withTestingFileLock<T>(_ body: () throws -> T) rethrows -> T {
|
|
try self.withFileLock(body)
|
|
}
|
|
#endif
|
|
|
|
static func url() -> URL {
|
|
OpenClawPaths.configURL
|
|
}
|
|
|
|
static func stateDirURL() -> URL {
|
|
OpenClawPaths.stateDirURL
|
|
}
|
|
|
|
static func defaultWorkspaceURL() -> URL {
|
|
OpenClawPaths.workspaceURL
|
|
}
|
|
|
|
static func loadDict() -> [String: Any] {
|
|
self.withFileLock {
|
|
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.observeConfigRead(data: data, root: nil, configURL: url, valid: false)
|
|
self.logger.warning("config JSON root invalid")
|
|
return [:]
|
|
}
|
|
self.observeConfigRead(data: data, root: root, configURL: url, valid: true)
|
|
return root
|
|
} catch {
|
|
self.logger.warning("config read failed: \(error.localizedDescription)")
|
|
return [:]
|
|
}
|
|
}
|
|
}
|
|
|
|
@discardableResult
|
|
static func saveDict(
|
|
_ dict: [String: Any],
|
|
preserveExistingKeys: Bool = false,
|
|
allowGatewayAuthMutation: Bool = false)
|
|
-> Bool
|
|
{
|
|
self.withFileLock {
|
|
// Nix mode disables config writes in production, but tests rely on saving temp configs.
|
|
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return false }
|
|
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 = if preserveExistingKeys, let previousRoot {
|
|
self.mergeExistingConfig(previousRoot, overridingWith: dict)
|
|
} else {
|
|
dict
|
|
}
|
|
let preservedGatewayAuth = self.preserveGatewayAuthIfNeeded(
|
|
previousRoot: previousRoot,
|
|
output: &output,
|
|
allowGatewayAuthMutation: allowGatewayAuthMutation)
|
|
self.stampMeta(&output)
|
|
|
|
do {
|
|
let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])
|
|
let nextBytes = data.count
|
|
let gatewayModeAfter = self.gatewayMode(output)
|
|
var suspicious = self.configWriteSuspiciousReasons(
|
|
existsBefore: previousData != nil,
|
|
previousBytes: previousBytes,
|
|
nextBytes: nextBytes,
|
|
hadMetaBefore: hadMetaBefore,
|
|
gatewayModeBefore: gatewayModeBefore,
|
|
gatewayModeAfter: gatewayModeAfter)
|
|
if preservedGatewayAuth {
|
|
suspicious.append("gateway-auth-preserved")
|
|
}
|
|
let blocking = self.configWriteBlockingReasons(suspicious)
|
|
if !blocking.isEmpty {
|
|
_ = self.persistRejectedConfigWrite(data: data, configURL: url)
|
|
self.logger.warning("config write rejected (\(blocking.joined(separator: ", "))) at \(url.path)")
|
|
return false
|
|
}
|
|
try FileManager().createDirectory(
|
|
at: url.deletingLastPathComponent(),
|
|
withIntermediateDirectories: true)
|
|
try data.write(to: url, options: [.atomic])
|
|
if !suspicious.isEmpty {
|
|
self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)")
|
|
}
|
|
self.observeConfigRead(data: data, root: output, configURL: url, valid: true)
|
|
return true
|
|
} catch {
|
|
self.logger.error("config save failed: \(error.localizedDescription)")
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
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? {
|
|
AgentWorkspaceConfig.workspace(from: self.loadDict())
|
|
}
|
|
|
|
static func setAgentWorkspace(_ workspace: String?) {
|
|
var root = self.loadDict()
|
|
AgentWorkspaceConfig.setWorkspace(in: &root, workspace: workspace)
|
|
self.saveDict(root)
|
|
let hasWorkspace = !(workspace?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
|
|
self.logger.debug("agents.defaults.workspace updated set=\(hasWorkspace)")
|
|
}
|
|
|
|
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? {
|
|
guard let normalizedSshHost = canonicalHostForComparison(sshHost),
|
|
let url = self.remoteGatewayUrl(),
|
|
let port = url.port,
|
|
port > 0,
|
|
let urlHost = url.host,
|
|
let normalizedUrlHost = canonicalHostForComparison(urlHost)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
guard normalizedSshHost == normalizedUrlHost 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 setRemoteGatewayUrlString(_ value: String) {
|
|
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
guard !trimmed.isEmpty else { return }
|
|
self.updateGatewayDict { gateway in
|
|
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
|
remote["url"] = trimmed
|
|
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
|
|
}
|
|
|
|
static func canonicalHostForComparison(_ raw: String?) -> String? {
|
|
guard var host = raw?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased(),
|
|
!host.isEmpty
|
|
else {
|
|
return nil
|
|
}
|
|
host = host.trimmingCharacters(in: CharacterSet(charactersIn: "[]"))
|
|
while host.hasSuffix(".") {
|
|
host.removeLast()
|
|
}
|
|
return host.isEmpty ? nil : host
|
|
}
|
|
|
|
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 gatewayAuth(_ root: [String: Any]?) -> [String: Any]? {
|
|
guard let root,
|
|
let gateway = root["gateway"] as? [String: Any]
|
|
else { return nil }
|
|
return gateway["auth"] as? [String: Any]
|
|
}
|
|
|
|
private static func configDictionariesEqual(_ left: [String: Any]?, _ right: [String: Any]) -> Bool {
|
|
guard let left else { return false }
|
|
return NSDictionary(dictionary: left).isEqual(NSDictionary(dictionary: right))
|
|
}
|
|
|
|
private static func mergeExistingConfig(
|
|
_ existing: [String: Any],
|
|
overridingWith next: [String: Any]) -> [String: Any]
|
|
{
|
|
var merged = existing
|
|
for (key, value) in next {
|
|
if let nextDict = value as? [String: Any],
|
|
let existingDict = merged[key] as? [String: Any]
|
|
{
|
|
merged[key] = self.mergeExistingConfig(existingDict, overridingWith: nextDict)
|
|
} else {
|
|
merged[key] = value
|
|
}
|
|
}
|
|
return merged
|
|
}
|
|
|
|
private static func preserveGatewayAuthIfNeeded(
|
|
previousRoot: [String: Any]?,
|
|
output: inout [String: Any],
|
|
allowGatewayAuthMutation: Bool) -> Bool
|
|
{
|
|
guard !allowGatewayAuthMutation,
|
|
let previousAuth = self.gatewayAuth(previousRoot)
|
|
else {
|
|
return false
|
|
}
|
|
var gateway = output["gateway"] as? [String: Any] ?? [:]
|
|
let changed = !self.configDictionariesEqual(gateway["auth"] as? [String: Any], previousAuth)
|
|
gateway["auth"] = previousAuth
|
|
output["gateway"] = gateway
|
|
return changed
|
|
}
|
|
|
|
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 configWriteBlockingReasons(_ suspicious: [String]) -> [String] {
|
|
suspicious.filter { reason in
|
|
reason.hasPrefix("size-drop:") || reason == "gateway-mode-removed"
|
|
}
|
|
}
|
|
|
|
private static func readConfigHealthState() -> [String: Any] {
|
|
self.configHealthState
|
|
}
|
|
|
|
private static func writeConfigHealthState(_ root: [String: Any]) {
|
|
self.configHealthState = root
|
|
}
|
|
|
|
private static func configHealthEntry(state: [String: Any], configPath: String) -> [String: Any] {
|
|
let entries = state["entries"] as? [String: Any]
|
|
return entries?[configPath] as? [String: Any] ?? [:]
|
|
}
|
|
|
|
private static func setConfigHealthEntry(
|
|
state: [String: Any],
|
|
configPath: String,
|
|
entry: [String: Any]) -> [String: Any]
|
|
{
|
|
var next = state
|
|
var entries = next["entries"] as? [String: Any] ?? [:]
|
|
entries[configPath] = entry
|
|
next["entries"] = entries
|
|
return next
|
|
}
|
|
|
|
private static func isUpdateChannelOnlyRoot(_ root: [String: Any]) -> Bool {
|
|
let keys = Array(root.keys)
|
|
guard keys.count == 1, keys.first == "update" else { return false }
|
|
guard let update = root["update"] as? [String: Any] else { return false }
|
|
let updateKeys = Array(update.keys)
|
|
return updateKeys.count == 1 && update["channel"] is String
|
|
}
|
|
|
|
private static func fileTimestampMs(_ value: Any?) -> Double? {
|
|
guard let date = value as? Date else { return nil }
|
|
return date.timeIntervalSince1970 * 1000
|
|
}
|
|
|
|
private static func fileAttributeInt(_ value: Any?) -> Int? {
|
|
if let number = value as? NSNumber { return number.intValue }
|
|
if let number = value as? Int { return number }
|
|
return nil
|
|
}
|
|
|
|
private static func fileSystemNumber(_ value: Any?) -> String? {
|
|
if let number = value as? NSNumber { return number.stringValue }
|
|
if let number = value as? Int { return String(number) }
|
|
return nil
|
|
}
|
|
|
|
private static func posixMode(_ value: Any?) -> Int? {
|
|
guard let mode = self.fileAttributeInt(value) else { return nil }
|
|
return mode & 0o777
|
|
}
|
|
|
|
private static func configFingerprint(
|
|
data: Data,
|
|
root: [String: Any]?,
|
|
configURL: URL,
|
|
observedAt: String) -> [String: Any]
|
|
{
|
|
let attributes = try? FileManager().attributesOfItem(atPath: configURL.path)
|
|
return [
|
|
"hash": SHA256.hash(data: data).compactMap { String(format: "%02x", $0) }.joined(),
|
|
"bytes": data.count,
|
|
"mtimeMs": self.fileTimestampMs(attributes?[.modificationDate]) ?? NSNull(),
|
|
"ctimeMs": self.fileTimestampMs(attributes?[.creationDate]) ?? NSNull(),
|
|
"dev": self.fileSystemNumber(attributes?[.systemNumber]) ?? NSNull(),
|
|
"ino": self.fileSystemNumber(attributes?[.systemFileNumber]) ?? NSNull(),
|
|
"mode": self.posixMode(attributes?[.posixPermissions]) ?? NSNull(),
|
|
"nlink": self.fileAttributeInt(attributes?[.referenceCount]) ?? NSNull(),
|
|
"uid": self.fileAttributeInt(attributes?[.ownerAccountID]) ?? NSNull(),
|
|
"gid": self.fileAttributeInt(attributes?[.groupOwnerAccountID]) ?? NSNull(),
|
|
"hasMeta": self.hasMeta(root),
|
|
"gatewayMode": self.gatewayMode(root) ?? NSNull(),
|
|
"observedAt": observedAt,
|
|
]
|
|
}
|
|
|
|
private static func sameFingerprint(_ left: [String: Any]?, _ right: [String: Any]) -> Bool {
|
|
guard let left else { return false }
|
|
return (left["hash"] as? String) == (right["hash"] as? String) &&
|
|
(left["bytes"] as? Int) == (right["bytes"] as? Int) &&
|
|
(left["mtimeMs"] as? Double) == (right["mtimeMs"] as? Double) &&
|
|
(left["ctimeMs"] as? Double) == (right["ctimeMs"] as? Double) &&
|
|
(left["dev"] as? String) == (right["dev"] as? String) &&
|
|
(left["ino"] as? String) == (right["ino"] as? String) &&
|
|
(left["mode"] as? Int) == (right["mode"] as? Int) &&
|
|
(left["nlink"] as? Int) == (right["nlink"] as? Int) &&
|
|
(left["uid"] as? Int) == (right["uid"] as? Int) &&
|
|
(left["gid"] as? Int) == (right["gid"] as? Int) &&
|
|
(left["hasMeta"] as? Bool) == (right["hasMeta"] as? Bool) &&
|
|
(left["gatewayMode"] as? String) == (right["gatewayMode"] as? String)
|
|
}
|
|
|
|
private static func observeSuspiciousReasons(
|
|
root: [String: Any]?,
|
|
bytes: Int,
|
|
lastKnownGood: [String: Any]?) -> [String]
|
|
{
|
|
guard let lastKnownGood else { return [] }
|
|
var reasons: [String] = []
|
|
if let previousBytes = lastKnownGood["bytes"] as? Int,
|
|
previousBytes >= 512,
|
|
bytes < max(1, previousBytes / 2)
|
|
{
|
|
reasons.append("size-drop-vs-last-good:\(previousBytes)->\(bytes)")
|
|
}
|
|
if (lastKnownGood["hasMeta"] as? Bool) == true, !self.hasMeta(root) {
|
|
reasons.append("missing-meta-vs-last-good")
|
|
}
|
|
if (lastKnownGood["gatewayMode"] as? String) != nil, self.gatewayMode(root) == nil {
|
|
reasons.append("gateway-mode-missing-vs-last-good")
|
|
}
|
|
if let root, (lastKnownGood["gatewayMode"] as? String) != nil, self.isUpdateChannelOnlyRoot(root) {
|
|
reasons.append("update-channel-only-root")
|
|
}
|
|
return reasons
|
|
}
|
|
|
|
private static func configTimestampToken(_ timestamp: String) -> String {
|
|
timestamp.replacingOccurrences(of: ":", with: "-")
|
|
.replacingOccurrences(of: ".", with: "-")
|
|
}
|
|
|
|
private static func persistClobberedSnapshot(data: Data, configURL: URL, observedAt: String) -> String? {
|
|
let url = configURL.deletingLastPathComponent()
|
|
.appendingPathComponent("\(configURL.lastPathComponent).clobbered.\(self.configTimestampToken(observedAt))")
|
|
guard !FileManager().fileExists(atPath: url.path) else { return url.path }
|
|
do {
|
|
try data.write(to: url, options: [])
|
|
return url.path
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private static func persistRejectedConfigWrite(data: Data, configURL: URL) -> String? {
|
|
let timestamp = ISO8601DateFormatter().string(from: Date())
|
|
let url = configURL.deletingLastPathComponent()
|
|
.appendingPathComponent("\(configURL.lastPathComponent).rejected.\(self.configTimestampToken(timestamp))")
|
|
let fileManager = FileManager()
|
|
let privatePermissions: NSNumber = 0o600
|
|
if fileManager.fileExists(atPath: url.path) {
|
|
try? fileManager.setAttributes([.posixPermissions: privatePermissions], ofItemAtPath: url.path)
|
|
return url.path
|
|
}
|
|
guard fileManager.createFile(
|
|
atPath: url.path,
|
|
contents: data,
|
|
attributes: [.posixPermissions: privatePermissions])
|
|
else {
|
|
return nil
|
|
}
|
|
return url.path
|
|
}
|
|
|
|
private static func observeConfigRead(data: Data, root: [String: Any]?, configURL: URL, valid: Bool) {
|
|
let observedAt = ISO8601DateFormatter().string(from: Date())
|
|
let current = self.configFingerprint(data: data, root: root, configURL: configURL, observedAt: observedAt)
|
|
var state = self.readConfigHealthState()
|
|
let entry = self.configHealthEntry(state: state, configPath: configURL.path)
|
|
let lastKnownGood = entry["lastKnownGood"] as? [String: Any]
|
|
let suspicious = self.observeSuspiciousReasons(
|
|
root: root,
|
|
bytes: current["bytes"] as? Int ?? 0,
|
|
lastKnownGood: lastKnownGood)
|
|
|
|
if suspicious.isEmpty {
|
|
guard valid else { return }
|
|
let nextEntry: [String: Any] = [
|
|
"lastKnownGood": current,
|
|
"lastObservedSuspiciousSignature": NSNull(),
|
|
]
|
|
if !self.sameFingerprint(lastKnownGood, current) || entry["lastObservedSuspiciousSignature"] != nil {
|
|
state = self.setConfigHealthEntry(state: state, configPath: configURL.path, entry: nextEntry)
|
|
self.writeConfigHealthState(state)
|
|
}
|
|
return
|
|
}
|
|
|
|
let signature = "\((current["hash"] as? String) ?? ""):\(suspicious.joined(separator: ","))"
|
|
if (entry["lastObservedSuspiciousSignature"] as? String) == signature {
|
|
return
|
|
}
|
|
|
|
_ = self.persistClobberedSnapshot(
|
|
data: data,
|
|
configURL: configURL,
|
|
observedAt: observedAt)
|
|
self.logger.warning("config observe anomaly (\(suspicious.joined(separator: ", "))) at \(configURL.path)")
|
|
var nextEntry = entry
|
|
nextEntry["lastObservedSuspiciousSignature"] = signature
|
|
state = self.setConfigHealthEntry(state: state, configPath: configURL.path, entry: nextEntry)
|
|
self.writeConfigHealthState(state)
|
|
}
|
|
}
|