@elizaos/capacitor-agent 1.0.0 → 2.0.11-beta.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,917 @@
1
+ import Foundation
2
+ import Capacitor
3
+ import WebKit
4
+
5
+ private let maxRequestBodyBytes = 10 * 1024 * 1024
6
+ private let maxResponseBodyBytes = 10 * 1024 * 1024
7
+ private let localAgentPort = 31337
8
+ private let localAgentIpcScheme = "eliza-local-agent"
9
+ private let localAgentIpcHost = "ipc"
10
+
11
+ private struct AgentEndpoint {
12
+ let baseURL: URL
13
+ let token: String?
14
+ }
15
+
16
+ private struct AgentHTTPResponse {
17
+ let status: Int
18
+ let statusText: String
19
+ let headers: [String: String]
20
+ let body: String
21
+ }
22
+
23
+ /// Eliza Agent Plugin — iOS bridge.
24
+ ///
25
+ /// Remote/cloud modes bridge the Capacitor Agent API to an explicitly
26
+ /// configured HTTP agent endpoint, such as a local Mac dev server or a remote
27
+ /// Eliza agent. Local dev/sideload mode uses a path-only in-app identity; full
28
+ /// Bun foreground traffic goes through the ElizaBunRuntime Capacitor bridge,
29
+ /// while this plugin keeps the foreground WebView ITTP route kernel as a
30
+ /// compatibility path. It never starts an iOS local TCP listener.
31
+ @objc(AgentPlugin)
32
+ public class AgentPlugin: CAPPlugin, CAPBridgedPlugin {
33
+ public let identifier = "AgentPlugin"
34
+ public let jsName = "Agent"
35
+ public let pluginMethods: [CAPPluginMethod] = [
36
+ CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
37
+ CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
38
+ CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
39
+ CAPPluginMethod(name: "chat", returnType: CAPPluginReturnPromise),
40
+ CAPPluginMethod(name: "getLocalAgentToken", returnType: CAPPluginReturnPromise),
41
+ CAPPluginMethod(name: "request", returnType: CAPPluginReturnPromise),
42
+ ]
43
+
44
+ private static var conversationIdByBaseURL: [String: String] = [:]
45
+ private static var localStartedAt: Date?
46
+ private static let apiBaseConfigKeys = [
47
+ "apiBase",
48
+ "baseUrl",
49
+ "baseURL",
50
+ "agentApiBase",
51
+ "ELIZA_AGENT_API_BASE",
52
+ "ELIZA_API_BASE",
53
+ "ELIZA_IOS_API_BASE",
54
+ "ELIZA_IOS_REMOTE_API_BASE",
55
+ "ELIZA_MOBILE_API_BASE",
56
+ "VITE_ELIZA_IOS_API_BASE",
57
+ "VITE_ELIZA_MOBILE_API_BASE",
58
+ "VITE_ELIZA_IOS_API_BASE",
59
+ ]
60
+
61
+ @objc func start(_ call: CAPPluginCall) {
62
+ if isLocalAgentMode(call: call) {
63
+ Self.localStartedAt = Self.localStartedAt ?? Date()
64
+ call.resolve(localAgentStatus(state: "running", error: nil))
65
+ return
66
+ }
67
+
68
+ guard let endpoint = resolveEndpoint(call: call) else {
69
+ call.reject(missingEndpointMessage())
70
+ return
71
+ }
72
+
73
+ sendJSON(endpoint: endpoint, path: "/api/agent/start", method: "POST", timeoutMs: timeoutMs(from: call)) { result in
74
+ switch result {
75
+ case .success(let response):
76
+ guard self.isHTTPSuccess(response.status) else {
77
+ call.reject(self.httpErrorMessage(prefix: "Agent start failed", response: response))
78
+ return
79
+ }
80
+ let payload = self.parseJSONObject(response.body)
81
+ let statusPayload = (payload?["status"] as? JSObject) ?? payload ?? [:]
82
+ call.resolve(self.normalizedStatus(statusPayload, fallbackState: "running", endpoint: endpoint, error: nil))
83
+ case .failure(let error):
84
+ call.reject("Agent start failed: \(error.localizedDescription)")
85
+ }
86
+ }
87
+ }
88
+
89
+ @objc func stop(_ call: CAPPluginCall) {
90
+ if isLocalAgentMode(call: call) {
91
+ Self.localStartedAt = nil
92
+ call.resolve(["ok": true])
93
+ return
94
+ }
95
+
96
+ guard let endpoint = resolveEndpoint(call: call) else {
97
+ call.reject(missingEndpointMessage())
98
+ return
99
+ }
100
+
101
+ sendJSON(endpoint: endpoint, path: "/api/agent/stop", method: "POST", timeoutMs: timeoutMs(from: call)) { result in
102
+ switch result {
103
+ case .success(let response):
104
+ guard self.isHTTPSuccess(response.status) else {
105
+ call.reject(self.httpErrorMessage(prefix: "Agent stop failed", response: response))
106
+ return
107
+ }
108
+ let payload = self.parseJSONObject(response.body)
109
+ let ok = (payload?["ok"] as? Bool) ?? true
110
+ call.resolve(["ok": ok])
111
+ case .failure(let error):
112
+ call.reject("Agent stop failed: \(error.localizedDescription)")
113
+ }
114
+ }
115
+ }
116
+
117
+ @objc func getStatus(_ call: CAPPluginCall) {
118
+ if isLocalAgentMode(call: call) {
119
+ Self.localStartedAt = Self.localStartedAt ?? Date()
120
+ call.resolve(localAgentStatus(state: "running", error: nil))
121
+ return
122
+ }
123
+
124
+ guard let endpoint = resolveEndpoint(call: call) else {
125
+ call.resolve(status(state: "error", agentName: nil, port: nil, startedAt: nil, error: missingEndpointMessage()))
126
+ return
127
+ }
128
+
129
+ sendJSON(endpoint: endpoint, path: "/api/status", method: "GET", timeoutMs: timeoutMs(from: call, defaultValue: 1_500)) { result in
130
+ switch result {
131
+ case .success(let response):
132
+ guard self.isHTTPSuccess(response.status) else {
133
+ call.resolve(self.status(
134
+ state: "error",
135
+ agentName: nil,
136
+ port: self.port(from: endpoint.baseURL),
137
+ startedAt: nil,
138
+ error: self.httpErrorMessage(prefix: "Agent status unavailable", response: response)
139
+ ))
140
+ return
141
+ }
142
+ let payload = self.parseJSONObject(response.body) ?? [:]
143
+ call.resolve(self.normalizedStatus(payload, fallbackState: "running", endpoint: endpoint, error: nil))
144
+ case .failure(let error):
145
+ call.resolve(self.status(
146
+ state: "error",
147
+ agentName: nil,
148
+ port: self.port(from: endpoint.baseURL),
149
+ startedAt: nil,
150
+ error: "Agent status unavailable: \(error.localizedDescription)"
151
+ ))
152
+ }
153
+ }
154
+ }
155
+
156
+ @objc func chat(_ call: CAPPluginCall) {
157
+ guard let text = call.getString("text")?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else {
158
+ call.reject("Agent.chat requires non-empty text")
159
+ return
160
+ }
161
+ if isLocalAgentMode(call: call) {
162
+ let timeout = timeoutMs(from: call)
163
+ ensureLocalConversation(timeoutMs: timeout) { conversationResult in
164
+ switch conversationResult {
165
+ case .success(let conversationId):
166
+ self.sendLocalChatMessage(conversationId: conversationId, text: text, timeoutMs: timeout, retryOnMissingConversation: true) { result in
167
+ switch result {
168
+ case .success(let payload):
169
+ call.resolve(payload)
170
+ case .failure(let error):
171
+ call.reject(error.localizedDescription)
172
+ }
173
+ }
174
+ case .failure(let error):
175
+ call.reject(error.localizedDescription)
176
+ }
177
+ }
178
+ return
179
+ }
180
+ guard let endpoint = resolveEndpoint(call: call) else {
181
+ call.reject(missingEndpointMessage())
182
+ return
183
+ }
184
+
185
+ ensureConversation(endpoint: endpoint, timeoutMs: timeoutMs(from: call)) { conversationResult in
186
+ switch conversationResult {
187
+ case .success(let conversationId):
188
+ self.sendChatMessage(endpoint: endpoint, conversationId: conversationId, text: text, timeoutMs: self.timeoutMs(from: call), retryOnMissingConversation: true) { result in
189
+ switch result {
190
+ case .success(let payload):
191
+ call.resolve(payload)
192
+ case .failure(let error):
193
+ call.reject(error.localizedDescription)
194
+ }
195
+ }
196
+ case .failure(let error):
197
+ call.reject(error.localizedDescription)
198
+ }
199
+ }
200
+ }
201
+
202
+ @objc func getLocalAgentToken(_ call: CAPPluginCall) {
203
+ if isLocalAgentMode(call: call) {
204
+ call.resolve([
205
+ "available": false,
206
+ "token": NSNull(),
207
+ ])
208
+ return
209
+ }
210
+
211
+ let token = resolveEndpoint(call: call)?.token
212
+ call.resolve([
213
+ "available": token != nil,
214
+ "token": token ?? NSNull(),
215
+ ])
216
+ }
217
+
218
+ @objc func request(_ call: CAPPluginCall) {
219
+ guard let path = call.getString("path")?.trimmingCharacters(in: .whitespacesAndNewlines),
220
+ isSafeLocalPath(path) else {
221
+ call.reject("Agent.request requires a local path that starts with / and is not an absolute URL")
222
+ return
223
+ }
224
+ let method = (call.getString("method") ?? "GET").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
225
+ guard method.range(of: "^[A-Z]{1,16}$", options: .regularExpression) != nil else {
226
+ call.reject("Unsupported HTTP method")
227
+ return
228
+ }
229
+ let body = call.getString("body")
230
+ if let bodyBytes = body?.data(using: .utf8), bodyBytes.count > maxRequestBodyBytes {
231
+ call.reject("Request body is too large")
232
+ return
233
+ }
234
+
235
+ let headers = call.getObject("headers") ?? [:]
236
+ if isLocalAgentMode(call: call) {
237
+ sendLocalIttpRequest(
238
+ path: path,
239
+ method: method,
240
+ headers: headers,
241
+ body: body,
242
+ timeoutMs: timeoutMs(from: call)
243
+ ) { result in
244
+ switch result {
245
+ case .success(let response):
246
+ call.resolve(self.agentHTTPResponseObject(response))
247
+ case .failure(let error):
248
+ call.reject("iOS local agent request failed: \(error.localizedDescription)")
249
+ }
250
+ }
251
+ return
252
+ }
253
+
254
+ guard let endpoint = resolveEndpoint(call: call) else {
255
+ call.reject(missingEndpointMessage())
256
+ return
257
+ }
258
+ sendJSON(
259
+ endpoint: endpoint,
260
+ path: path,
261
+ method: method,
262
+ headers: headers,
263
+ body: body,
264
+ timeoutMs: timeoutMs(from: call)
265
+ ) { result in
266
+ switch result {
267
+ case .success(let response):
268
+ call.resolve([
269
+ "status": response.status,
270
+ "statusText": response.statusText,
271
+ "headers": response.headers,
272
+ "body": response.body,
273
+ ])
274
+ case .failure(let error):
275
+ call.reject("Local agent request failed: \(error.localizedDescription)")
276
+ }
277
+ }
278
+ }
279
+
280
+ private func ensureLocalConversation(
281
+ timeoutMs: Int,
282
+ completion: @escaping (Result<String, Error>) -> Void
283
+ ) {
284
+ let baseKey = "ios-local-ittp"
285
+ if let existing = Self.conversationIdByBaseURL[baseKey], !existing.isEmpty {
286
+ completion(.success(existing))
287
+ return
288
+ }
289
+
290
+ sendLocalIttpRequest(
291
+ path: "/api/conversations",
292
+ method: "POST",
293
+ headers: ["Content-Type": "application/json"],
294
+ body: "{\"title\":\"Quick Chat\"}",
295
+ timeoutMs: timeoutMs
296
+ ) { result in
297
+ switch result {
298
+ case .success(let response):
299
+ guard self.isHTTPSuccess(response.status) else {
300
+ completion(.failure(self.pluginError(self.httpErrorMessage(prefix: "Failed to create local conversation", response: response))))
301
+ return
302
+ }
303
+ guard let payload = self.parseJSONObject(response.body),
304
+ let conversation = payload["conversation"] as? JSObject,
305
+ let id = conversation["id"] as? String,
306
+ !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
307
+ completion(.failure(self.pluginError("Local conversation create response missing id")))
308
+ return
309
+ }
310
+ Self.conversationIdByBaseURL[baseKey] = id
311
+ completion(.success(id))
312
+ case .failure(let error):
313
+ completion(.failure(self.pluginError("Failed to create local conversation: \(error.localizedDescription)")))
314
+ }
315
+ }
316
+ }
317
+
318
+ private func sendLocalChatMessage(
319
+ conversationId: String,
320
+ text: String,
321
+ timeoutMs: Int,
322
+ retryOnMissingConversation: Bool,
323
+ completion: @escaping (Result<JSObject, Error>) -> Void
324
+ ) {
325
+ let path = "/api/conversations/\(urlEncode(conversationId))/messages"
326
+ let bodyObject: JSObject = [
327
+ "text": text,
328
+ "channelType": "DM",
329
+ ]
330
+ guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyObject, options: []),
331
+ let body = String(data: bodyData, encoding: .utf8) else {
332
+ completion(.failure(pluginError("Failed to encode local chat request")))
333
+ return
334
+ }
335
+
336
+ sendLocalIttpRequest(
337
+ path: path,
338
+ method: "POST",
339
+ headers: ["Content-Type": "application/json"],
340
+ body: body,
341
+ timeoutMs: timeoutMs
342
+ ) { result in
343
+ switch result {
344
+ case .success(let response):
345
+ if response.status == 404 && retryOnMissingConversation {
346
+ Self.conversationIdByBaseURL.removeValue(forKey: "ios-local-ittp")
347
+ self.ensureLocalConversation(timeoutMs: timeoutMs) { nextConversation in
348
+ switch nextConversation {
349
+ case .success(let nextId):
350
+ self.sendLocalChatMessage(
351
+ conversationId: nextId,
352
+ text: text,
353
+ timeoutMs: timeoutMs,
354
+ retryOnMissingConversation: false,
355
+ completion: completion
356
+ )
357
+ case .failure(let error):
358
+ completion(.failure(error))
359
+ }
360
+ }
361
+ return
362
+ }
363
+ guard self.isHTTPSuccess(response.status) else {
364
+ completion(.failure(self.pluginError(self.httpErrorMessage(prefix: "Local chat request failed", response: response))))
365
+ return
366
+ }
367
+ let payload = self.parseJSONObject(response.body) ?? [:]
368
+ let responseText = (payload["text"] as? String) ?? ""
369
+ let agentName = (payload["agentName"] as? String) ?? "Agent"
370
+ completion(.success([
371
+ "text": responseText,
372
+ "agentName": agentName,
373
+ ]))
374
+ case .failure(let error):
375
+ completion(.failure(self.pluginError("Local chat request failed: \(error.localizedDescription)")))
376
+ }
377
+ }
378
+ }
379
+
380
+ private func sendLocalIttpRequest(
381
+ path: String,
382
+ method: String,
383
+ headers: JSObject = [:],
384
+ body: String? = nil,
385
+ timeoutMs: Int,
386
+ completion: @escaping (Result<AgentHTTPResponse, Error>) -> Void
387
+ ) {
388
+ guard let webView = bridge?.webView else {
389
+ completion(.success(localIttpUnavailableResponse()))
390
+ return
391
+ }
392
+
393
+ let payload: JSObject = [
394
+ "path": path,
395
+ "method": method,
396
+ "headers": headers,
397
+ "body": body ?? NSNull(),
398
+ "timeoutMs": timeoutMs,
399
+ ]
400
+
401
+ let source = """
402
+ const handler = window.__ELIZA_IOS_LOCAL_AGENT_REQUEST__;
403
+ if (typeof handler !== "function") {
404
+ return {
405
+ status: 503,
406
+ statusText: "Service Unavailable",
407
+ headers: { "content-type": "application/json" },
408
+ body: JSON.stringify({
409
+ ok: false,
410
+ error: "ios_ittp_handler_unavailable",
411
+ reason: "The WebView ITTP local-agent request bridge is not installed yet."
412
+ })
413
+ };
414
+ }
415
+ return await handler(options);
416
+ """
417
+
418
+ if #available(iOS 14.0, *) {
419
+ DispatchQueue.main.async {
420
+ Task { @MainActor in
421
+ do {
422
+ let value = try await webView.callAsyncJavaScript(
423
+ source,
424
+ arguments: ["options": payload],
425
+ in: nil,
426
+ contentWorld: .page
427
+ )
428
+ completion(self.parseAgentHTTPResponse(value))
429
+ } catch {
430
+ completion(.failure(error))
431
+ }
432
+ }
433
+ }
434
+ } else {
435
+ completion(.success(localIttpUnavailableResponse()))
436
+ }
437
+ }
438
+
439
+ private func ensureConversation(
440
+ endpoint: AgentEndpoint,
441
+ timeoutMs: Int,
442
+ completion: @escaping (Result<String, Error>) -> Void
443
+ ) {
444
+ let baseKey = endpoint.baseURL.absoluteString
445
+ if let existing = Self.conversationIdByBaseURL[baseKey], !existing.isEmpty {
446
+ completion(.success(existing))
447
+ return
448
+ }
449
+
450
+ sendJSON(
451
+ endpoint: endpoint,
452
+ path: "/api/conversations",
453
+ method: "POST",
454
+ headers: ["Content-Type": "application/json"],
455
+ body: "{\"title\":\"Quick Chat\"}",
456
+ timeoutMs: timeoutMs
457
+ ) { result in
458
+ switch result {
459
+ case .success(let response):
460
+ guard self.isHTTPSuccess(response.status) else {
461
+ completion(.failure(self.pluginError(self.httpErrorMessage(prefix: "Failed to create conversation", response: response))))
462
+ return
463
+ }
464
+ guard let payload = self.parseJSONObject(response.body),
465
+ let conversation = payload["conversation"] as? JSObject,
466
+ let id = conversation["id"] as? String,
467
+ !id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
468
+ completion(.failure(self.pluginError("Conversation create response missing id")))
469
+ return
470
+ }
471
+ Self.conversationIdByBaseURL[baseKey] = id
472
+ completion(.success(id))
473
+ case .failure(let error):
474
+ completion(.failure(self.pluginError("Failed to create conversation: \(error.localizedDescription)")))
475
+ }
476
+ }
477
+ }
478
+
479
+ private func sendChatMessage(
480
+ endpoint: AgentEndpoint,
481
+ conversationId: String,
482
+ text: String,
483
+ timeoutMs: Int,
484
+ retryOnMissingConversation: Bool,
485
+ completion: @escaping (Result<JSObject, Error>) -> Void
486
+ ) {
487
+ let path = "/api/conversations/\(urlEncode(conversationId))/messages"
488
+ let bodyObject: JSObject = [
489
+ "text": text,
490
+ "channelType": "DM",
491
+ ]
492
+ guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyObject, options: []),
493
+ let body = String(data: bodyData, encoding: .utf8) else {
494
+ completion(.failure(pluginError("Failed to encode chat request")))
495
+ return
496
+ }
497
+
498
+ sendJSON(
499
+ endpoint: endpoint,
500
+ path: path,
501
+ method: "POST",
502
+ headers: ["Content-Type": "application/json"],
503
+ body: body,
504
+ timeoutMs: timeoutMs
505
+ ) { result in
506
+ switch result {
507
+ case .success(let response):
508
+ if response.status == 404 && retryOnMissingConversation {
509
+ Self.conversationIdByBaseURL.removeValue(forKey: endpoint.baseURL.absoluteString)
510
+ self.ensureConversation(endpoint: endpoint, timeoutMs: timeoutMs) { nextConversation in
511
+ switch nextConversation {
512
+ case .success(let nextId):
513
+ self.sendChatMessage(
514
+ endpoint: endpoint,
515
+ conversationId: nextId,
516
+ text: text,
517
+ timeoutMs: timeoutMs,
518
+ retryOnMissingConversation: false,
519
+ completion: completion
520
+ )
521
+ case .failure(let error):
522
+ completion(.failure(error))
523
+ }
524
+ }
525
+ return
526
+ }
527
+ guard self.isHTTPSuccess(response.status) else {
528
+ completion(.failure(self.pluginError(self.httpErrorMessage(prefix: "Chat request failed", response: response))))
529
+ return
530
+ }
531
+ let payload = self.parseJSONObject(response.body) ?? [:]
532
+ let responseText = (payload["text"] as? String) ?? ""
533
+ let agentName = (payload["agentName"] as? String) ?? "Agent"
534
+ completion(.success([
535
+ "text": responseText,
536
+ "agentName": agentName,
537
+ ]))
538
+ case .failure(let error):
539
+ completion(.failure(self.pluginError("Chat request failed: \(error.localizedDescription)")))
540
+ }
541
+ }
542
+ }
543
+
544
+ private func sendJSON(
545
+ endpoint: AgentEndpoint,
546
+ path: String,
547
+ method: String,
548
+ headers: JSObject = [:],
549
+ body: String? = nil,
550
+ timeoutMs: Int,
551
+ completion: @escaping (Result<AgentHTTPResponse, Error>) -> Void
552
+ ) {
553
+ guard let url = URL(string: path, relativeTo: endpoint.baseURL)?.absoluteURL else {
554
+ completion(.failure(pluginError("Invalid local agent request path")))
555
+ return
556
+ }
557
+
558
+ var request = URLRequest(url: url)
559
+ request.httpMethod = method
560
+ request.timeoutInterval = TimeInterval(timeoutMs) / 1_000
561
+ request.cachePolicy = .reloadIgnoringLocalCacheData
562
+
563
+ for (key, value) in headers {
564
+ let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines)
565
+ guard !normalizedKey.isEmpty,
566
+ !isBlockedHeader(normalizedKey),
567
+ let stringValue = value as? String,
568
+ !stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
569
+ continue
570
+ }
571
+ request.setValue(stringValue, forHTTPHeaderField: normalizedKey)
572
+ }
573
+
574
+ if let token = endpoint.token, request.value(forHTTPHeaderField: "Authorization") == nil {
575
+ request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
576
+ }
577
+
578
+ if let body = body, method != "GET", method != "HEAD" {
579
+ let bodyData = body.data(using: .utf8) ?? Data()
580
+ if bodyData.count > maxRequestBodyBytes {
581
+ completion(.failure(pluginError("Request body is too large")))
582
+ return
583
+ }
584
+ request.httpBody = bodyData
585
+ if request.value(forHTTPHeaderField: "Content-Type") == nil {
586
+ request.setValue("application/json", forHTTPHeaderField: "Content-Type")
587
+ }
588
+ }
589
+
590
+ URLSession.shared.dataTask(with: request) { data, response, error in
591
+ if let error = error {
592
+ completion(.failure(error))
593
+ return
594
+ }
595
+ guard let httpResponse = response as? HTTPURLResponse else {
596
+ completion(.failure(self.pluginError("Agent endpoint returned a non-HTTP response")))
597
+ return
598
+ }
599
+ let responseData = data ?? Data()
600
+ if responseData.count > maxResponseBodyBytes {
601
+ completion(.failure(self.pluginError("Response body is too large")))
602
+ return
603
+ }
604
+
605
+ var responseHeaders: [String: String] = [:]
606
+ for (key, value) in httpResponse.allHeaderFields {
607
+ guard let headerKey = key as? String else { continue }
608
+ responseHeaders[headerKey.lowercased()] = String(describing: value)
609
+ }
610
+
611
+ completion(.success(AgentHTTPResponse(
612
+ status: httpResponse.statusCode,
613
+ statusText: HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode),
614
+ headers: responseHeaders,
615
+ body: String(data: responseData, encoding: .utf8) ?? ""
616
+ )))
617
+ }.resume()
618
+ }
619
+
620
+ private func resolveEndpoint(call: CAPPluginCall? = nil) -> AgentEndpoint? {
621
+ guard let rawBaseURL = readConfiguredString(
622
+ call: call,
623
+ keys: Self.apiBaseConfigKeys
624
+ ) else {
625
+ return nil
626
+ }
627
+
628
+ let trimmed = rawBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
629
+ .trimmingCharacters(in: CharacterSet(charactersIn: "/"))
630
+ guard let baseURL = URL(string: trimmed),
631
+ let scheme = baseURL.scheme?.lowercased(),
632
+ scheme == "http" || scheme == "https",
633
+ baseURL.host != nil else {
634
+ return nil
635
+ }
636
+
637
+ let token = readConfiguredString(
638
+ call: call,
639
+ keys: [
640
+ "apiToken",
641
+ "token",
642
+ "agentApiToken",
643
+ "ELIZA_AGENT_API_TOKEN",
644
+ "ELIZA_API_TOKEN",
645
+ "ELIZA_IOS_API_TOKEN",
646
+ "ELIZA_IOS_REMOTE_API_TOKEN",
647
+ "ELIZA_MOBILE_API_TOKEN",
648
+ "VITE_ELIZA_IOS_API_TOKEN",
649
+ "VITE_ELIZA_MOBILE_API_TOKEN",
650
+ "VITE_ELIZA_IOS_API_TOKEN",
651
+ ]
652
+ )
653
+
654
+ return AgentEndpoint(baseURL: baseURL, token: token)
655
+ }
656
+
657
+ private func readConfiguredString(call: CAPPluginCall?, keys: [String]) -> String? {
658
+ for key in keys {
659
+ if let value = call?.getString(key)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
660
+ return value
661
+ }
662
+ }
663
+
664
+ let pluginConfig = getConfig()
665
+ for key in keys {
666
+ if let value = pluginConfig.getString(key)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
667
+ return value
668
+ }
669
+ }
670
+
671
+ for key in keys {
672
+ if let value = Bundle.main.object(forInfoDictionaryKey: key) as? String {
673
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
674
+ if !trimmed.isEmpty { return trimmed }
675
+ }
676
+ }
677
+
678
+ let environment = ProcessInfo.processInfo.environment
679
+ for key in keys {
680
+ if let value = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
681
+ return value
682
+ }
683
+ }
684
+
685
+ let defaults = UserDefaults.standard
686
+ for key in keys {
687
+ if let value = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
688
+ return value
689
+ }
690
+ }
691
+
692
+ return nil
693
+ }
694
+
695
+ private func isLocalAgentMode(call: CAPPluginCall? = nil) -> Bool {
696
+ if let endpoint = resolveEndpoint(call: call),
697
+ isLocalAgentEndpoint(endpoint.baseURL) {
698
+ return true
699
+ }
700
+
701
+ if let rawBaseURL = readConfiguredString(
702
+ call: call,
703
+ keys: Self.apiBaseConfigKeys
704
+ ), isLocalAgentIdentity(rawBaseURL) {
705
+ return true
706
+ }
707
+
708
+ guard let rawMode = readConfiguredString(
709
+ call: call,
710
+ keys: [
711
+ "mode",
712
+ "runtimeMode",
713
+ "agentRuntimeMode",
714
+ "ELIZA_IOS_RUNTIME_MODE",
715
+ "ELIZA_MOBILE_RUNTIME_MODE",
716
+ "VITE_ELIZA_IOS_RUNTIME_MODE",
717
+ "VITE_ELIZA_MOBILE_RUNTIME_MODE",
718
+ ]
719
+ ) else {
720
+ return false
721
+ }
722
+
723
+ switch rawMode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
724
+ case "local", "ios-local", "sideload-local", "dev-local":
725
+ return true
726
+ default:
727
+ return false
728
+ }
729
+ }
730
+
731
+ private func isLocalAgentEndpoint(_ url: URL) -> Bool {
732
+ guard let scheme = url.scheme?.lowercased(),
733
+ let host = url.host?.lowercased() else { return false }
734
+ if scheme == localAgentIpcScheme && host == localAgentIpcHost {
735
+ return true
736
+ }
737
+ guard scheme == "http" else { return false }
738
+ return (host == "127.0.0.1" || host == "localhost") && (url.port ?? 80) == localAgentPort
739
+ }
740
+
741
+ private func isLocalAgentIdentity(_ value: String) -> Bool {
742
+ let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
743
+ guard let url = URL(string: trimmed) else { return false }
744
+ return isLocalAgentEndpoint(url)
745
+ }
746
+
747
+ private func localAgentStatus(state: String, error: String?) -> JSObject {
748
+ let startedAt = Self.localStartedAt.map { $0.timeIntervalSince1970 * 1000 }
749
+ return status(
750
+ state: state,
751
+ agentName: "Eliza",
752
+ port: nil,
753
+ startedAt: startedAt,
754
+ error: error
755
+ )
756
+ }
757
+
758
+ private func normalizedStatus(
759
+ _ payload: JSObject,
760
+ fallbackState: String,
761
+ endpoint: AgentEndpoint,
762
+ error: String?
763
+ ) -> JSObject {
764
+ let state = (payload["state"] as? String) ?? fallbackState
765
+ let agentName = payload["agentName"] as? String
766
+ let startedAt = payload["startedAt"] as? Double
767
+ ?? (payload["startedAt"] as? NSNumber)?.doubleValue
768
+ ?? (payload["started_at"] as? NSNumber)?.doubleValue
769
+ let rawError = error ?? payload["error"] as? String
770
+ return status(
771
+ state: state,
772
+ agentName: agentName,
773
+ port: (payload["port"] as? Int)
774
+ ?? (payload["port"] as? NSNumber)?.intValue
775
+ ?? port(from: endpoint.baseURL),
776
+ startedAt: startedAt,
777
+ error: rawError
778
+ )
779
+ }
780
+
781
+ private func status(
782
+ state: String,
783
+ agentName: String?,
784
+ port: Int?,
785
+ startedAt: Double?,
786
+ error: String?
787
+ ) -> JSObject {
788
+ return [
789
+ "state": state,
790
+ "agentName": agentName ?? NSNull(),
791
+ "port": port ?? NSNull(),
792
+ "startedAt": startedAt ?? NSNull(),
793
+ "error": error ?? NSNull(),
794
+ ]
795
+ }
796
+
797
+ private func timeoutMs(from call: CAPPluginCall, defaultValue: Int = 10_000) -> Int {
798
+ let value = call.getInt("timeoutMs") ?? defaultValue
799
+ return min(120_000, max(1_000, value))
800
+ }
801
+
802
+ private func port(from url: URL) -> Int? {
803
+ if let port = url.port { return port }
804
+ if url.scheme?.lowercased() == "http" { return 80 }
805
+ if url.scheme?.lowercased() == "https" { return 443 }
806
+ return nil
807
+ }
808
+
809
+ private func isSafeLocalPath(_ path: String) -> Bool {
810
+ if !path.hasPrefix("/") || path.hasPrefix("//") { return false }
811
+ if path.range(of: "^[a-zA-Z][a-zA-Z0-9+.-]*://", options: .regularExpression) != nil {
812
+ return false
813
+ }
814
+ return true
815
+ }
816
+
817
+ private func isBlockedHeader(_ key: String) -> Bool {
818
+ switch key.lowercased() {
819
+ case "host", "connection", "content-length":
820
+ return true
821
+ default:
822
+ return false
823
+ }
824
+ }
825
+
826
+ private func isHTTPSuccess(_ status: Int) -> Bool {
827
+ return status >= 200 && status < 300
828
+ }
829
+
830
+ private func parseJSONObject(_ body: String) -> JSObject? {
831
+ guard let data = body.data(using: .utf8),
832
+ let object = try? JSONSerialization.jsonObject(with: data, options: []),
833
+ let json = object as? JSObject else {
834
+ return nil
835
+ }
836
+ return json
837
+ }
838
+
839
+ private func parseAgentHTTPResponse(_ value: Any?) -> Result<AgentHTTPResponse, Error> {
840
+ guard let payload = value as? JSObject else {
841
+ return .failure(pluginError("iOS local ITTP bridge returned a non-object response"))
842
+ }
843
+ let status = (payload["status"] as? Int)
844
+ ?? (payload["status"] as? NSNumber)?.intValue
845
+ ?? 500
846
+ let statusText = payload["statusText"] as? String
847
+ ?? HTTPURLResponse.localizedString(forStatusCode: status)
848
+ let rawHeaders = payload["headers"] as? JSObject ?? [:]
849
+ var headers: [String: String] = [:]
850
+ for (key, value) in rawHeaders {
851
+ headers[key.lowercased()] = String(describing: value)
852
+ }
853
+ let body = payload["body"] as? String ?? ""
854
+ if let bodyBytes = body.data(using: .utf8), bodyBytes.count > maxResponseBodyBytes {
855
+ return .failure(pluginError("Response body is too large"))
856
+ }
857
+ return .success(AgentHTTPResponse(
858
+ status: status,
859
+ statusText: statusText,
860
+ headers: headers,
861
+ body: body
862
+ ))
863
+ }
864
+
865
+ private func agentHTTPResponseObject(_ response: AgentHTTPResponse) -> JSObject {
866
+ var headers: JSObject = [:]
867
+ for (key, value) in response.headers {
868
+ headers[key] = value
869
+ }
870
+ return [
871
+ "status": response.status,
872
+ "statusText": response.statusText,
873
+ "headers": headers,
874
+ "body": response.body,
875
+ ]
876
+ }
877
+
878
+ private func localIttpUnavailableResponse() -> AgentHTTPResponse {
879
+ let bodyObject: JSObject = [
880
+ "ok": false,
881
+ "error": "ios_ittp_handler_unavailable",
882
+ "reason": localIttpOnlyMessage(),
883
+ ]
884
+ let bodyData = try? JSONSerialization.data(withJSONObject: bodyObject, options: [])
885
+ let body = bodyData.flatMap { String(data: $0, encoding: .utf8) } ?? "{\"ok\":false,\"error\":\"ios_ittp_handler_unavailable\"}"
886
+ return AgentHTTPResponse(
887
+ status: 503,
888
+ statusText: HTTPURLResponse.localizedString(forStatusCode: 503),
889
+ headers: ["content-type": "application/json"],
890
+ body: body
891
+ )
892
+ }
893
+
894
+ private func httpErrorMessage(prefix: String, response: AgentHTTPResponse) -> String {
895
+ let body = response.body.trimmingCharacters(in: .whitespacesAndNewlines)
896
+ if body.isEmpty {
897
+ return "\(prefix): HTTP \(response.status)"
898
+ }
899
+ return "\(prefix): HTTP \(response.status): \(body)"
900
+ }
901
+
902
+ private func missingEndpointMessage() -> String {
903
+ return "iOS Agent requires a configured HTTP endpoint for remote/cloud mode, or runtimeMode=local for dev/sideload local mode. Set Agent.apiBase in capacitor.config, an Info.plist/UserDefaults key such as ELIZA_IOS_API_BASE or ELIZA_AGENT_API_BASE, or a simulator environment variable."
904
+ }
905
+
906
+ private func localIttpOnlyMessage() -> String {
907
+ return "iOS local agent requests require the WebView ITTP route kernel bridge. Start the app WebView before calling native Agent.request or Agent.chat in local mode."
908
+ }
909
+
910
+ private func urlEncode(_ value: String) -> String {
911
+ return value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value
912
+ }
913
+
914
+ private func pluginError(_ message: String) -> NSError {
915
+ return NSError(domain: "AgentPlugin", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
916
+ }
917
+ }