1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
//
// 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
}
}