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
//
// AgentsAPI.swift
// AT-IRC
//
// The socialweb market request flow from the app, following the page
// (scripts/browser/request_vm.js and vmshell.js): one EphemeralInfra per app
// session, one ComputeContract entry per launch with spec.agent, requester
// RFP/accept/receipt, leased atirc token, Release through the infra entry.
//
// Everything goes through the embedded kcp (AtircgoKCPRequest); the app
// never talks to bidders directly and never writes a ComputeContract.
//
import AtircGo
import Foundation
/// One ComputeContract from kcp, reduced to what the Agents list shows.
struct AgentContract: Identifiable, Decodable, Sendable {
struct Metadata: Decodable, Sendable {
let name: String
let labels: [String: String]?
}
struct Spec: Decodable, Sendable {
struct Agent: Decodable, Sendable {}
struct SecretsRef: Decodable, Sendable {
let mount: String?
let path: String?
}
let vmName: String?
let agent: Agent?
let secretsRef: SecretsRef?
let releaseRequestedAt: String?
}
struct Status: Decodable, Sendable {
let phase: String?
}
let metadata: Metadata
let spec: Spec?
let status: Status?
var id: String { metadata.name }
var phase: String { status?.phase ?? "Pending" }
var isAgent: Bool { spec?.agent != nil }
/// The EphemeralInfra entry name (`short`) the infra controller maps to id.
var shortName: String {
metadata.labels?["infra.socialweb.computer/contract"] ?? metadata.name
}
}
private struct AgentContractList: Decodable {
let items: [AgentContract]
}
/// Coarse MarketIdentity state for the Agents UI. The sign-in URL is a
/// short-lived authorization URL; never log it.
struct MarketIdentityState: Sendable {
let phase: String
let pendingURL: URL?
let pendingReason: String?
}
struct AgentsAPI: Sendable {
private static let apiPath = "/apis/socialweb.computer/v1alpha1"
private static let sessionInfraName = "atirc-ios"
private static let agentEnvFile = "/etc/atirc/atirc.env"
/// Same field manager as the page. The slash is percent-encoded in the
/// query; kcp parses the encoded form.
private static let fieldManager = "socialweb.computer%2Fpage"
/// The page's spec fields, applied whole; other composers keep theirs.
private static let pageSpecFields = ["description", "ttlSeconds", "endWhen", "record", "contracts", "secrets"]
private let kcp = KCPClient()
// MARK: - Reads
/// Whether this kcp serves the socialweb API at all. False until the
/// socialweb requester is embedded in the app (plan 0038 I4).
func socialwebAvailable() async throws -> Bool {
do {
// The requester applies its provider manifests during Start; the
// APIBinding and CRDs can take a few seconds to be served.
_ = try await kcp.getRetryingNotFound("/apis/socialweb.computer", attempts: 8, delay: .milliseconds(250))
return true
} catch let error as KCPError where error.isNotFound {
return false
}
}
func contracts() async throws -> [AgentContract] {
let response = try await kcp.getRetryingNotFound("\(Self.apiPath)/computecontracts", attempts: 20, delay: .milliseconds(500))
guard let list = try? JSONDecoder().decode(AgentContractList.self, from: response.data) else {
throw KCPError.malformedResponse
}
return list.items
}
/// Bump the requester's identity generation so the controller issues a
/// fresh MarketIdentity authorization URL (the page does the same merge
/// patch before opening its sign-in popup).
func patchRequesterIdentity(_ patch: Data) async throws {
_ = try await kcp.request(
"PATCH",
"\(Self.apiPath)/marketrequesters/app",
body: patch,
contentType: "application/merge-patch+json"
)
}
/// The embedded requester's MarketIdentity state. It is composed a few
/// seconds after Start, so transient 404s are retried.
func marketIdentity() async throws -> MarketIdentityState {
let response = try await kcp.getRetryingNotFound(
"\(Self.apiPath)/marketidentities/requester",
attempts: 20,
delay: .milliseconds(500)
)
guard let object = response.json as? [String: Any] else {
throw KCPError.malformedResponse
}
let status = object["status"] as? [String: Any] ?? [:]
let phase = status["phase"] as? String ?? "Unknown"
var pendingURL: URL?
var pendingReason: String?
if let pending = status["pendingAction"] as? [String: Any] {
pendingReason = pending["reason"] as? String
if let raw = pending["url"] as? String, !raw.isEmpty {
pendingURL = URL(string: raw)
}
}
return MarketIdentityState(phase: phase, pendingURL: pendingURL, pendingReason: pendingReason)
}
func sessionInfra() async throws -> [String: Any] {
let response = try await kcp.get("\(Self.apiPath)/ephemeralinfras/\(Self.sessionInfraName)")
guard let object = response.json as? [String: Any] else {
throw KCPError.malformedResponse
}
return object
}
// MARK: - Writes
/// Create the app session's EphemeralInfra when it is absent. Like the
/// page, it holds every launch so the requester tracks them together.
/// Without a MarketIdentity record binding it stays local to kcp.
func ensureSessionInfra() async throws {
do {
_ = try await sessionInfra()
return
} catch let error as KCPError where error.isNotFound {
// Create below.
}
let manifest: [String: Any] = [
"apiVersion": "socialweb.computer/v1alpha1",
"kind": "EphemeralInfra",
"metadata": ["name": Self.sessionInfraName],
"spec": [
"description": "AT-IRC app session",
"ttlSeconds": 30 * 24 * 3600,
"endWhen": "Deadline",
"contracts": [],
],
]
let body = try JSONSerialization.data(withJSONObject: manifest)
do {
_ = try await kcp.request(
"POST",
"\(Self.apiPath)/ephemeralinfras?fieldManager=\(Self.fieldManager)",
body: body
)
} catch let error as KCPError {
if case .http(409, _) = error { return } // a concurrent launch won
throw error
}
}
/// One launch: a ComputeContract entry with spec.agent in the session
/// EphemeralInfra. The requester runs the RFP/accept/receipt flow and
/// mints a leased atirc token after the verified receipt.
///
/// Remaining for a real launch (plan 0038 I4/I7): the page writes the
/// secrets bundle (the `ATIRC_TOKEN=` placeholder) through its
/// `socialwebVaultPut` Go export before creating the contract. There is no
/// iOS path to that export yet, and the requester fails a contract whose
/// secretsRef has no bundle.
func launch() async throws {
guard try await socialwebAvailable() else {
throw KCPError.http(
status: 404,
message: "the embedded kcp has no socialweb requester yet (plan 0038 I4); agent launch is unavailable in this build"
)
}
try await ensureSessionInfra()
let short = Self.newShortName()
let name = "einfra-\(Self.sessionInfraName)-\(short)"
// The requester reads the bundle once and fails a contract whose
// secretsRef is missing, so it must exist before the contract. The
// token line is an empty placeholder the requester fills after the
// receipt; the app never holds an agent token.
try await putContractSecrets(
contract: name,
bundle: [["path": Self.agentEnvFile, "value": "ATIRC_TOKEN=\n", "mode": "0600"]]
)
try await updateSessionInfra { spec in
var contracts = spec["contracts"] as? [[String: Any]] ?? []
contracts.append([
"name": short,
"state": "Requested",
"vmName": name,
"policy": "requester-tangled-vouch",
"tunnels": [["tag": "ssh", "port": 22, "transport": "webrtc"]],
"agent": ["envFile": Self.agentEnvFile],
])
spec["contracts"] = contracts
// The bundle's ref is a sibling entry (spec.secrets), not a contract
// field: the infra controller copies it onto the composed contract.
var secrets = spec["secrets"] as? [[String: Any]] ?? []
secrets.append([
"contract": short,
"secretsRef": ["mount": "requester", "path": "contracts/\(name)/secrets"],
])
spec["secrets"] = secrets
return true
}
}
/// Release through the EphemeralInfra entry: state Released with a fresh
/// releaseRequestedAt. The infra controller copies both to the contract;
/// the requester revokes the leased atirc token and destroys the VM.
/// Send guest.agentStart for a Held contract, exactly the page's Agent
/// button: an outbound ContractEvent the requester publishes, after which
/// the guest starts atirc-agent.service and the agent joins the channel.
/// Returns the settled phase and message.
func agentStart(contractID: String) async throws -> String {
let alphabet = Array("abcdefghijklmnopqrstuvwxyz0123456789")
let suffix = String((0..<6).map { _ in alphabet.randomElement()! })
let name = "\(contractID)-agent-\(suffix)"
let body: [String: Any] = [
"apiVersion": "socialweb.computer/v1alpha1",
"kind": "ContractEvent",
"metadata": ["name": name],
"spec": [
"contractRef": ["name": contractID],
"direction": "outbound",
"nsid": "com.publicdomainrelay.temp.compute.events.guest.agentStart",
],
]
let data = try JSONSerialization.data(withJSONObject: body)
_ = try await kcp.request(
"POST",
"\(Self.apiPath)/contractevents?fieldManager=\(Self.fieldManager)",
body: data
)
// Settle like `run request agent`: Pending until the requester acts.
for _ in 0..<30 {
if let response = try? await kcp.get("\(Self.apiPath)/contractevents/\(name)"),
let status = response.json as? [String: Any],
let phase = (status["status"] as? [String: Any])?["phase"] as? String,
phase != "Pending", !phase.isEmpty {
let message = (status["status"] as? [String: Any])?["message"] as? String ?? ""
return "\(phase): \(message)"
}
try? await Task.sleep(for: .seconds(2))
}
return "Pending"
}
/// Release every EphemeralInfra entry still holding a contract. The debug
/// release-all hook uses this because the ComputeContract list can be
/// empty even while entries hold VMs.
func releaseAllHeld() async throws -> [String] {
var released: [String] = []
try await updateSessionInfra { spec in
guard var contracts = spec["contracts"] as? [[String: Any]] else {
throw KCPError.malformedResponse
}
released = []
for index in contracts.indices {
var entry = contracts[index]
if (entry["state"] as? String) == "Released" { continue }
let name = entry["name"] as? String ?? "?"
entry["state"] = "Released"
entry["releaseRequestedAt"] = Self.nextStamp(entry["releaseRequestedAt"] as? String)
contracts[index] = entry
released.append(name)
}
if released.isEmpty { return false }
spec["contracts"] = contracts
return true
}
return released
}
func release(_ contract: AgentContract) async throws {
try await updateSessionInfra { spec in
guard var contracts = spec["contracts"] as? [[String: Any]] else {
throw KCPError.malformedResponse
}
guard let index = contracts.firstIndex(where: { entry in
guard let name = entry["name"] as? String else { return false }
return name == contract.shortName || "einfra-\(Self.sessionInfraName)-\(name)" == contract.id
}) else {
throw KCPError.http(status: 404, message: "no EphemeralInfra entry for \(contract.id)")
}
var entry = contracts[index]
entry["state"] = "Released"
entry["releaseRequestedAt"] = Self.nextStamp(entry["releaseRequestedAt"] as? String)
contracts[index] = entry
spec["contracts"] = contracts
return true
}
}
// MARK: - Infra apply
/// Read the session EphemeralInfra, change its spec and apply it at the
/// read resourceVersion. The infra controller writes the object's status
/// right after the requester starts (and on every contract change), so a
/// 409 between the read and the apply is expected: read again and retry.
/// Before this, the first launch after a start could fail with 409 and
/// nothing was requested (2026-11-10, 1 launch in ~8).
private func updateSessionInfra(_ change: (inout [String: Any]) throws -> Bool) async throws {
for attempt in 1...5 {
let infra = try await sessionInfra()
guard var spec = infra["spec"] as? [String: Any] else { throw KCPError.malformedResponse }
guard try change(&spec) else { return }
do {
try await apply(spec: spec, resourceVersion: Self.resourceVersion(of: infra))
return
} catch KCPError.http(status: 409, message: _) where attempt < 5 {
try? await Task.sleep(for: .milliseconds(200 * attempt))
}
}
}
/// Write a contract's secrets bundle through the embedded requester's
/// vault (the page's socialwebVaultPut equivalent).
private func putContractSecrets(contract: String, bundle: [[String: Any]]) async throws {
let data = try JSONSerialization.data(withJSONObject: bundle)
try await Task.detached(priority: .userInitiated) {
guard let secrets = AtircgoKCPSecrets() else {
throw KCPError.malformedResponse
}
try secrets.put(contract, bundleJSON: data)
}.value
}
private func apply(spec: [String: Any], resourceVersion: String?) async throws {
var pageSpec: [String: Any] = [:]
for field in Self.pageSpecFields where spec[field] != nil {
pageSpec[field] = spec[field]
}
var metadata: [String: Any] = ["name": Self.sessionInfraName]
if let resourceVersion { metadata["resourceVersion"] = resourceVersion }
let manifest: [String: Any] = [
"apiVersion": "socialweb.computer/v1alpha1",
"kind": "EphemeralInfra",
"metadata": metadata,
"spec": pageSpec,
]
let path = "\(Self.apiPath)/ephemeralinfras/\(Self.sessionInfraName)?fieldManager=\(Self.fieldManager)&force=true"
let body = try JSONSerialization.data(withJSONObject: manifest)
_ = try await kcp.request("PATCH", path, body: body, contentType: "application/apply-patch+yaml")
}
private static func resourceVersion(of object: [String: Any]) -> String? {
(object["metadata"] as? [String: Any])?["resourceVersion"] as? String
}
private static func newShortName() -> String {
let alphabet = Array("abcdefghijklmnopqrstuvwxyz0123456789")
return String((0..<6).map { _ in alphabet.randomElement()! })
}
/// Strictly after `previous`, as the page's releaseRequestedAt does.
private static func nextStamp(_ previous: String?) -> String {
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let now = Date()
if let previous {
let parser = ISO8601DateFormatter()
parser.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
if let prior = parser.date(from: previous), prior >= now {
return formatter.string(from: prior.addingTimeInterval(0.001))
}
}
return formatter.string(from: now)
}
}