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
990 lines
38 KiB
Swift
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,
|
|
]
|
|
)
|
|
}
|
|
}
|