Files
wehub-resource-sync 426e9eeabd
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Waiting to run
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Waiting to run
Build Agent Image / build-and-push (push) Waiting to run
Chat shell gestures / Chat shell gesture + parity e2e (push) Waiting to run
Cloud Gateway Discord / Test (push) Waiting to run
Cloud Gateway Webhook / Test (push) Waiting to run
Cloud Tests / lint-and-types (push) Waiting to run
Cloud Tests / unit-tests (push) Waiting to run
Cloud Tests / integration-tests (push) Waiting to run
Cloud Tests / e2e-tests (push) Blocked by required conditions
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Deploy Apps Worker (Product 2) / Determine environment (push) Waiting to run
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Blocked by required conditions
Deploy Eliza Provisioning Worker / Determine environment (push) Waiting to run
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Blocked by required conditions
Dev Smoke / Classify changed paths (push) Waiting to run
Dev Smoke / bun run dev onboarding chat (push) Blocked by required conditions
Dev Smoke / Vite HMR dependency-level smoke (push) Blocked by required conditions
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Waiting to run
Publish @elizaos/example-code / check_npm (push) Waiting to run
Publish @elizaos/example-code / publish_npm (push) Blocked by required conditions
Publish @elizaos/plugin-elizacloud / verify_version (push) Waiting to run
Publish @elizaos/plugin-elizacloud / publish_npm (push) Blocked by required conditions
Sandbox Live Smoke / Sandbox live smoke (push) Waiting to run
Snap Build & Test / Build Snap (amd64) (push) Waiting to run
Snap Build & Test / Build Snap (arm64) (push) Waiting to run
supply-chain / sbom (push) Waiting to run
supply-chain / vulnerability-scan (push) Waiting to run
Build, Push & Deploy to Phala Cloud / build-and-push (push) Waiting to run
Test Packaging / Validate Packaging Configs (push) Waiting to run
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Waiting to run
Test Packaging / Pack & Test JS Tarballs (push) Waiting to run
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Waiting to run
UI Fixture E2E / ui-fixture-e2e (push) Waiting to run
UI Fixture E2E / fixture-e2e (push) Waiting to run
UI Story Gate / story-gate (push) Waiting to run
vault-ci / test (macos-latest) (push) Waiting to run
vault-ci / test (ubuntu-latest) (push) Waiting to run
vault-ci / test (windows-latest) (push) Waiting to run
vault-ci / app-core wiring tests (push) Waiting to run
verify-patches / verify patches/CHECKSUMS.sha256 (push) Waiting to run
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Waiting to run
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Waiting to run
Voice Benchmark Smoke / voice bench smoke summary (push) Blocked by required conditions
Windows CI / windows ([bun run --cwd packages/app-core test bun run --cwd packages/elizaos test bun run --cwd packages/cloud/shared test], app-and-cli) (push) Waiting to run
Windows CI / windows ([bun run --cwd packages/scenario-runner test bun run --cwd packages/vault test bun run --cwd packages/security test bun run --cwd plugins/plugin-coding-tools test], framework-packages) (push) Waiting to run
Windows CI / windows ([bun run --cwd plugins/plugin-elizacloud test bun run --cwd plugins/plugin-discord test bun run --cwd plugins/plugin-anthropic test bun run --cwd plugins/plugin-openai test bun run --cwd plugins/plugin-app-control test bun run --cwd plugins/pl… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run build --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/agent --concurrency=4 node packages/scripts/run-bash-linux-only.mjs scripts/verify-riscv64-buildpaths.sh node packages/scripts/run… (push) Waiting to run
Windows CI / windows ([node packages/scripts/run-turbo.mjs run typecheck --filter=@elizaos/core --filter=@elizaos/shared --filter=@elizaos/cloud-shared --concurrency=4 bun run --cwd packages/core test bun run --cwd packages/shared test], core-runtime, 75) (push) Waiting to run
Test Packaging / Build & Test PyPI Package (push) Waiting to run
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:43:05 +08:00

990 lines
38 KiB
Swift

import Foundation
import Capacitor
import WebKit
private let maxRequestBodyBytes = 10 * 1024 * 1024
private let maxResponseBodyBytes = 10 * 1024 * 1024
private let localAgentPort = 31337
private let localAgentIpcScheme = "eliza-local-agent"
private let localAgentIpcHost = "ipc"
private struct AgentEndpoint {
let baseURL: URL
let token: String?
}
private struct AgentHTTPResponse {
let status: Int
let statusText: String
let headers: [String: String]
let body: String
}
/// Eliza Agent Plugin — iOS bridge.
///
/// Remote/cloud modes bridge the Capacitor Agent API to an explicitly
/// configured HTTP agent endpoint, such as a local Mac dev server or a remote
/// Eliza agent. Local dev/sideload mode uses a path-only in-app identity; full
/// Bun foreground traffic goes through the ElizaBunRuntime Capacitor bridge,
/// while this plugin keeps the foreground WebView ITTP route kernel as a
/// compatibility path. It never starts an iOS local TCP listener.
@objc(AgentPlugin)
public class AgentPlugin: CAPPlugin, CAPBridgedPlugin {
public let identifier = "AgentPlugin"
public let jsName = "Agent"
public let pluginMethods: [CAPPluginMethod] = [
CAPPluginMethod(name: "start", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "stop", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getStatus", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "chat", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "getLocalAgentToken", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "request", returnType: CAPPluginReturnPromise),
CAPPluginMethod(name: "appendBootTrace", returnType: CAPPluginReturnPromise),
]
private static var conversationIdByBaseURL: [String: String] = [:]
private static var localStartedAt: Date?
private static let apiBaseConfigKeys = [
"apiBase",
"baseUrl",
"baseURL",
"agentApiBase",
"ELIZA_AGENT_API_BASE",
"ELIZA_API_BASE",
"ELIZA_IOS_API_BASE",
"ELIZA_IOS_REMOTE_API_BASE",
"ELIZA_MOBILE_API_BASE",
"VITE_ELIZA_IOS_API_BASE",
"VITE_ELIZA_MOBILE_API_BASE",
"VITE_ELIZA_IOS_API_BASE",
]
@objc func start(_ call: CAPPluginCall) {
if isLocalAgentMode(call: call) {
Self.localStartedAt = Self.localStartedAt ?? Date()
// "running" here means "local mode is active; the in-process agent
// lifecycle is renderer-owned" — it does NOT prove the ElizaBunRuntime
// engine is up. Mark the trace entry optimistic so on-device boot
// triage never mistakes this for engine readiness (#11030: the engine
// was never started while this kept reporting running).
postBootTrace(stage: "start", detail: ["mode": "local", "state": "running", "optimistic": true])
call.resolve(localAgentStatus(state: "running", error: nil))
return
}
guard let endpoint = resolveEndpoint(call: call) else {
postBootTrace(stage: "start", detail: [
"mode": "remote",
"state": "error",
"error": missingEndpointMessage(),
])
call.reject(missingEndpointMessage())
return
}
sendJSON(endpoint: endpoint, path: "/api/agent/start", method: "POST", timeoutMs: timeoutMs(from: call)) { result in
switch result {
case .success(let response):
guard self.isHTTPSuccess(response.status) else {
call.reject(self.httpErrorMessage(prefix: "Agent start failed", response: response))
return
}
let payload = self.parseJSONObject(response.body)
let statusPayload = (payload?["status"] as? JSObject) ?? payload ?? [:]
call.resolve(self.normalizedStatus(statusPayload, fallbackState: "running", endpoint: endpoint, error: nil))
case .failure(let error):
call.reject("Agent start failed: \(error.localizedDescription)")
}
}
}
@objc func stop(_ call: CAPPluginCall) {
if isLocalAgentMode(call: call) {
Self.localStartedAt = nil
call.resolve(["ok": true])
return
}
guard let endpoint = resolveEndpoint(call: call) else {
call.reject(missingEndpointMessage())
return
}
sendJSON(endpoint: endpoint, path: "/api/agent/stop", method: "POST", timeoutMs: timeoutMs(from: call)) { result in
switch result {
case .success(let response):
guard self.isHTTPSuccess(response.status) else {
call.reject(self.httpErrorMessage(prefix: "Agent stop failed", response: response))
return
}
let payload = self.parseJSONObject(response.body)
let ok = (payload?["ok"] as? Bool) ?? true
call.resolve(["ok": ok])
case .failure(let error):
call.reject("Agent stop failed: \(error.localizedDescription)")
}
}
}
@objc func getStatus(_ call: CAPPluginCall) {
if isLocalAgentMode(call: call) {
Self.localStartedAt = Self.localStartedAt ?? Date()
// Optimistic: local mode is active, but engine readiness is owned by
// ElizaBunRuntime (see its engine-* boot-trace stages). Marked so the
// boot trace never reads as "agent running" while the engine is down.
postBootTrace(stage: "get-status", detail: ["mode": "local", "state": "running", "optimistic": true])
call.resolve(localAgentStatus(state: "running", error: nil))
return
}
guard let endpoint = resolveEndpoint(call: call) else {
postBootTrace(stage: "get-status", detail: [
"mode": "remote",
"state": "error",
"error": missingEndpointMessage(),
])
call.resolve(status(state: "error", agentName: nil, port: nil, startedAt: nil, error: missingEndpointMessage()))
return
}
sendJSON(endpoint: endpoint, path: "/api/status", method: "GET", timeoutMs: timeoutMs(from: call, defaultValue: 1_500)) { result in
switch result {
case .success(let response):
guard self.isHTTPSuccess(response.status) else {
let message = self.httpErrorMessage(prefix: "Agent status unavailable", response: response)
self.postBootTrace(stage: "get-status", detail: [
"mode": "remote",
"state": "error",
"endpointHost": endpoint.baseURL.host ?? "",
"httpStatus": response.status,
"error": message,
])
call.resolve(self.status(
state: "error",
agentName: nil,
port: self.port(from: endpoint.baseURL),
startedAt: nil,
error: message
))
return
}
let payload = self.parseJSONObject(response.body) ?? [:]
call.resolve(self.normalizedStatus(payload, fallbackState: "running", endpoint: endpoint, error: nil))
case .failure(let error):
self.postBootTrace(stage: "get-status", detail: [
"mode": "remote",
"state": "error",
"endpointHost": endpoint.baseURL.host ?? "",
"error": "Agent status unavailable: \(error.localizedDescription)",
])
call.resolve(self.status(
state: "error",
agentName: nil,
port: self.port(from: endpoint.baseURL),
startedAt: nil,
error: "Agent status unavailable: \(error.localizedDescription)"
))
}
}
}
@objc func chat(_ call: CAPPluginCall) {
guard let text = call.getString("text")?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else {
call.reject("Agent.chat requires non-empty text")
return
}
if isLocalAgentMode(call: call) {
let timeout = timeoutMs(from: call)
ensureLocalConversation(timeoutMs: timeout) { conversationResult in
switch conversationResult {
case .success(let conversationId):
self.sendLocalChatMessage(conversationId: conversationId, text: text, timeoutMs: timeout, retryOnMissingConversation: true) { result in
switch result {
case .success(let payload):
call.resolve(payload)
case .failure(let error):
call.reject(error.localizedDescription)
}
}
case .failure(let error):
call.reject(error.localizedDescription)
}
}
return
}
guard let endpoint = resolveEndpoint(call: call) else {
call.reject(missingEndpointMessage())
return
}
ensureConversation(endpoint: endpoint, timeoutMs: timeoutMs(from: call)) { conversationResult in
switch conversationResult {
case .success(let conversationId):
self.sendChatMessage(endpoint: endpoint, conversationId: conversationId, text: text, timeoutMs: self.timeoutMs(from: call), retryOnMissingConversation: true) { result in
switch result {
case .success(let payload):
call.resolve(payload)
case .failure(let error):
call.reject(error.localizedDescription)
}
}
case .failure(let error):
call.reject(error.localizedDescription)
}
}
}
@objc func getLocalAgentToken(_ call: CAPPluginCall) {
if isLocalAgentMode(call: call) {
call.resolve([
"available": false,
"token": NSNull(),
])
return
}
let token = resolveEndpoint(call: call)?.token
call.resolve([
"available": token != nil,
"token": token ?? NSNull(),
])
}
@objc func request(_ call: CAPPluginCall) {
guard let path = call.getString("path")?.trimmingCharacters(in: .whitespacesAndNewlines),
isSafeLocalPath(path) else {
call.reject("Agent.request requires a local path that starts with / and is not an absolute URL")
return
}
let method = (call.getString("method") ?? "GET").trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
guard method.range(of: "^[A-Z]{1,16}$", options: .regularExpression) != nil else {
call.reject("Unsupported HTTP method")
return
}
let body = call.getString("body")
if let bodyBytes = body?.data(using: .utf8), bodyBytes.count > maxRequestBodyBytes {
call.reject("Request body is too large")
return
}
let headers = call.getObject("headers") ?? [:]
if isLocalAgentMode(call: call) {
sendLocalIttpRequest(
path: path,
method: method,
headers: headers,
body: body,
timeoutMs: timeoutMs(from: call)
) { result in
switch result {
case .success(let response):
call.resolve(self.agentHTTPResponseObject(response))
case .failure(let error):
call.reject("iOS local agent request failed: \(error.localizedDescription)")
}
}
return
}
guard let endpoint = resolveEndpoint(call: call) else {
call.reject(missingEndpointMessage())
return
}
sendJSON(
endpoint: endpoint,
path: path,
method: method,
headers: headers,
body: body,
timeoutMs: timeoutMs(from: call)
) { result in
switch result {
case .success(let response):
call.resolve([
"status": response.status,
"statusText": response.statusText,
"headers": response.headers,
"body": response.body,
])
case .failure(let error):
call.reject("Local agent request failed: \(error.localizedDescription)")
}
}
}
private func ensureLocalConversation(
timeoutMs: Int,
completion: @escaping (Result<String, Error>) -> Void
) {
let baseKey = "ios-local-ittp"
if let existing = Self.conversationIdByBaseURL[baseKey], !existing.isEmpty {
completion(.success(existing))
return
}
sendLocalIttpRequest(
path: "/api/conversations",
method: "POST",
headers: ["Content-Type": "application/json"],
body: "{\"title\":\"Quick Chat\"}",
timeoutMs: timeoutMs
) { result in
switch result {
case .success(let response):
guard self.isHTTPSuccess(response.status) else {
completion(.failure(self.pluginError(self.httpErrorMessage(prefix: "Failed to create local conversation", response: response))))
return
}
guard let payload = self.parseJSONObject(response.body),
let conversation = payload["conversation"] as? JSObject,
let id = conversation["id"] as? String,
!id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
completion(.failure(self.pluginError("Local conversation create response missing id")))
return
}
Self.conversationIdByBaseURL[baseKey] = id
completion(.success(id))
case .failure(let error):
completion(.failure(self.pluginError("Failed to create local conversation: \(error.localizedDescription)")))
}
}
}
private func sendLocalChatMessage(
conversationId: String,
text: String,
timeoutMs: Int,
retryOnMissingConversation: Bool,
completion: @escaping (Result<JSObject, Error>) -> Void
) {
let path = "/api/conversations/\(urlEncode(conversationId))/messages"
let bodyObject: JSObject = [
"text": text,
"channelType": "DM",
]
guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyObject, options: []),
let body = String(data: bodyData, encoding: .utf8) else {
completion(.failure(pluginError("Failed to encode local chat request")))
return
}
sendLocalIttpRequest(
path: path,
method: "POST",
headers: ["Content-Type": "application/json"],
body: body,
timeoutMs: timeoutMs
) { result in
switch result {
case .success(let response):
if response.status == 404 && retryOnMissingConversation {
Self.conversationIdByBaseURL.removeValue(forKey: "ios-local-ittp")
self.ensureLocalConversation(timeoutMs: timeoutMs) { nextConversation in
switch nextConversation {
case .success(let nextId):
self.sendLocalChatMessage(
conversationId: nextId,
text: text,
timeoutMs: timeoutMs,
retryOnMissingConversation: false,
completion: completion
)
case .failure(let error):
completion(.failure(error))
}
}
return
}
guard self.isHTTPSuccess(response.status) else {
completion(.failure(self.pluginError(self.httpErrorMessage(prefix: "Local chat request failed", response: response))))
return
}
let payload = self.parseJSONObject(response.body) ?? [:]
let responseText = (payload["text"] as? String) ?? ""
let agentName = (payload["agentName"] as? String) ?? "Agent"
completion(.success([
"text": responseText,
"agentName": agentName,
]))
case .failure(let error):
completion(.failure(self.pluginError("Local chat request failed: \(error.localizedDescription)")))
}
}
}
private func sendLocalIttpRequest(
path: String,
method: String,
headers: JSObject = [:],
body: String? = nil,
timeoutMs: Int,
completion: @escaping (Result<AgentHTTPResponse, Error>) -> Void
) {
guard let webView = bridge?.webView else {
completion(.success(localIttpUnavailableResponse()))
return
}
let payload: JSObject = [
"path": path,
"method": method,
"headers": headers,
"body": body ?? NSNull(),
"timeoutMs": timeoutMs,
]
let source = """
const handler = window.__ELIZA_BRIDGE__?.iosLocalAgentRequest;
if (typeof handler !== "function") {
return {
status: 503,
statusText: "Service Unavailable",
headers: { "content-type": "application/json" },
body: JSON.stringify({
ok: false,
error: "ios_ittp_handler_unavailable",
reason: "The WebView ITTP local-agent request bridge is not installed yet."
})
};
}
return await handler(options);
"""
if #available(iOS 14.0, *) {
DispatchQueue.main.async {
Task { @MainActor in
do {
let value = try await webView.callAsyncJavaScript(
source,
arguments: ["options": payload],
in: nil,
contentWorld: .page
)
completion(self.parseAgentHTTPResponse(value))
} catch {
completion(.failure(error))
}
}
}
} else {
completion(.success(localIttpUnavailableResponse()))
}
}
private func ensureConversation(
endpoint: AgentEndpoint,
timeoutMs: Int,
completion: @escaping (Result<String, Error>) -> Void
) {
let baseKey = endpoint.baseURL.absoluteString
if let existing = Self.conversationIdByBaseURL[baseKey], !existing.isEmpty {
completion(.success(existing))
return
}
sendJSON(
endpoint: endpoint,
path: "/api/conversations",
method: "POST",
headers: ["Content-Type": "application/json"],
body: "{\"title\":\"Quick Chat\"}",
timeoutMs: timeoutMs
) { result in
switch result {
case .success(let response):
guard self.isHTTPSuccess(response.status) else {
completion(.failure(self.pluginError(self.httpErrorMessage(prefix: "Failed to create conversation", response: response))))
return
}
guard let payload = self.parseJSONObject(response.body),
let conversation = payload["conversation"] as? JSObject,
let id = conversation["id"] as? String,
!id.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
completion(.failure(self.pluginError("Conversation create response missing id")))
return
}
Self.conversationIdByBaseURL[baseKey] = id
completion(.success(id))
case .failure(let error):
completion(.failure(self.pluginError("Failed to create conversation: \(error.localizedDescription)")))
}
}
}
private func sendChatMessage(
endpoint: AgentEndpoint,
conversationId: String,
text: String,
timeoutMs: Int,
retryOnMissingConversation: Bool,
completion: @escaping (Result<JSObject, Error>) -> Void
) {
let path = "/api/conversations/\(urlEncode(conversationId))/messages"
let bodyObject: JSObject = [
"text": text,
"channelType": "DM",
]
guard let bodyData = try? JSONSerialization.data(withJSONObject: bodyObject, options: []),
let body = String(data: bodyData, encoding: .utf8) else {
completion(.failure(pluginError("Failed to encode chat request")))
return
}
sendJSON(
endpoint: endpoint,
path: path,
method: "POST",
headers: ["Content-Type": "application/json"],
body: body,
timeoutMs: timeoutMs
) { result in
switch result {
case .success(let response):
if response.status == 404 && retryOnMissingConversation {
Self.conversationIdByBaseURL.removeValue(forKey: endpoint.baseURL.absoluteString)
self.ensureConversation(endpoint: endpoint, timeoutMs: timeoutMs) { nextConversation in
switch nextConversation {
case .success(let nextId):
self.sendChatMessage(
endpoint: endpoint,
conversationId: nextId,
text: text,
timeoutMs: timeoutMs,
retryOnMissingConversation: false,
completion: completion
)
case .failure(let error):
completion(.failure(error))
}
}
return
}
guard self.isHTTPSuccess(response.status) else {
completion(.failure(self.pluginError(self.httpErrorMessage(prefix: "Chat request failed", response: response))))
return
}
let payload = self.parseJSONObject(response.body) ?? [:]
let responseText = (payload["text"] as? String) ?? ""
let agentName = (payload["agentName"] as? String) ?? "Agent"
completion(.success([
"text": responseText,
"agentName": agentName,
]))
case .failure(let error):
completion(.failure(self.pluginError("Chat request failed: \(error.localizedDescription)")))
}
}
}
private func sendJSON(
endpoint: AgentEndpoint,
path: String,
method: String,
headers: JSObject = [:],
body: String? = nil,
timeoutMs: Int,
completion: @escaping (Result<AgentHTTPResponse, Error>) -> Void
) {
guard let url = URL(string: path, relativeTo: endpoint.baseURL)?.absoluteURL else {
completion(.failure(pluginError("Invalid local agent request path")))
return
}
var request = URLRequest(url: url)
request.httpMethod = method
request.timeoutInterval = TimeInterval(timeoutMs) / 1_000
request.cachePolicy = .reloadIgnoringLocalCacheData
for (key, value) in headers {
let normalizedKey = key.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedKey.isEmpty,
!isBlockedHeader(normalizedKey),
let stringValue = value as? String,
!stringValue.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else {
continue
}
request.setValue(stringValue, forHTTPHeaderField: normalizedKey)
}
if let token = endpoint.token, request.value(forHTTPHeaderField: "Authorization") == nil {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body, method != "GET", method != "HEAD" {
let bodyData = body.data(using: .utf8) ?? Data()
if bodyData.count > maxRequestBodyBytes {
completion(.failure(pluginError("Request body is too large")))
return
}
request.httpBody = bodyData
if request.value(forHTTPHeaderField: "Content-Type") == nil {
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
}
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
completion(.failure(error))
return
}
guard let httpResponse = response as? HTTPURLResponse else {
completion(.failure(self.pluginError("Agent endpoint returned a non-HTTP response")))
return
}
let responseData = data ?? Data()
if responseData.count > maxResponseBodyBytes {
completion(.failure(self.pluginError("Response body is too large")))
return
}
var responseHeaders: [String: String] = [:]
for (key, value) in httpResponse.allHeaderFields {
guard let headerKey = key as? String else { continue }
responseHeaders[headerKey.lowercased()] = String(describing: value)
}
completion(.success(AgentHTTPResponse(
status: httpResponse.statusCode,
statusText: HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode),
headers: responseHeaders,
body: String(data: responseData, encoding: .utf8) ?? ""
)))
}.resume()
}
private func resolveEndpoint(call: CAPPluginCall? = nil) -> AgentEndpoint? {
guard let rawBaseURL = readConfiguredString(
call: call,
keys: Self.apiBaseConfigKeys
) else {
return nil
}
let trimmed = rawBaseURL.trimmingCharacters(in: .whitespacesAndNewlines)
.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
guard let baseURL = URL(string: trimmed),
let scheme = baseURL.scheme?.lowercased(),
scheme == "http" || scheme == "https",
baseURL.host != nil else {
return nil
}
let token = readConfiguredString(
call: call,
keys: [
"apiToken",
"token",
"agentApiToken",
"ELIZA_AGENT_API_TOKEN",
"ELIZA_API_TOKEN",
"ELIZA_IOS_API_TOKEN",
"ELIZA_IOS_REMOTE_API_TOKEN",
"ELIZA_MOBILE_API_TOKEN",
"VITE_ELIZA_IOS_API_TOKEN",
"VITE_ELIZA_MOBILE_API_TOKEN",
"VITE_ELIZA_IOS_API_TOKEN",
]
)
return AgentEndpoint(baseURL: baseURL, token: token)
}
private func readConfiguredString(call: CAPPluginCall?, keys: [String]) -> String? {
for key in keys {
if let value = call?.getString(key)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
return value
}
}
let pluginConfig = getConfig()
for key in keys {
if let value = pluginConfig.getString(key)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
return value
}
}
for key in keys {
if let value = Bundle.main.object(forInfoDictionaryKey: key) as? String {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmed.isEmpty { return trimmed }
}
}
let environment = ProcessInfo.processInfo.environment
for key in keys {
if let value = environment[key]?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
return value
}
}
let defaults = UserDefaults.standard
for key in keys {
if let value = defaults.string(forKey: key)?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty {
return value
}
}
return nil
}
private func isLocalAgentMode(call: CAPPluginCall? = nil) -> Bool {
if let endpoint = resolveEndpoint(call: call),
isLocalAgentEndpoint(endpoint.baseURL) {
return true
}
if let rawBaseURL = readConfiguredString(
call: call,
keys: Self.apiBaseConfigKeys
), isLocalAgentIdentity(rawBaseURL) {
return true
}
guard let rawMode = readConfiguredString(
call: call,
keys: [
"mode",
"runtimeMode",
"agentRuntimeMode",
"ELIZA_IOS_RUNTIME_MODE",
"ELIZA_MOBILE_RUNTIME_MODE",
"VITE_ELIZA_IOS_RUNTIME_MODE",
"VITE_ELIZA_MOBILE_RUNTIME_MODE",
]
) else {
return false
}
switch rawMode.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
case "local", "ios-local", "sideload-local", "dev-local":
return true
default:
return false
}
}
private func isLocalAgentEndpoint(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(),
let host = url.host?.lowercased() else { return false }
if scheme == localAgentIpcScheme && host == localAgentIpcHost {
return true
}
guard scheme == "http" else { return false }
return (host == "127.0.0.1" || host == "localhost") && (url.port ?? 80) == localAgentPort
}
private func isLocalAgentIdentity(_ value: String) -> Bool {
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
guard let url = URL(string: trimmed) else { return false }
return isLocalAgentEndpoint(url)
}
private func localAgentStatus(state: String, error: String?) -> JSObject {
let startedAt = Self.localStartedAt.map { $0.timeIntervalSince1970 * 1000 }
return status(
state: state,
agentName: "Eliza",
port: nil,
startedAt: startedAt,
error: error
)
}
private func normalizedStatus(
_ payload: JSObject,
fallbackState: String,
endpoint: AgentEndpoint,
error: String?
) -> JSObject {
let state = (payload["state"] as? String) ?? fallbackState
let agentName = payload["agentName"] as? String
let startedAt = payload["startedAt"] as? Double
?? (payload["startedAt"] as? NSNumber)?.doubleValue
?? (payload["started_at"] as? NSNumber)?.doubleValue
let rawError = error ?? payload["error"] as? String
return status(
state: state,
agentName: agentName,
port: (payload["port"] as? Int)
?? (payload["port"] as? NSNumber)?.intValue
?? port(from: endpoint.baseURL),
startedAt: startedAt,
error: rawError
)
}
private func status(
state: String,
agentName: String?,
port: Int?,
startedAt: Double?,
error: String?
) -> JSObject {
return [
"state": state,
"agentName": agentName ?? NSNull(),
"port": port ?? NSNull(),
"startedAt": startedAt ?? NSNull(),
"error": error ?? NSNull(),
]
}
private func timeoutMs(from call: CAPPluginCall, defaultValue: Int = 10_000) -> Int {
let value = call.getInt("timeoutMs") ?? defaultValue
return min(120_000, max(1_000, value))
}
private func port(from url: URL) -> Int? {
if let port = url.port { return port }
if url.scheme?.lowercased() == "http" { return 80 }
if url.scheme?.lowercased() == "https" { return 443 }
return nil
}
private func isSafeLocalPath(_ path: String) -> Bool {
if !path.hasPrefix("/") || path.hasPrefix("//") { return false }
if path.range(of: "^[a-zA-Z][a-zA-Z0-9+.-]*://", options: .regularExpression) != nil {
return false
}
return true
}
private func isBlockedHeader(_ key: String) -> Bool {
switch key.lowercased() {
case "host", "connection", "content-length":
return true
default:
return false
}
}
private func isHTTPSuccess(_ status: Int) -> Bool {
return status >= 200 && status < 300
}
private func parseJSONObject(_ body: String) -> JSObject? {
guard let data = body.data(using: .utf8),
let object = try? JSONSerialization.jsonObject(with: data, options: []),
let json = object as? JSObject else {
return nil
}
return json
}
private func parseAgentHTTPResponse(_ value: Any?) -> Result<AgentHTTPResponse, Error> {
guard let payload = value as? JSObject else {
return .failure(pluginError("iOS local ITTP bridge returned a non-object response"))
}
let status = (payload["status"] as? Int)
?? (payload["status"] as? NSNumber)?.intValue
?? 500
let statusText = payload["statusText"] as? String
?? HTTPURLResponse.localizedString(forStatusCode: status)
let rawHeaders = payload["headers"] as? JSObject ?? [:]
var headers: [String: String] = [:]
for (key, value) in rawHeaders {
headers[key.lowercased()] = String(describing: value)
}
let body = payload["body"] as? String ?? ""
if let bodyBytes = body.data(using: .utf8), bodyBytes.count > maxResponseBodyBytes {
return .failure(pluginError("Response body is too large"))
}
return .success(AgentHTTPResponse(
status: status,
statusText: statusText,
headers: headers,
body: body
))
}
private func agentHTTPResponseObject(_ response: AgentHTTPResponse) -> JSObject {
var headers: JSObject = [:]
for (key, value) in response.headers {
headers[key] = value
}
return [
"status": response.status,
"statusText": response.statusText,
"headers": headers,
"body": response.body,
]
}
private func localIttpUnavailableResponse() -> AgentHTTPResponse {
let bodyObject: JSObject = [
"ok": false,
"error": "ios_ittp_handler_unavailable",
"reason": localIttpOnlyMessage(),
]
let bodyData = try? JSONSerialization.data(withJSONObject: bodyObject, options: [])
let body = bodyData.flatMap { String(data: $0, encoding: .utf8) } ?? "{\"ok\":false,\"error\":\"ios_ittp_handler_unavailable\"}"
return AgentHTTPResponse(
status: 503,
statusText: HTTPURLResponse.localizedString(forStatusCode: 503),
headers: ["content-type": "application/json"],
body: body
)
}
private func httpErrorMessage(prefix: String, response: AgentHTTPResponse) -> String {
let body = response.body.trimmingCharacters(in: .whitespacesAndNewlines)
if body.isEmpty {
return "\(prefix): HTTP \(response.status)"
}
return "\(prefix): HTTP \(response.status): \(body)"
}
private func missingEndpointMessage() -> String {
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."
}
private func localIttpOnlyMessage() -> String {
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."
}
private func urlEncode(_ value: String) -> String {
return value.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? value
}
private func pluginError(_ message: String) -> NSError {
return NSError(domain: "AgentPlugin", code: 1, userInfo: [NSLocalizedDescriptionKey: message])
}
/// Renderer-side boot-trace entries: the startup coordinator/poll in the
/// WebView appends its phases + failures into the SAME persistent trace
/// file the native side writes (Documents/eliza-boot-trace.jsonl), so an
/// unattended launch is fully reconstructable from one file. The renderer
/// pre-redacts detail values (never tokens).
@objc func appendBootTrace(_ call: CAPPluginCall) {
let stage = call.getString("stage") ?? "event"
let detail: [String: Any] = call.getObject("detail") ?? [:]
NotificationCenter.default.post(
name: Notification.Name("ElizaBootTraceAppend"),
object: nil,
userInfo: [
"source": "renderer",
"stage": stage,
"detail": detail,
]
)
call.resolve()
}
/// Boot-trace bridge: this pod cannot link against the app target, so it
/// posts the app's `ElizaBootTraceAppend` notification; the app-side
/// `ElizaStartupTrace` observer persists the entry to
/// Documents/eliza-boot-trace.jsonl. Detail values must never include
/// tokens — callers pass hosts and messages only.
private func postBootTrace(stage: String, detail: [String: Any]) {
NotificationCenter.default.post(
name: Notification.Name("ElizaBootTraceAppend"),
object: nil,
userInfo: [
"source": "agent-plugin",
"stage": stage,
"detail": detail,
]
)
}
}