Files
openclaw/apps/macos/Sources/OpenClaw/OpenClawConfigFile.swift
Peter Steinberger f91de52f0d refactor: move runtime state to SQLite
* 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>
2026-05-13 13:15:12 +01:00

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)
}
}