// // KCPHost.swift // AT-IRC // // Boots the real kcp control plane from the embedded AtircGo framework and // reports its status. kcp runs entirely in-process over a net.Pipe; no // sockets are opened. // import CryptoKit import Foundation import AtircGo import Observation import OSLog import UIKit /// One in-process kcp control plane. @Observable @MainActor final class KCPHost { enum State: Equatable { case idle case starting case ready case failed(String) } private(set) var state: State = .idle /// kcp's self-reported git version, e.g. "v1.31.0". private(set) var version = "" /// Wall time from Start to ready; cold boot vs snapshot resume. private(set) var bootSeconds: TimeInterval = 0 private let log = Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "kcp") private let policySandbox = PolicySandboxHost() var statusText: String { switch state { case .idle: "kcp: idle" case .starting: "kcp: starting…" case .ready: String(format: "kcp: ready · %.1fs", bootSeconds) case .failed(let message): "kcp: failed — \(message)" } } /// Boot kcp once, restoring the saved snapshot. Safe to call repeatedly; /// later calls are no-ops. func start() async { guard state == .idle else { return } state = .starting let root = Self.rootDirectory() let began = Date() log.notice("kcp start") // iOS has no separate process for this: keep the whole boot off the // main thread at background QoS and hold a background task so the // system does not suspend the app mid-boot. let backgroundTask = UIApplication.shared.beginBackgroundTask(withName: "kcp.boot") defer { UIApplication.shared.endBackgroundTask(backgroundTask) } do { // userInitiated keeps boot off the E-cores; the GOMAXPROCS cap in // the Go bridge keeps the UI responsive while it runs. let bootVersion = try await Task.detached(priority: .userInitiated) { try Self.boot(root: root) }.value version = bootVersion bootSeconds = Date().timeIntervalSince(began) state = .ready log.notice("kcp ready in \(self.bootSeconds, format: .fixed(precision: 1))s") Self.persistSnapshot() } catch { state = .failed(error.localizedDescription) log.error("kcp failed: \(error.localizedDescription, privacy: .public)") } } /// The MarketIdentity is composed a couple of seconds after Start; wait /// briefly and log only its coarse phase (never the sign-in URL). nonisolated private static func logMarketIdentityPhase() async { for _ in 0..<15 { var error: NSError? let response = AtircgoKCPRequest( "GET", "/apis/socialweb.computer/v1alpha1/marketidentities/requester", "{}", "", &error ) if error == nil, let data = response.data(using: .utf8), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let body = envelope["bodyJSON"] as? [String: Any], let status = body["status"] as? [String: Any] { let phase = status["phase"] as? String ?? "unknown" let reason = (status["pendingAction"] as? [String: Any])?["reason"] as? String ?? "" Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "kcp") .notice("marketidentity phase=\(phase, privacy: .public) pending=\(reason, privacy: .public)") return } try? await Task.sleep(for: .seconds(1)) } } /// Debug: log the policy engine and its workflow runs so a launch test can /// tell whether a run reached the sandbox. Status only: no URLs, no secrets. nonisolated static func logPolicyRuns() async { let log = Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "kcp") func list(_ path: String) -> [[String: Any]] { var error: NSError? let response = AtircgoKCPRequest("GET", path, "{\"X-Atirc-Direct\":\"1\"}", "", &error) guard error == nil, let data = response.data(using: .utf8), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let body = envelope["bodyJSON"] as? [String: Any], let items = body["items"] as? [[String: Any]] else { return [] } return items } for engine in list("/apis/policy.socialweb.computer/v1alpha1/policyengines") { let status = engine["status"] as? [String: Any] ?? [:] let conditions = (status["conditions"] as? [[String: Any]] ?? []).map { "\($0["type"] as? String ?? "?")=\($0["status"] as? String ?? "?")/\($0["reason"] as? String ?? "?"):\(String(($0["message"] as? String ?? "").prefix(500)))" } log.notice("policyengine status phase=\(status["phase"] as? String ?? "?", privacy: .public) conditions=\(conditions.joined(separator: " | "), privacy: .public)") } let runs = list("/apis/policy.socialweb.computer/v1alpha1/policyworkflowruns") for run in runs.prefix(10) { let meta = run["metadata"] as? [String: Any] ?? [:] let status = run["status"] as? [String: Any] ?? [:] let conditions = (status["conditions"] as? [[String: Any]] ?? []).map { "\($0["type"] as? String ?? "?")=\($0["status"] as? String ?? "?")/\($0["reason"] as? String ?? "?"):\($0["message"] as? String ?? "")" } let joined = String(conditions.joined(separator: " | ").prefix(700)) let annotations = status["annotations"] as? [String: Any] ?? [:] let errorText = String(String(describing: annotations["error"] ?? "").prefix(700)) let console = String((status["consoleOutput"] as? String ?? "").prefix(400)) let outputs = String(String(describing: status["outputs"] ?? "").prefix(300)) log.notice("policyrun name=\(meta["name"] as? String ?? "?", privacy: .public) phase=\(status["phase"] as? String ?? "?", privacy: .public) start=\(status["startTime"] as? String ?? "-", privacy: .public) done=\(status["completionTime"] as? String ?? "-", privacy: .public) conditions=\(joined, privacy: .public) error=\(errorText, privacy: .public) outputs=\(outputs, privacy: .public) console=\(console, privacy: .public)") } log.notice("policyrun count=\(runs.count, privacy: .public)") } /// Debug: log the Workspace app phase/initializers and its LogicalCluster /// conditions, so a stalled requester compose says why. No URLs or secrets. nonisolated static func logWorkspaceState() async { let log = Logger(subsystem: "com.fedfork.irc.AT-IRC", category: "kcp") let headers = "{\"X-Atirc-Direct\":\"1\"}" func object(_ path: String) -> [String: Any]? { var error: NSError? let response = AtircgoKCPRequest("GET", path, headers, "", &error) guard error == nil, let data = response.data(using: .utf8), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let body = envelope["bodyJSON"] as? [String: Any] else { return nil } return body } if let workspace = object("/apis/tenancy.kcp.io/v1alpha1/workspaces/app") { let status = workspace["status"] as? [String: Any] ?? [:] let initializers = (status["initializers"] as? [Any] ?? []).map { String(describing: $0) } let phase = status["phase"] as? String ?? "?" log.notice("workspace app phase=\(phase, privacy: .public) initializers=\(initializers.joined(separator: ","), privacy: .public)") } if let cluster = object("/clusters/root:app/apis/core.kcp.io/v1alpha1/logicalclusters/cluster") { let status = cluster["status"] as? [String: Any] ?? [:] let conditions = (status["conditions"] as? [[String: Any]] ?? []).map { "\($0["type"] as? String ?? "?")=\($0["status"] as? String ?? "?")/\($0["reason"] as? String ?? "?"):\(String(($0["message"] as? String ?? "").prefix(300)))" } log.notice("logicalcluster root:app conditions=\(conditions.joined(separator: " | "), privacy: .public)") } } func stop() { AtircgoKCPStop() state = .idle version = "" } /// Start the socialweb market requester on the running kcp, with the /// signed-in identity. The requester is a second Go component; keep it off /// the main thread like the boot. func startRequester(configJSON: String) async { guard state == .ready else { return } log.notice("requester start") let root = Self.rootDirectory() // Resume the requester's vault (OAuth session, MarketIdentity state). var config = (try? JSONSerialization.jsonObject(with: Data(configJSON.utf8)) as? [String: Any]) ?? [:] let restore = Self.savedRequesterSnapshot(root: root) if !restore.isEmpty { config["restore"] = restore } // EphemeralInfra + ComputeContracts are merged inside the embedded // BeforeRequester hook, once the socialweb schemas exist and before // the requester controllers start (no orphan race). let infraRestore = Self.savedInfraSnapshot(root: root) if !infraRestore.isEmpty { config["infraRestoreB64"] = infraRestore } // The provider APIExport identities keep the bound-resource key hash // stable across boots, so the restored contracts stay visible. Key // material: it only travels from the sealed file back into Start. let identities = Self.savedIdentities(root: root) if !identities.isEmpty { config["providerIdentitiesB64"] = identities } let resolved: String if !config.isEmpty, let data = try? JSONSerialization.data(withJSONObject: config) { resolved = String(decoding: data, as: UTF8.self) } else { resolved = configJSON } do { try await Task.detached(priority: .background) { var error: NSError? _ = AtircgoKCPRequesterStart(resolved, self.policySandbox, &error) if let error { throw error } }.value log.notice("requester ready") // A contract's receipt and state change after the launch; persist // them as they change, so a killed app can still release its VMs // on the next launch (an unsaved receipt orphans the VM: no bidder // timer ends a plain requester's VM). Unchanged state is not rewritten. Task.detached(priority: .utility) { while AtircgoKCPRequesterReady() { Self.persistSnapshot() try? await Task.sleep(for: .seconds(5)) } } var infraError: NSError? let infra = AtircgoKCPRequest( "GET", "/apis/socialweb.computer/v1alpha1/ephemeralinfras/atirc-ios", "{\"X-Atirc-Direct\":\"1\"}", "", &infraError ) log.notice("infra restore visible=\(infraError == nil, privacy: .public) bytes=\(infra.utf8.count, privacy: .public)") await Self.logPolicyRuns() // The PolicyEngine is created a beat after the requester's // components; ask once more so the fence verdict is in the log. try? await Task.sleep(for: .seconds(2)) await Self.logPolicyRuns() await Self.logMarketIdentityPhase() var engineError: NSError? let engines = AtircgoKCPRequest( "GET", "/apis/policy.socialweb.computer/v1alpha1/policyengines", "{\"X-Atirc-Direct\":\"1\"}", "", &engineError ) if engineError == nil, let data = engines.data(using: .utf8), let envelope = try? JSONSerialization.jsonObject(with: data) as? [String: Any], let list = envelope["bodyJSON"] as? [String: Any], let items = list["items"] as? [[String: Any]], let first = items.first, let state = first["status"] as? [String: Any] { let phase = state["phase"] as? String ?? "?" let message = state["message"] as? String ?? "" log.notice("policyengine phase=\(phase, privacy: .public) message=\(message.prefix(600), privacy: .public)") } } catch { log.error("requester failed: \(error.localizedDescription, privacy: .public)") } } /// Save the full memory:// state so the next launch can resume. Called on /// ready and when the app goes to the background. The snapshot carries /// OAuth sessions, DPoP keys and atirc tokens in clear JSON, so it is /// sealed with AES-GCM under a Keychain-only key, written with complete /// file protection and excluded from backups. nonisolated static func persistSnapshot() { guard AtircgoKCPReady() else { return } let root = rootDirectory() let requesterRunning = AtircgoKCPRequesterReady() Task.detached(priority: .utility) { guard let key = snapshotKey() else { return } // Once the requester composed its CRDs, the kcp object snapshot // grows to ~10 MB and restoring it makes the apiserver rebuild // every informer (40s+). Keep the small pre-requester snapshot // instead; the requester's own vault snapshot carries identity. if !requesterRunning { var error: NSError? let snapshot = AtircgoKCPSnapshot(&error) if error == nil, !snapshot.isEmpty { writeSealed(snapshot, to: snapshotPath(root: root), key: key) } } if requesterRunning { var requesterError: NSError? let requesterSnapshot = AtircgoKCPRequesterSnapshot(&requesterError) if requesterError == nil, !requesterSnapshot.isEmpty { writeSealed(requesterSnapshot, to: requesterSnapshotPath(root: root), key: key) } // The session EphemeralInfra and its contracts are a few KB; // keep them so a Release can finish after a restart. var infraError: NSError? let infraSnapshot = AtircgoKCPInfraSnapshot(&infraError) if infraError == nil, !infraSnapshot.isEmpty { writeSealed(infraSnapshot, to: infraSnapshotPath(root: root), key: key) } // Provider APIExport identity secrets: key material that keeps // bound-resource keys stable across boots. Sealed like the rest. var identitiesError: NSError? let identities = AtircgoKCPProviderIdentities(&identitiesError) if identitiesError == nil, !identities.isEmpty { writeSealed(identities, to: identitiesPath(root: root), key: key) } } } } /// Seal UTF-8 text with AES-GCM, then write it with complete file /// protection and excluded from backups. nonisolated(unsafe) private static var lastWritten: [String: Int] = [:] private static let lastWrittenLock = NSLock() nonisolated private static func writeSealed(_ text: String, to path: String, key: SymmetricKey) { let unchanged = lastWrittenLock.withLock { if lastWritten[path] == text.hashValue { return true } lastWritten[path] = text.hashValue return false } if unchanged { return } guard let plaintext = text.data(using: .utf8), let sealed = try? AES.GCM.seal(plaintext, using: key), let combined = sealed.combined else { return } let url = URL(fileURLWithPath: path) do { try combined.write(to: url, options: [.atomic, .completeFileProtection]) var values = URLResourceValues() values.isExcludedFromBackup = true var mutable = url try? mutable.setResourceValues(values) } catch { // Persistence is best effort; kcp keeps running. Retry next time. lastWrittenLock.withLock { lastWritten[path] = nil } } } nonisolated private static func boot(root: String) throws -> String { let saved = Self.savedSnapshot(root: root) var startError: NSError? var status = AtircgoKCPStart(root, saved, &startError) if startError != nil, !saved.isEmpty { // Fail closed on a saved state this build cannot read: drop it and // boot a fresh control plane instead of refusing to start. try? FileManager.default.removeItem(atPath: Self.snapshotPath(root: root)) startError = nil status = AtircgoKCPStart(root, "", &startError) } if let startError { throw startError } _ = status var requestError: NSError? let response = AtircgoKCPRequest("GET", "/version", "", "", &requestError) if let requestError { throw requestError } return version(from: response) } nonisolated private static func rootDirectory() -> String { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0] let root = base.appendingPathComponent("kcp", isDirectory: true) try? FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) return root.path } nonisolated private static func snapshotPath(root: String) -> String { URL(fileURLWithPath: root, isDirectory: true) .appendingPathComponent("snapshot.b64") .path } nonisolated private static func requesterSnapshotPath(root: String) -> String { URL(fileURLWithPath: root, isDirectory: true) .appendingPathComponent("socialweb.b64") .path } nonisolated private static func infraSnapshotPath(root: String) -> String { URL(fileURLWithPath: root, isDirectory: true) .appendingPathComponent("infra.b64") .path } nonisolated private static func identitiesPath(root: String) -> String { URL(fileURLWithPath: root, isDirectory: true) .appendingPathComponent("identities.b64") .path } nonisolated private static func savedIdentities(root: String) -> String { guard let key = snapshotKey(), let sealed = try? Data(contentsOf: URL(fileURLWithPath: identitiesPath(root: root))), let box = try? AES.GCM.SealedBox(combined: sealed), let plaintext = try? AES.GCM.open(box, using: key), let text = String(data: plaintext, encoding: .utf8) else { return "" } return text } nonisolated private static func savedInfraSnapshot(root: String) -> String { guard let key = snapshotKey(), let sealed = try? Data(contentsOf: URL(fileURLWithPath: infraSnapshotPath(root: root))), let box = try? AES.GCM.SealedBox(combined: sealed), let plaintext = try? AES.GCM.open(box, using: key), let text = String(data: plaintext, encoding: .utf8) else { return "" } return text } nonisolated private static func savedRequesterSnapshot(root: String) -> String { guard let key = snapshotKey(), let sealed = try? Data(contentsOf: URL(fileURLWithPath: requesterSnapshotPath(root: root))), let box = try? AES.GCM.SealedBox(combined: sealed), let plaintext = try? AES.GCM.open(box, using: key), let text = String(data: plaintext, encoding: .utf8) else { return "" } return text } /// The snapshot sealing key lives only in the Keychain, device-bound and /// unavailable while the device is locked. nonisolated private static func snapshotKey() -> SymmetricKey? { let account = "kcp.snapshot.key.v1" if let data = Keychain.get(account), data.count == 32 { return SymmetricKey(data: data) } let key = SymmetricKey(size: .bits256) let data = key.withUnsafeBytes { Data($0) } Keychain.set(data, for: account, accessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly) guard let stored = Keychain.get(account), stored == data else { return nil } return key } nonisolated private static func savedSnapshot(root: String) -> String { let path = snapshotPath(root: root) // A snapshot that grew past ~3 MB holds the requester's CRDs; restoring // it is slower than a cold boot that lets the requester re-apply its // provider sets. Drop it once (the requester vault snapshot remains). if let size = (try? FileManager.default.attributesOfItem(atPath: path)[.size] as? NSNumber)?.intValue, size > 3_000_000 { try? FileManager.default.removeItem(atPath: path) return "" } guard let key = snapshotKey(), let sealed = try? Data(contentsOf: URL(fileURLWithPath: path)), let box = try? AES.GCM.SealedBox(combined: sealed), let plaintext = try? AES.GCM.open(box, using: key), let text = String(data: plaintext, encoding: .utf8) else { // Fail closed: unreadable/plaintext-from-an-older-build state is // dropped rather than booted over. try? FileManager.default.removeItem(atPath: path) return "" } return text } /// Pull gitVersion out of the JSON response KCPRequest returns. Unstamped /// builds report the placeholder "v0.0.0-master+$Format:%H$"; hide it. nonisolated private static func version(from response: String) -> String { struct Envelope: Decodable { let bodyJSON: Body? } struct Body: Decodable { let gitVersion: String? } guard let data = response.data(using: .utf8), let envelope = try? JSONDecoder().decode(Envelope.self, from: data), let gitVersion = envelope.bodyJSON?.gitVersion, !gitVersion.isEmpty, !gitVersion.contains("$Format") else { return "" } return gitVersion } }