@elizaos/plugin-capacitor-bridge 2.0.0-beta.1 → 2.0.3-beta.3

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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Shaw Walters and elizaOS Contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,90 @@
1
+ # @elizaos/plugin-capacitor-bridge
2
+
3
+ Agent-side bridge that enables stock iOS and Android Eliza builds to run local GGUF inference through the device's native Capacitor llama.cpp plugin.
4
+
5
+ ## What it does
6
+
7
+ AOSP builds run llama.cpp directly inside the agent process via `bun:ffi`. Stock Capacitor builds (App Store iOS, standard Android APK) cannot do that — llama.cpp is exposed to the WebView through a native Capacitor plugin instead. This package is the agent-side half of that path:
8
+
9
+ - **Android**: accepts a loopback WebSocket from the Capacitor WebView, delegates `TEXT_SMALL`, `TEXT_LARGE`, and `TEXT_EMBEDDING` model requests to the connected device, and lets the normal elizaOS model-handler system work unchanged.
10
+ - **iOS**: runs the elizaOS runtime inside the Bun binary bundled into the iOS app and dispatches API calls in-process over native Bun host IPC (no HTTP loopback).
11
+
12
+ Both paths install a sandboxed virtual filesystem (`installMobileFsShim`) that confines all `node:fs` operations to the app's writable workspace directory, enforcing App Store and Play Store code-execution policies.
13
+
14
+ ## Capabilities added
15
+
16
+ - `TEXT_SMALL` model handler — routes to the connected Capacitor device.
17
+ - `TEXT_LARGE` model handler — routes to the connected Capacitor device.
18
+ - `TEXT_EMBEDDING` model handler — routes to the connected Capacitor device.
19
+ - Automatic GGUF model download from `elizaos/eliza-1` on HuggingFace when no local model is found (unless `ELIZA_DISABLE_MODEL_AUTO_DOWNLOAD=1`).
20
+ - WebSocket endpoint `/api/local-inference/device-bridge` for Capacitor WebView registration and inference RPC.
21
+
22
+ ## Installation
23
+
24
+ This package is used by the elizaOS agent bundle. It is not a standard elizaOS plugin and cannot be added to `character.plugins`. The agent bundle entry point imports and calls its bootstrap functions directly.
25
+
26
+ ```
27
+ @elizaos/plugin-capacitor-bridge
28
+ ```
29
+
30
+ ## Configuration
31
+
32
+ ### Android (WebSocket device bridge)
33
+
34
+ | Env var | Required | Description |
35
+ |---|---|---|
36
+ | `ELIZA_DEVICE_BRIDGE_ENABLED=1` | Yes | Enables the WebSocket bridge. |
37
+ | `ELIZA_DEVICE_PAIRING_TOKEN` | Yes | Shared secret — must match the token sent by the Capacitor WebView. |
38
+ | `ELIZA_DEVICE_BRIDGE_TOKEN` | Alias | Fallback for `ELIZA_DEVICE_PAIRING_TOKEN`. |
39
+
40
+ ### Model path (both platforms)
41
+
42
+ | Env var | Description |
43
+ |---|---|
44
+ | `ELIZA_LOCAL_CHAT_MODEL_PATH` | Absolute path to a GGUF for chat (TEXT_SMALL / TEXT_LARGE). |
45
+ | `ELIZA_LOCAL_EMBEDDING_MODEL_PATH` | Absolute path to an embedding GGUF. |
46
+ | `ELIZA_LOCAL_MODEL_PATH` | Fallback when neither slot-specific var is set. |
47
+ | `ELIZA_DISABLE_MODEL_AUTO_DOWNLOAD=1` | Disables auto-download from HuggingFace. |
48
+
49
+ If no model path is set and auto-download is enabled, the bridge downloads recommended eliza-1 GGUFs from `elizaos/eliza-1` on HuggingFace into `$ELIZA_STATE_DIR/local-inference/models/`.
50
+
51
+ ### Timeouts (all optional, default 600000 ms)
52
+
53
+ - `ELIZA_DEVICE_LOAD_TIMEOUT_MS`
54
+ - `ELIZA_DEVICE_GENERATE_TIMEOUT_MS`
55
+ - `ELIZA_DEVICE_EMBED_TIMEOUT_MS`
56
+
57
+ ## Filesystem sandbox
58
+
59
+ Both platforms install a deny-by-default `node:fs` interceptor (`installMobileFsShim`) before booting the runtime:
60
+
61
+ - All paths are resolved relative to the app's writable workspace root (`MOBILE_WORKSPACE_ROOT` on iOS, `HOME` on Android).
62
+ - Path traversal outside the root throws `EACCES`.
63
+ - System directories (`/etc`, `/usr`, `/System`, `/proc`, etc.) are blocked unconditionally.
64
+ - Writes to native binary extensions (`.so`, `.dylib`, `.node`) are blocked.
65
+ - `require()` of file paths is blocked — all code must be bundled.
66
+
67
+ ## WebSocket protocol (Android)
68
+
69
+ The Capacitor WebView connects to `ws://127.0.0.1:<port>/api/local-inference/device-bridge?token=<ELIZA_DEVICE_PAIRING_TOKEN>`.
70
+
71
+ Connection flow:
72
+ 1. WebView sends `{ type: "register", payload: { deviceId, pairingToken, capabilities, loadedPath } }`.
73
+ 2. Agent sends `{ type: "load", correlationId, modelPath, ... }` → device replies `{ type: "loadResult", correlationId, ok, loadedPath }`.
74
+ 3. Agent sends `{ type: "generate", correlationId, prompt, ... }` → device replies `{ type: "generateResult", correlationId, ok, text }`.
75
+ 4. Agent sends `{ type: "embed", correlationId, input }` → device replies `{ type: "embedResult", correlationId, ok, embedding }`.
76
+ 5. Agent sends `{ type: "formatChat", correlationId, messages }` → device replies `{ type: "formatChatResult", correlationId, ok, prompt }` (invokes native Jinja chat template).
77
+ 6. Agent pings every 15 s; device replies `{ type: "pong" }`.
78
+
79
+ Note: iOS connections are rejected with close code `4003`. iOS uses native IPC, not this WebSocket path.
80
+
81
+ ## Recommended default models
82
+
83
+ | Slot | Model ID | HuggingFace path |
84
+ |---|---|---|
85
+ | TEXT_SMALL | `eliza-1-4b` | `elizaos/eliza-1` — `bundles/4b/text/eliza-1-4b-128k.gguf` |
86
+ | TEXT_LARGE | `eliza-1-4b` | `elizaos/eliza-1` — `bundles/4b/text/eliza-1-4b-128k.gguf` |
87
+ | TEXT_EMBEDDING | `eliza-1-embedding` | `elizaos/eliza-1` — `bundles/4b/embedding/eliza-1-embedding.gguf` |
88
+
89
+ The 4B tier is the shipped mobile default for both chat slots; `eliza-1-2b` is
90
+ the smallest/entry tier (the small-phone floor) but is not a recommended default.
@@ -0,0 +1,39 @@
1
+ /**
2
+ * android-mobile-bridge.ts — Android counterpart to ios-bridge.ts.
3
+ *
4
+ * On Android, the elizaOS agent runs as a Bun child process managed by
5
+ * `ElizaAgentService`. Unlike the iOS path (which uses a stdio JSON-RPC
6
+ * bridge to a JSContext host), the Android Bun process boots the full
7
+ * elizaOS backend as an HTTP server listening on 127.0.0.1:31337.
8
+ *
9
+ * The agent bundle entry-point (`serve` / `start` command) already binds
10
+ * the server when `ELIZA_DISABLE_DIRECT_RUN` is unset. This module:
11
+ * 1. Sets Android-specific environment variables before any module import.
12
+ * 2. Installs the mobile fs sandbox shim.
13
+ * 3. Boots the elizaOS runtime via the canonical `startEliza` path.
14
+ * 4. Wires the `ELIZA_DEVICE_BRIDGE_ENABLED` inference delegation layer
15
+ * so the Capacitor WebView's llama-cpp plugin routes through the
16
+ * on-device agent over loopback.
17
+ *
18
+ * This module is imported by the agent bundle's `android-bridge` CLI command:
19
+ * `bun agent-bundle.js android-bridge`
20
+ *
21
+ * Environment variables set here mirror those set by `ElizaAgentService`:
22
+ * - ELIZA_PLATFORM=android
23
+ * - ELIZA_MOBILE_PLATFORM=android
24
+ * - ELIZA_ANDROID_LOCAL_BACKEND=1 (Android-specific backend flag)
25
+ * - ELIZA_HEADLESS=1 (no terminal UI)
26
+ * - ELIZA_API_BIND=127.0.0.1 (loopback only)
27
+ * - ELIZA_VAULT_BACKEND=file
28
+ * - ELIZA_DISABLE_VAULT_PROFILE_RESOLVER=1
29
+ * - ELIZA_DISABLE_AGENT_WALLET_BOOTSTRAP=1
30
+ * - LOG_LEVEL=error (quiet on-device)
31
+ *
32
+ * All values use the `||=` pattern so that values pre-set by the
33
+ * `ElizaAgentService` environment take precedence over these defaults.
34
+ * The service sets richer values (e.g. `ELIZA_API_TOKEN`, port, state dir)
35
+ * before spawning the bundle; this module only fills gaps for direct runs.
36
+ */
37
+ declare function runAndroidBridgeCli(): Promise<void>;
38
+
39
+ export { runAndroidBridgeCli };
@@ -0,0 +1,151 @@
1
+ import {
2
+ installMobileFsShim
3
+ } from "../chunk-E7Y447TQ.js";
4
+ import "../chunk-MLKGABMK.js";
5
+
6
+ // src/android/bridge.ts
7
+ import process from "process";
8
+ import * as nodeFs from "fs";
9
+ import nodePath from "path";
10
+ process.env.ELIZA_PLATFORM ||= "android";
11
+ process.env.ELIZA_MOBILE_PLATFORM ||= "android";
12
+ process.env.ELIZA_ANDROID_LOCAL_BACKEND ||= "1";
13
+ process.env.ELIZA_DISABLE_DIRECT_RUN ||= "1";
14
+ process.env.ELIZA_HEADLESS ||= "1";
15
+ process.env.ELIZA_API_BIND ||= "127.0.0.1";
16
+ process.env.ELIZA_VAULT_BACKEND ||= "file";
17
+ process.env.ELIZA_DISABLE_VAULT_PROFILE_RESOLVER ||= "1";
18
+ process.env.ELIZA_DISABLE_AGENT_WALLET_BOOTSTRAP ||= "1";
19
+ process.env.LOG_LEVEL ||= "error";
20
+ process.env.ELIZA_DISABLE_AUTO_BOOTSTRAP ||= "1";
21
+ process.env.ELIZA_DISABLE_TRAJECTORY_LOGGING ||= "1";
22
+ async function loadStartEliza() {
23
+ const mod = await import(
24
+ /* @vite-ignore */
25
+ "@elizaos/agent"
26
+ );
27
+ return mod.startEliza;
28
+ }
29
+ var _logPath = "";
30
+ function setupAndroidBridgeEnvironment() {
31
+ const rawHome = process.env.HOME || nodePath.dirname(
32
+ process.env.ELIZA_STATE_DIR || process.env.ELIZA_STATE_DIR || "/data/local/tmp/.eliza"
33
+ );
34
+ let canonicalHome;
35
+ try {
36
+ canonicalHome = nodeFs.realpathSync(rawHome);
37
+ } catch {
38
+ canonicalHome = rawHome;
39
+ }
40
+ if (canonicalHome !== rawHome) {
41
+ if (process.env.HOME) process.env.HOME = canonicalHome;
42
+ for (const key of [
43
+ "ELIZA_STATE_DIR",
44
+ "ELIZA_STATE_DIR",
45
+ "ELIZA_WORKSPACE_DIR",
46
+ "ELIZA_WORKSPACE_DIR",
47
+ "TMPDIR"
48
+ ]) {
49
+ const val = process.env[key];
50
+ if (val?.startsWith(rawHome)) {
51
+ process.env[key] = canonicalHome + val.slice(rawHome.length);
52
+ }
53
+ }
54
+ }
55
+ const stateDir = process.env.ELIZA_STATE_DIR || process.env.ELIZA_STATE_DIR || `${canonicalHome}/.eliza`;
56
+ installMobileFsShim(canonicalHome);
57
+ _logPath = `${stateDir}/android-bridge.log`;
58
+ try {
59
+ nodeFs.mkdirSync(stateDir, { recursive: true });
60
+ } catch {
61
+ }
62
+ _logToFile(`[android-bridge] process started, stateDir=${stateDir}`);
63
+ return stateDir;
64
+ }
65
+ function _logToFile(line) {
66
+ if (!_logPath) return;
67
+ try {
68
+ nodeFs.appendFileSync(_logPath, `${(/* @__PURE__ */ new Date()).toISOString()} ${line}
69
+ `);
70
+ } catch {
71
+ }
72
+ }
73
+ async function runAndroidBridgeCli() {
74
+ setupAndroidBridgeEnvironment();
75
+ process.on("exit", (code) => {
76
+ _logToFile(`[android-bridge] process.exit code=${code}`);
77
+ });
78
+ const _origConsoleError = console.error.bind(console);
79
+ console.error = (...args) => {
80
+ _logToFile(`[console.error] ${args.map(String).join(" ")}`);
81
+ _origConsoleError(...args);
82
+ };
83
+ const _origConsoleWarn = console.warn.bind(console);
84
+ console.warn = (...args) => {
85
+ const msg = args.map(String).join(" ");
86
+ if (msg.includes("Error") || msg.includes("error") || msg.includes("fail")) {
87
+ _logToFile(`[console.warn] ${msg}`);
88
+ }
89
+ _origConsoleWarn(...args);
90
+ };
91
+ process.on("unhandledRejection", (reason) => {
92
+ const msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
93
+ _logToFile(`[android-bridge] unhandledRejection: ${msg}`);
94
+ console.error("[android-bridge] unhandled rejection:", msg);
95
+ });
96
+ process.on("uncaughtException", (error) => {
97
+ _logToFile(
98
+ `[android-bridge] uncaughtException: ${error.stack || error.message}`
99
+ );
100
+ console.error(
101
+ "[android-bridge] uncaught exception:",
102
+ error.stack || error.message
103
+ );
104
+ });
105
+ _logToFile("[android-bridge] importing startEliza...");
106
+ const startEliza = await loadStartEliza();
107
+ _logToFile("[android-bridge] calling startEliza({ serverOnly: true })...");
108
+ const _hb = setInterval(() => {
109
+ _logToFile("[android-bridge] startEliza still running...");
110
+ }, 1e4);
111
+ let runtime;
112
+ try {
113
+ runtime = await startEliza({ serverOnly: true });
114
+ } catch (err) {
115
+ const msg = err instanceof Error ? err.stack || err.message : String(err);
116
+ _logToFile(`[android-bridge] startEliza THREW: ${msg}`);
117
+ throw err;
118
+ } finally {
119
+ clearInterval(_hb);
120
+ }
121
+ _logToFile(
122
+ `[android-bridge] startEliza returned: ${runtime ? "present" : "null"}`
123
+ );
124
+ _logToFile(
125
+ `[android-bridge] startEliza returned: runtime=${runtime ? "present" : "null"}, ELIZA_ANDROID_LOCAL_BACKEND=${process.env.ELIZA_ANDROID_LOCAL_BACKEND ?? "(unset)"}`
126
+ );
127
+ if (runtime && process.env.ELIZA_DEVICE_BRIDGE_ENABLED?.trim() === "1") {
128
+ _logToFile("[android-bridge] importing mobile-device-bridge-bootstrap\u2026");
129
+ const { ensureMobileDeviceBridgeInferenceHandlers } = await import("../mobile-device-bridge-bootstrap.js");
130
+ await ensureMobileDeviceBridgeInferenceHandlers(runtime);
131
+ try {
132
+ const { installRouterHandler } = await import("@elizaos/plugin-local-inference/runtime");
133
+ installRouterHandler(runtime, {});
134
+ _logToFile(
135
+ "[android-bridge] installed prefer-local cross-provider router"
136
+ );
137
+ } catch (err) {
138
+ _logToFile(
139
+ `[android-bridge] router install failed (local routing may defer to priority): ${err instanceof Error ? err.message : String(err)}`
140
+ );
141
+ }
142
+ }
143
+ await new Promise((resolve) => {
144
+ process.once("SIGINT", resolve);
145
+ process.once("SIGTERM", resolve);
146
+ });
147
+ _logToFile("[android-bridge] shutdown signal received, exiting.");
148
+ }
149
+ export {
150
+ runAndroidBridgeCli
151
+ };