chore: import upstream snapshot with attribution
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
ci / test (push) Waiting to run
ci / lint-and-format (push) Waiting to run
ci / build (push) Waiting to run
ci / dev-startup (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
gitleaks / gitleaks (push) Waiting to run
Markdown Links / Relative Markdown Links (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
Quality (Extended) / Homepage Build (PR smoke) (push) Waiting to run
Quality (Extended) / Comment-only diff guard (push) Waiting to run
Quality (Extended) / Format + Type Safety Ratchet (push) Waiting to run
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Waiting to run
Quality (Extended) / Develop Gate (lint) (push) Waiting to run
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

This commit is contained in:
wehub-resource-sync
2026-07-13 12:43:05 +08:00
commit 426e9eeabd
41828 changed files with 9656266 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
action-benchmark-report/
+24
View File
@@ -0,0 +1,24 @@
/** Configures Storybook coverage for app-core dashboard and shell components. */
import type { StorybookConfig } from "@storybook/react-vite";
const config: StorybookConfig = {
stories: ["../src/**/*.stories.@(ts|tsx)"],
addons: [
"@storybook/addon-essentials",
"@storybook/addon-a11y",
"@storybook/addon-themes",
],
framework: {
name: "@storybook/react-vite",
options: {},
},
docs: {
autodocs: "tag",
},
viteFinal: async (config) => {
// Ensure theme CSS is available
return config;
},
};
export default config;
+51
View File
@@ -0,0 +1,51 @@
/** Configures Storybook coverage for app-core dashboard and shell components. */
import { withThemeByClassName } from "@storybook/addon-themes";
import type { Preview } from "@storybook/react";
import "../../app-core/src/styles/styles.css";
const preview: Preview = {
parameters: {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
layout: "centered",
viewport: {
options: {
mobilePortrait: {
name: "Mobile Portrait",
styles: { width: "390px", height: "844px" },
type: "mobile",
},
mobileLandscape: {
name: "Mobile Landscape",
styles: { width: "844px", height: "390px" },
type: "mobile",
},
ipadPortrait: {
name: "iPad Portrait",
styles: { width: "820px", height: "1180px" },
type: "tablet",
},
desktopWide: {
name: "Desktop Wide",
styles: { width: "1440px", height: "960px" },
type: "desktop",
},
},
},
},
decorators: [
withThemeByClassName({
themes: {
light: "",
dark: "dark",
},
defaultTheme: "light",
}),
],
};
export default preview;
+139
View File
@@ -0,0 +1,139 @@
# @elizaos/app-core
Shared application core for elizaOS agent app shells. Provides the CLI bootstrap, the dashboard HTTP API, the Eliza runtime loader, the static app/plugin/connector registry, auth/secrets/vault services, and per-platform (Node, browser, Capacitor/iOS/Android, Electrobun desktop) bootstrap. Consumed by `@elizaos/agent`, `@elizaos/ui`, `@elizaos/shared`, the `packages/app` shell, and most `plugins/*` app plugins (e.g. `plugin-registry`, `plugin-lifeops`).
Repo-wide rules (logger-only, ESM, naming, architecture commandments, git workflow) live in the root [AGENTS.md](../../AGENTS.md) — not restated here.
## Layout
```
src/
entry.ts CLI process bootstrap → dist/entry.js (imported by the generated app launcher; no `bin` field)
index.ts Node/runtime barrel (the `.` export) — re-exports api/runtime/registry/security/services
browser.ts Browser-safe re-exports (pulls UI surface from @elizaos/ui)
ui-compat.ts Legacy UI-compat shims (`./ui-compat` export)
cli/ Commander CLI
run-main.ts runCli(): env normalize, dotenv, build + parse program
program/build-program.ts buildProgram(): help + preaction hooks + commands
program/command-registry.ts registerProgramCommands(): start, setup, doctor, db, configure, config, dashboard, update, auth, benchmark, capability-router, subclis
program/register.*.ts one file per command
profile.ts, argv.ts, doctor/ profile env, arg parsing, doctor checks
api/ Dashboard HTTP API (server-side)
server.ts startApiServer() — wraps @elizaos/agent's server with app-core routes
dev-stack.ts /api/dev/stack discovery payload (ELIZA_DEV_STACK_SCHEMA)
auth.ts, auth/ route authorization
auth-bootstrap-routes.ts, auth-session-routes.ts, auth-pairing-routes.ts first-run + device pairing auth
response.ts sendJson / sendJsonError helpers
secrets-*-routes.ts, server-wallet-trade.ts, *-compat-routes.ts
dispatch/ Connector/channel dispatch layer
index.ts barrel
channel-registry.ts channel registry
connector-registry.ts connector registry
approval-queue.ts approval queue for dispatched actions
runtime/ Runtime loading + lifecycle
eliza.ts Eliza agent loader — boots AgentRuntime, loads plugins, starts API server
dev-server.ts Dev orchestration entry + startup timing
desktop/ Electrobun tray/window React runtimes (AppWindowRenderer, DesktopTrayRuntime, …)
build-character-from-config.ts, channel-plugin-map.ts, autonomy-policy.ts, sandbox-policy.ts
registry/index.ts Back-compat shim: re-exports `@elizaos/registry/first-party`.
The curated app/plugin/connector registry (schema,
loader, entries, registerCuratedApp, registerRegistryEntry)
now lives in `packages/registry/src/first-party/`.
config/app-config.ts AppConfig types + DEFAULT_APP_CONFIG (re-exported from @elizaos/shared)
first-run/ first-run-config + runtime-target resolution
security/ agent-vault-id, platform-secure-store (+ -node), wallet key hydration
services/ auth-store, steward-credentials/sidecar, vault-mirror/bootstrap, account-pool, task-host-capabilities, sensitive-requests, …
platform/ ios-runtime-*, native-plugin-entrypoints, empty-node-module (browser-build alias target), *-browser-stub.ts
permissions/types.ts, diagnostics/integration-observability.ts, connectors/ (capacitor sqlite/jsc/quickjs)
scripts/ build/packaging/sms-gateway/voice scripts (namespaced in package.json scripts)
platforms/{android,ios,electrobun}/ native shell projects + Apple Store entitlements
```
## Key exports / surface
- Default `.` import → `src/index.ts`: `startApiServer`, the Eliza runtime loader (`runtime/eliza`), `loadRegistry`/`getApps`/`getPlugins`/`getConnectors`/`getEntry`, `registerCuratedApp`, auth helpers, security stores, vault + steward services.
- Subpath exports (see `package.json` `exports`): `./entry`, `./agent-bridge`, `./api/auth`, `./api/response`, `./api/automation-node-contributors`, `./api/compat-route-shared`, `./api/cloud-pair-route`, `./api/ios-local-agent-transport`, `./registry`, `./first-run/first-run-config`, `./security/agent-vault-id`, `./security/platform-secure-store`, `./security/platform-secure-store-node`, `./services/vault-mirror`, `./services/steward-credentials`, `./services/steward-sidecar/helpers`, `./services/task-host-capabilities`, `./services/app-updates/update-policy`, `./platform/native-plugin-entrypoints`, `./platform/ios-runtime-backends`, `./platform/empty-node-module`, `./platform/native-library-policy`, `./ui-compat`.
- `src/browser.ts` is the browser-safe surface; it re-exports React/UI from `@elizaos/ui` and the desktop runtimes from `runtime/desktop`.
## Commands
Run from repo root with `--cwd packages/app-core`:
- `bun run --cwd packages/app-core build``build:dist` (tsc → flatten → copy assets → rewrite dist ESM imports)
- `bun run --cwd packages/app-core typecheck``tsgo --noEmit -p tsconfig.json`
- `bun run --cwd packages/app-core test` — vitest (config `vitest.config.ts`)
- `bun run --cwd packages/app-core test:auth` — auth/auth-bootstrap/auth-store suites, no file parallelism
- `bun run --cwd packages/app-core lint` / `lint:check` / `format` / `format:check` — Biome
- `bun run --cwd packages/app-core benchmark:server` — action benchmark harness
- SMS-gateway, flatpak, codesign, and voice scripts are namespaced (`sms-gateway:*`, `build:flatpak*`, `codesign:mas*`, `voice:*`) — see `package.json`.
## Config / env vars
- Ports: `ELIZA_API_PORT`/`ELIZA_PORT`/`ELIZA_UI_PORT` are read via `@elizaos/shared` `resolveDesktopApiPort`/`resolveServerOnlyPort`/`syncResolvedApiPort`. Never hardcode; the orchestrator shifts and syncs them.
- `LOG_LEVEL` / `--debug` / `--verbose` / `--no-color` — set in `entry.ts` before runtime imports; also drives `NODE_LLAMA_CPP_LOG_LEVEL`.
- `DATABASE_URL` → bridged to `POSTGRES_URL` for `plugin-sql` (cloud/sandbox provisioners inject `DATABASE_URL`).
- `ELIZAOS_CLOUD_API_KEY` (dev fallback `ELIZA_DEV_CLOUD_API_KEY` in non-prod).
- `ELIZA_API_PROCESS_SPAWNED_AT_MS` / `ELIZA_PROCESS_SPAWNED_AT_MS` — startup timing (dev-server).
- `/api/dev/stack` response schema tag is the `ELIZA_DEV_STACK_SCHEMA` constant (`"elizaos.dev.stack/v1"`) from `api/dev-stack.ts` — it is a code constant, not an env var. State dir via `@elizaos/core` `resolveStateDir`. Provider key aliases normalized in `run-main.ts` (`Z_AI_API_KEY``ZAI_API_KEY`, `KIMI_API_KEY``MOONSHOT_API_KEY`).
- **App-route boot knobs** (in `runtime/eliza.ts`):
- `ELIZA_SKIP_APP_ROUTE_PLUGINS` — comma-separated app-route-plugin ids/short-aliases to NOT load (`getSkippedAppRoutePluginIds`). Filters WHICH route plugins register (e.g. `lifeops,steward,training,shopify`). Empty/unset → every loader runs.
- `ELIZA_DEFER_APP_ROUTES` — controls WHETHER the post-ready boot tail (app-route plugins, training hooks, sensitive-request adapters, telegram polling, trigger bridge, connector catalog, voice warmup) blocks the readiness gate (`getDeferAppRoutesEnabled`). **Deferred by default:** `/api/health` flips `ready:true` before the tail finishes, so feature routes may 404 for a sub-second-to-few-second window after "Agent ready" — poll `/api/health` `deferredBoot.settled` (phase `app-route-tail`) before hitting them instead of sleeping. Set `ELIZA_DEFER_APP_ROUTES=0` (or `false`/`no`/`off`) to await the tail inline before ready (the pre-deferral boot shape, slower time-to-ready). Composes with `ELIZA_SKIP_APP_ROUTE_PLUGINS` (skip filters which load; defer controls when the tail blocks).
## How to extend
- **Add a CLI command:** create `src/cli/program/register.<name>.ts` exporting `register<Name>Command(program)`, then wire it into `src/cli/program/command-registry.ts`.
- **Add an API route:** add a handler module under `src/api/` and dispatch it from `src/api/server.ts` (or the relevant `*-routes.ts`). Use `sendJson` from `api/response.ts`; authorize via `api/auth.ts`.
- **Add a registry app/plugin/connector:** the curated registry moved to `@elizaos/registry/first-party`. Drop a JSON file in `packages/registry/src/first-party/entries/{apps,plugins,connectors}/` conforming to `packages/registry/src/first-party/schema.ts`, or have the plugin self-register at runtime via `registerRegistryEntry()`. For curated-app name matching, call `registerCuratedApp`. `@elizaos/app-core/registry` re-exports all of these for back-compat.
- **Add a subpath export:** add the `exports` map entry in `package.json` AND export it from the right barrel; the build emits the matching `dist/*.d.ts`/`.js`.
## Conventions / gotchas
- `src/platform/empty-node-module.ts` is a tsconfig-paths alias target for browser builds — it is intentionally NOT re-exported from `index.ts` (re-exporting would shadow the real Node `api/server` / `runtime/eliza` exports with noops). Browser bundlers alias it in; Node imports the originals.
- `index.ts` re-exports `./services/steward-sidecar.ts` with an explicit `.ts` extension to disambiguate from the sibling `steward-sidecar/` directory after `tsc --rewriteRelativeImportExtensions`.
- The registry's `var cacheSlot` TDZ-hardening + `resolveEntriesDir()` now live in `@elizaos/registry/first-party` (`packages/registry/src/first-party/index.ts`); `packages/app-core/src/registry/index.ts` is a one-line re-export shim.
- `entry.ts` builds to `dist/entry.js` and is imported by the generated app launcher (desktop/Electrobun bundling emits a tiny ESM file that `import`s `dist/entry.js`) — there is no `bin` field; do not add one assuming a downstream installer.
- `plugin-local-inference` is imported lazily in `runtime/eliza.ts` to avoid static plugin-boundary violations.
- Peer deps `react`, `react-dom`, `three`; Capacitor mobile bridges are `optionalDependencies` (`@elizaos/capacitor-*`). Node `>=24`.
- **iOS local-agent watchdog parity** (`platforms/ios/App/App/AgentWatchdog.swift`, wired from `AppDelegate`): the iOS equivalent of Android's `ElizaAgentService` watchdog (issue #10197). The iOS agent is in-process (the `ElizaBunRuntime` Capacitor plugin, no TCP port), so the watchdog polls liveness through the Capacitor bridge (`ElizaBunRuntime.getStatus().ready`) gated on `localStorage["eliza:mobile-runtime-mode"]` (dormant/no-op only in pure `cloud` mode; `local`, `cloud-hybrid`, and `tunnel-to-mobile` own a phone-side agent), accumulates 3 strikes like Android's `HEALTH_FAIL_STRIKES`, and on a confirmed crash emits a bounded restart *request* (`AgentWatchdog.restartRequestedNotification` + a `window` `eliza:local-agent-restart-requested` event, max 5 attempts/exponential backoff) for the renderer's existing `ElizaBunRuntime.start(...)` to honor — it never invents a second restart mechanism. To auto-recover end-to-end the renderer must honor that restart-request signal.
<!-- BEGIN: evidence-and-e2e-mandate (managed; canonical standard = repo-root AGENTS.md) -->
## ⛔ NON-NEGOTIABLE — evidence, trajectories & real end-to-end tests
> The binding, repo-wide standard is **[AGENTS.md](../../AGENTS.md)**. Read it.
> Nothing in this package is *done* until it is *proven* done — a reviewer must confirm it
> works **without reading the code**, from the artifacts you attach. This applies to **every**
> feature, fix, refactor, and chore here. "Tests pass" is not proof; "CI is green" is not proof.
- **Record AND read model trajectories.** Capture the *actual* inputs and outputs of the model
from a **live** LLM — not the deterministic proxy, not a mock: the prompt, the
providers/context, the raw model output, every tool/action call, and the result. Then **open
the trajectory and review it by hand.** A captured-but-unread trajectory is not evidence
(`packages/scenario-runner/bin/eliza-scenarios run <scenario> --report <out>`).
- **Real, full-featured E2E — no larp.** Every feature ships detailed end-to-end tests that
drive the *real* path end to end. Not the happy "front door" only: cover error paths,
edge/empty/invalid input, concurrency, roles/permissions, and adversarial input. A test that
asserts against a mock/stub/fixture standing in for the thing under test **does not count**.
If the real model/device/chain/connector/account is hard to reach, **make it reachable — that
is the work**, not an excuse to mock. If the existing tests here are shallow or mocked, fixing
them is part of your change.
- **Screenshots + logs at every phase**, plus a **complete walkthrough video/run-through** of
the entire feature or view, start to finish (`bun run test:e2e:record`).
- **Manually review every artifact the change touches** — never just the green check: client
logs (console + network), server logs (`[ClassName] …`), the model trajectories in and out,
before/after full-page screenshots, **and the domain artifacts listed below for this package.**
- **No residuals. No shortcuts.** The goal is not "done" — it is *everything* done. Clear every
blocker by the **hard path**: build the real architecture, stand up the real
model/device/service, actually test it. Never leave a TODO, a stub, a stepping-stone, or a
"follow-up." When unsure, research thoroughly, weigh the options, and ship the best,
highest-effort, production-ready version. Keep going until every possibility is exhausted.
Artifacts → attached inline in the PR (MP4 video, JPG screenshots, logs in `<details>`); attach each evidence type **or**
explicitly mark it N/A with a reason — never leave it blank. If `develop` moved and changed
behavior, **re-capture** evidence; stale proof is worse than none.
**Capture & manually review for this package — runtime / framework:**
- A **live-LLM** scenario trajectory for the runtime path you touched — provider → model → action → evaluator — with the raw `<response>` XML and every tool/action call visible and **read**.
- Backend `[ClassName]` logs proving the message loop, task scheduler, or service actually fired end to end.
- The memory/state artifacts produced — rows written, embeddings, room/world/entity records, scheduled-task rows — inspected, not assumed.
- For shared modules: `build:node` vs full `build` so the browser/edge bundles still compile.
<!-- END: evidence-and-e2e-mandate -->
+139
View File
@@ -0,0 +1,139 @@
# @elizaos/app-core
Shared application core for elizaOS agent app shells. Provides the CLI bootstrap, the dashboard HTTP API, the Eliza runtime loader, the static app/plugin/connector registry, auth/secrets/vault services, and per-platform (Node, browser, Capacitor/iOS/Android, Electrobun desktop) bootstrap. Consumed by `@elizaos/agent`, `@elizaos/ui`, `@elizaos/shared`, the `packages/app` shell, and most `plugins/*` app plugins (e.g. `plugin-registry`, `plugin-lifeops`).
Repo-wide rules (logger-only, ESM, naming, architecture commandments, git workflow) live in the root [AGENTS.md](../../AGENTS.md) — not restated here.
## Layout
```
src/
entry.ts CLI process bootstrap → dist/entry.js (imported by the generated app launcher; no `bin` field)
index.ts Node/runtime barrel (the `.` export) — re-exports api/runtime/registry/security/services
browser.ts Browser-safe re-exports (pulls UI surface from @elizaos/ui)
ui-compat.ts Legacy UI-compat shims (`./ui-compat` export)
cli/ Commander CLI
run-main.ts runCli(): env normalize, dotenv, build + parse program
program/build-program.ts buildProgram(): help + preaction hooks + commands
program/command-registry.ts registerProgramCommands(): start, setup, doctor, db, configure, config, dashboard, update, auth, benchmark, capability-router, subclis
program/register.*.ts one file per command
profile.ts, argv.ts, doctor/ profile env, arg parsing, doctor checks
api/ Dashboard HTTP API (server-side)
server.ts startApiServer() — wraps @elizaos/agent's server with app-core routes
dev-stack.ts /api/dev/stack discovery payload (ELIZA_DEV_STACK_SCHEMA)
auth.ts, auth/ route authorization
auth-bootstrap-routes.ts, auth-session-routes.ts, auth-pairing-routes.ts first-run + device pairing auth
response.ts sendJson / sendJsonError helpers
secrets-*-routes.ts, server-wallet-trade.ts, *-compat-routes.ts
dispatch/ Connector/channel dispatch layer
index.ts barrel
channel-registry.ts channel registry
connector-registry.ts connector registry
approval-queue.ts approval queue for dispatched actions
runtime/ Runtime loading + lifecycle
eliza.ts Eliza agent loader — boots AgentRuntime, loads plugins, starts API server
dev-server.ts Dev orchestration entry + startup timing
desktop/ Electrobun tray/window React runtimes (AppWindowRenderer, DesktopTrayRuntime, …)
build-character-from-config.ts, channel-plugin-map.ts, autonomy-policy.ts, sandbox-policy.ts
registry/index.ts Back-compat shim: re-exports `@elizaos/registry/first-party`.
The curated app/plugin/connector registry (schema,
loader, entries, registerCuratedApp, registerRegistryEntry)
now lives in `packages/registry/src/first-party/`.
config/app-config.ts AppConfig types + DEFAULT_APP_CONFIG (re-exported from @elizaos/shared)
first-run/ first-run-config + runtime-target resolution
security/ agent-vault-id, platform-secure-store (+ -node), wallet key hydration
services/ auth-store, steward-credentials/sidecar, vault-mirror/bootstrap, account-pool, task-host-capabilities, sensitive-requests, …
platform/ ios-runtime-*, native-plugin-entrypoints, empty-node-module (browser-build alias target), *-browser-stub.ts
permissions/types.ts, diagnostics/integration-observability.ts, connectors/ (capacitor sqlite/jsc/quickjs)
scripts/ build/packaging/sms-gateway/voice scripts (namespaced in package.json scripts)
platforms/{android,ios,electrobun}/ native shell projects + Apple Store entitlements
```
## Key exports / surface
- Default `.` import → `src/index.ts`: `startApiServer`, the Eliza runtime loader (`runtime/eliza`), `loadRegistry`/`getApps`/`getPlugins`/`getConnectors`/`getEntry`, `registerCuratedApp`, auth helpers, security stores, vault + steward services.
- Subpath exports (see `package.json` `exports`): `./entry`, `./agent-bridge`, `./api/auth`, `./api/response`, `./api/automation-node-contributors`, `./api/compat-route-shared`, `./api/cloud-pair-route`, `./api/ios-local-agent-transport`, `./registry`, `./first-run/first-run-config`, `./security/agent-vault-id`, `./security/platform-secure-store`, `./security/platform-secure-store-node`, `./services/vault-mirror`, `./services/steward-credentials`, `./services/steward-sidecar/helpers`, `./services/task-host-capabilities`, `./services/app-updates/update-policy`, `./platform/native-plugin-entrypoints`, `./platform/ios-runtime-backends`, `./platform/empty-node-module`, `./platform/native-library-policy`, `./ui-compat`.
- `src/browser.ts` is the browser-safe surface; it re-exports React/UI from `@elizaos/ui` and the desktop runtimes from `runtime/desktop`.
## Commands
Run from repo root with `--cwd packages/app-core`:
- `bun run --cwd packages/app-core build``build:dist` (tsc → flatten → copy assets → rewrite dist ESM imports)
- `bun run --cwd packages/app-core typecheck``tsgo --noEmit -p tsconfig.json`
- `bun run --cwd packages/app-core test` — vitest (config `vitest.config.ts`)
- `bun run --cwd packages/app-core test:auth` — auth/auth-bootstrap/auth-store suites, no file parallelism
- `bun run --cwd packages/app-core lint` / `lint:check` / `format` / `format:check` — Biome
- `bun run --cwd packages/app-core benchmark:server` — action benchmark harness
- SMS-gateway, flatpak, codesign, and voice scripts are namespaced (`sms-gateway:*`, `build:flatpak*`, `codesign:mas*`, `voice:*`) — see `package.json`.
## Config / env vars
- Ports: `ELIZA_API_PORT`/`ELIZA_PORT`/`ELIZA_UI_PORT` are read via `@elizaos/shared` `resolveDesktopApiPort`/`resolveServerOnlyPort`/`syncResolvedApiPort`. Never hardcode; the orchestrator shifts and syncs them.
- `LOG_LEVEL` / `--debug` / `--verbose` / `--no-color` — set in `entry.ts` before runtime imports; also drives `NODE_LLAMA_CPP_LOG_LEVEL`.
- `DATABASE_URL` → bridged to `POSTGRES_URL` for `plugin-sql` (cloud/sandbox provisioners inject `DATABASE_URL`).
- `ELIZAOS_CLOUD_API_KEY` (dev fallback `ELIZA_DEV_CLOUD_API_KEY` in non-prod).
- `ELIZA_API_PROCESS_SPAWNED_AT_MS` / `ELIZA_PROCESS_SPAWNED_AT_MS` — startup timing (dev-server).
- `/api/dev/stack` response schema tag is the `ELIZA_DEV_STACK_SCHEMA` constant (`"elizaos.dev.stack/v1"`) from `api/dev-stack.ts` — it is a code constant, not an env var. State dir via `@elizaos/core` `resolveStateDir`. Provider key aliases normalized in `run-main.ts` (`Z_AI_API_KEY``ZAI_API_KEY`, `KIMI_API_KEY``MOONSHOT_API_KEY`).
- **App-route boot knobs** (in `runtime/eliza.ts`):
- `ELIZA_SKIP_APP_ROUTE_PLUGINS` — comma-separated app-route-plugin ids/short-aliases to NOT load (`getSkippedAppRoutePluginIds`). Filters WHICH route plugins register (e.g. `lifeops,steward,training,shopify`). Empty/unset → every loader runs.
- `ELIZA_DEFER_APP_ROUTES` — controls WHETHER the post-ready boot tail (app-route plugins, training hooks, sensitive-request adapters, telegram polling, trigger bridge, connector catalog, voice warmup) blocks the readiness gate (`getDeferAppRoutesEnabled`). **Deferred by default:** `/api/health` flips `ready:true` before the tail finishes, so feature routes may 404 for a sub-second-to-few-second window after "Agent ready" — poll `/api/health` `deferredBoot.settled` (phase `app-route-tail`) before hitting them instead of sleeping. Set `ELIZA_DEFER_APP_ROUTES=0` (or `false`/`no`/`off`) to await the tail inline before ready (the pre-deferral boot shape, slower time-to-ready). Composes with `ELIZA_SKIP_APP_ROUTE_PLUGINS` (skip filters which load; defer controls when the tail blocks).
## How to extend
- **Add a CLI command:** create `src/cli/program/register.<name>.ts` exporting `register<Name>Command(program)`, then wire it into `src/cli/program/command-registry.ts`.
- **Add an API route:** add a handler module under `src/api/` and dispatch it from `src/api/server.ts` (or the relevant `*-routes.ts`). Use `sendJson` from `api/response.ts`; authorize via `api/auth.ts`.
- **Add a registry app/plugin/connector:** the curated registry moved to `@elizaos/registry/first-party`. Drop a JSON file in `packages/registry/src/first-party/entries/{apps,plugins,connectors}/` conforming to `packages/registry/src/first-party/schema.ts`, or have the plugin self-register at runtime via `registerRegistryEntry()`. For curated-app name matching, call `registerCuratedApp`. `@elizaos/app-core/registry` re-exports all of these for back-compat.
- **Add a subpath export:** add the `exports` map entry in `package.json` AND export it from the right barrel; the build emits the matching `dist/*.d.ts`/`.js`.
## Conventions / gotchas
- `src/platform/empty-node-module.ts` is a tsconfig-paths alias target for browser builds — it is intentionally NOT re-exported from `index.ts` (re-exporting would shadow the real Node `api/server` / `runtime/eliza` exports with noops). Browser bundlers alias it in; Node imports the originals.
- `index.ts` re-exports `./services/steward-sidecar.ts` with an explicit `.ts` extension to disambiguate from the sibling `steward-sidecar/` directory after `tsc --rewriteRelativeImportExtensions`.
- The registry's `var cacheSlot` TDZ-hardening + `resolveEntriesDir()` now live in `@elizaos/registry/first-party` (`packages/registry/src/first-party/index.ts`); `packages/app-core/src/registry/index.ts` is a one-line re-export shim.
- `entry.ts` builds to `dist/entry.js` and is imported by the generated app launcher (desktop/Electrobun bundling emits a tiny ESM file that `import`s `dist/entry.js`) — there is no `bin` field; do not add one assuming a downstream installer.
- `plugin-local-inference` is imported lazily in `runtime/eliza.ts` to avoid static plugin-boundary violations.
- Peer deps `react`, `react-dom`, `three`; Capacitor mobile bridges are `optionalDependencies` (`@elizaos/capacitor-*`). Node `>=24`.
- **iOS local-agent watchdog parity** (`platforms/ios/App/App/AgentWatchdog.swift`, wired from `AppDelegate`): the iOS equivalent of Android's `ElizaAgentService` watchdog (issue #10197). The iOS agent is in-process (the `ElizaBunRuntime` Capacitor plugin, no TCP port), so the watchdog polls liveness through the Capacitor bridge (`ElizaBunRuntime.getStatus().ready`) gated on `localStorage["eliza:mobile-runtime-mode"]` (dormant/no-op only in pure `cloud` mode; `local`, `cloud-hybrid`, and `tunnel-to-mobile` own a phone-side agent), accumulates 3 strikes like Android's `HEALTH_FAIL_STRIKES`, and on a confirmed crash emits a bounded restart *request* (`AgentWatchdog.restartRequestedNotification` + a `window` `eliza:local-agent-restart-requested` event, max 5 attempts/exponential backoff) for the renderer's existing `ElizaBunRuntime.start(...)` to honor — it never invents a second restart mechanism. To auto-recover end-to-end the renderer must honor that restart-request signal.
<!-- BEGIN: evidence-and-e2e-mandate (managed; canonical standard = repo-root AGENTS.md) -->
## ⛔ NON-NEGOTIABLE — evidence, trajectories & real end-to-end tests
> The binding, repo-wide standard is **[AGENTS.md](../../AGENTS.md)**. Read it.
> Nothing in this package is *done* until it is *proven* done — a reviewer must confirm it
> works **without reading the code**, from the artifacts you attach. This applies to **every**
> feature, fix, refactor, and chore here. "Tests pass" is not proof; "CI is green" is not proof.
- **Record AND read model trajectories.** Capture the *actual* inputs and outputs of the model
from a **live** LLM — not the deterministic proxy, not a mock: the prompt, the
providers/context, the raw model output, every tool/action call, and the result. Then **open
the trajectory and review it by hand.** A captured-but-unread trajectory is not evidence
(`packages/scenario-runner/bin/eliza-scenarios run <scenario> --report <out>`).
- **Real, full-featured E2E — no larp.** Every feature ships detailed end-to-end tests that
drive the *real* path end to end. Not the happy "front door" only: cover error paths,
edge/empty/invalid input, concurrency, roles/permissions, and adversarial input. A test that
asserts against a mock/stub/fixture standing in for the thing under test **does not count**.
If the real model/device/chain/connector/account is hard to reach, **make it reachable — that
is the work**, not an excuse to mock. If the existing tests here are shallow or mocked, fixing
them is part of your change.
- **Screenshots + logs at every phase**, plus a **complete walkthrough video/run-through** of
the entire feature or view, start to finish (`bun run test:e2e:record`).
- **Manually review every artifact the change touches** — never just the green check: client
logs (console + network), server logs (`[ClassName] …`), the model trajectories in and out,
before/after full-page screenshots, **and the domain artifacts listed below for this package.**
- **No residuals. No shortcuts.** The goal is not "done" — it is *everything* done. Clear every
blocker by the **hard path**: build the real architecture, stand up the real
model/device/service, actually test it. Never leave a TODO, a stub, a stepping-stone, or a
"follow-up." When unsure, research thoroughly, weigh the options, and ship the best,
highest-effort, production-ready version. Keep going until every possibility is exhausted.
Artifacts → attached inline in the PR (MP4 video, JPG screenshots, logs in `<details>`); attach each evidence type **or**
explicitly mark it N/A with a reason — never leave it blank. If `develop` moved and changed
behavior, **re-capture** evidence; stale proof is worse than none.
**Capture & manually review for this package — runtime / framework:**
- A **live-LLM** scenario trajectory for the runtime path you touched — provider → model → action → evaluator — with the raw `<response>` XML and every tool/action call visible and **read**.
- Backend `[ClassName]` logs proving the message loop, task scheduler, or service actually fired end to end.
- The memory/state artifacts produced — rows written, embeddings, room/world/entity records, scheduled-task rows — inspected, not assumed.
- For shared modules: `build:node` vs full `build` so the browser/edge bundles still compile.
<!-- END: evidence-and-e2e-mandate -->
+42
View File
@@ -0,0 +1,42 @@
# `@elizaos/app-core`
Shared application core for elizaOS agent app shells (desktop, mobile, web). It bundles the pieces every shell needs: the CLI bootstrap, the dashboard HTTP API, the Eliza runtime loader, the static app/plugin/connector registry, auth/secrets/vault services, and per-platform bootstrap.
## What's in here
| Subdir | Contains |
| --------------- | -------------------------------------------------------------------------------------------- |
| `src/entry.ts` | CLI process bootstrap (built to `dist/entry.js`, imported by the generated app launcher). |
| `src/cli/` | Commander CLI: `start`, `setup`, `doctor`, `db`, `config`, `dashboard`, `update`, `auth`, … |
| `src/api/` | Dashboard HTTP API: server, auth/pairing routes, dev-stack discovery, secrets/wallet routes. |
| `src/runtime/` | Eliza agent loader (`eliza.ts`), dev server, runtime-mode (local/remote), Electrobun desktop runtimes. |
| `src/registry/` | Static app/plugin/connector registry — JSON entries in `entries/`, validated by `schema.ts`. |
| `src/security/` | Agent vault id + platform secure stores + wallet key hydration. |
| `src/services/` | Auth store, steward credentials/sidecar, vault mirror/bootstrap, account pool, and more. |
| `src/platform/` | Per-platform bootstrap (Capacitor for mobile, browser stubs, native plugin entrypoints). |
| `src/config/` | `AppConfig` types and `DEFAULT_APP_CONFIG` (re-exported from `@elizaos/shared`). |
## Usage
```ts
// Node/runtime barrel
import { startApiServer, loadRegistry, getPlugins } from "@elizaos/app-core";
// Targeted subpaths (see package.json exports for the full list)
import { loadRegistry } from "@elizaos/app-core/registry";
import { ensureRouteAuthorized } from "@elizaos/app-core/api/auth";
import { deriveAgentVaultId } from "@elizaos/app-core/security/agent-vault-id";
```
The full subpath list lives in the `exports` map of `package.json`.
## Build & test
```bash
bun run --cwd packages/app-core build # tsc → flatten → copy assets → rewrite dist ESM imports
bun run --cwd packages/app-core typecheck # tsgo --noEmit
bun run --cwd packages/app-core test # vitest
bun run --cwd packages/app-core lint # Biome
```
This package is consumed by `@elizaos/agent`, `@elizaos/ui`, `@elizaos/shared`, the `packages/app` shell, and most `plugins/*` app plugins. It targets Node `>=24`, with `react`/`react-dom`/`three` as peer dependencies and the `@elizaos/capacitor-*` mobile bridges as optional dependencies.
@@ -0,0 +1,484 @@
/** Exercises barge in behavior with deterministic app-core test fixtures. */
import { afterEach, beforeEach, describe, expect, it } from "vitest";
type RuntimeListenerEvent = {
type: "aborted";
roomId: string;
reason: string;
};
interface CoordinatorRuntime {
turnControllers: {
abortTurn(roomId: string, reason: string): boolean;
onEvent(listener: (event: RuntimeListenerEvent) => void): () => void;
};
}
type TurnToken = {
signal: AbortSignal;
aborted: boolean;
reason?: string;
runId: string;
slot: number;
};
type VoiceCancellationCoordinatorInstance = {
armTurn(input: { roomId: string; runId: string; slot: number }): TurnToken;
bargeIn(roomId: string): boolean;
current(roomId: string): TurnToken | null;
dispose(): void;
};
type OptimisticGenerationPolicyInstance = {
setPowerSource(source: "battery" | "ac"): void;
shouldStartOptimisticLm(eotProbability: number): boolean;
};
type LocalInferenceServicesModule = {
OptimisticGenerationPolicy: new () => OptimisticGenerationPolicyInstance;
VoiceCancellationCoordinator: new (options: {
runtime: CoordinatorRuntime;
slotAbort(slot: number, reason: string): void;
ttsStop(): void;
}) => VoiceCancellationCoordinatorInstance;
};
const { OptimisticGenerationPolicy, VoiceCancellationCoordinator } =
(await import(
"@elizaos/plugin-local-inference/services"
)) as LocalInferenceServicesModule;
interface FakeRuntime extends CoordinatorRuntime {
abortCalls: Array<{
roomId: string;
reason: string;
atMs: number;
}>;
emitEvent(event: RuntimeListenerEvent): void;
}
function fakeRuntime(clock: () => number): FakeRuntime {
const listeners = new Set<(e: RuntimeListenerEvent) => void>();
const abortCalls: FakeRuntime["abortCalls"] = [];
return {
turnControllers: {
abortTurn(roomId, reason) {
abortCalls.push({ roomId, reason, atMs: clock() });
return true;
},
onEvent(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
},
abortCalls,
emitEvent(event) {
for (const l of listeners) l(event);
},
};
}
/**
* Fake LM. Caller drives streaming by calling `pushChunk()` or `endStream()`.
* The LM responds to `signal.aborted` between chunks — the test asserts this.
*/
class FakeLm {
private aborted = false;
private startedAtMs: number | null = null;
chunks: string[] = [];
constructor(private readonly clock: () => number) {}
/** "Fire" the LM. Returns the wall-clock ms the call began. */
start(signal: AbortSignal): number {
this.startedAtMs = this.clock();
signal.addEventListener(
"abort",
() => {
this.aborted = true;
},
{ once: true },
);
return this.startedAtMs;
}
pushChunk(text: string): void {
if (this.aborted) return;
this.chunks.push(text);
}
get isAborted(): boolean {
return this.aborted;
}
get startedAt(): number | null {
return this.startedAtMs;
}
}
/**
* Fake TTS sink. `write()` pretends to play audio; `drain()` is the SIGKILL
* path the audio sink exposes — we record the wall-clock time it fires.
*/
class FakeTtsSink {
playing = false;
stoppedAtMs: number | null = null;
startedAtMs: number | null = null;
chunksWritten = 0;
constructor(private readonly clock: () => number) {}
startPlayback(): void {
this.playing = true;
this.startedAtMs = this.clock();
this.stoppedAtMs = null;
}
write(_chunk: Uint8Array): void {
if (!this.playing) return;
this.chunksWritten += 1;
}
drain(): void {
if (!this.playing) return;
this.playing = false;
this.stoppedAtMs = this.clock();
}
}
/** Fake VAD. Tests drive `.emit({ type: 'speech-start' | 'speech-end' })`. */
class FakeVad {
private readonly listeners = new Set<(event: { type: string }) => void>();
onEvent(listener: (event: { type: string }) => void): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
emit(event: { type: string }): void {
for (const l of this.listeners) l(event);
}
}
/**
* Fake turn detector. Test asserts that the optimistic policy fires the LM
* within 200 ms of the EOT timestamp this detector emits.
*/
class FakeTurnDetector {
private p = 0;
emit(prob: number): number {
this.p = prob;
return this.p;
}
get latestProb(): number {
return this.p;
}
}
// ---------------------------------------------------------------------------
// Test rig — wires the fakes through the real coordinator.
// ---------------------------------------------------------------------------
interface Rig {
clock: () => number;
advanceMs(ms: number): void;
runtime: FakeRuntime;
coordinator: VoiceCancellationCoordinator;
policy: OptimisticGenerationPolicy;
lm: FakeLm;
tts: FakeTtsSink;
vad: FakeVad;
turnDetector: FakeTurnDetector;
// Captured by slotAbort callback the coordinator fires.
slotAbortCalls: Array<{ slot: number; reason: string; atMs: number }>;
}
function newRig(): Rig {
let now = 0;
const clock = () => now;
const runtime = fakeRuntime(clock);
const lm = new FakeLm(clock);
const tts = new FakeTtsSink(clock);
const vad = new FakeVad();
const turnDetector = new FakeTurnDetector();
const slotAbortCalls: Rig["slotAbortCalls"] = [];
const coordinator = new VoiceCancellationCoordinator({
runtime,
slotAbort(slot, reason) {
slotAbortCalls.push({ slot, reason, atMs: now });
// Real slotAbort would close the slot's in-flight fetch; here we
// don't need to because the AbortSignal on the LM call already
// fires when the token aborts.
},
ttsStop() {
tts.drain();
},
});
const policy = new OptimisticGenerationPolicy();
return {
clock,
advanceMs(ms: number) {
now += ms;
},
runtime,
coordinator,
policy,
lm,
tts,
vad,
turnDetector,
slotAbortCalls,
};
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Drive the optimistic-LM-start logic: when the turn detector EOT probability
* crosses threshold and the policy says yes, arm a token and "start" the LM.
* This is the contract `VoiceStateMachine.handlePartialTranscript` calls;
* the test exercises the same gate by hand so we can assert timing without
* a full state machine boot.
*
* Returns true if the optimistic LM was started this tick.
*/
function maybeStartOptimisticLm(
rig: Rig,
roomId: string,
runId: string,
eotProb: number,
slot: number,
): boolean {
if (!rig.policy.shouldStartOptimisticLm(eotProb)) return false;
const token = rig.coordinator.armTurn({ roomId, runId, slot });
rig.lm.start(token.signal);
return true;
}
// ---------------------------------------------------------------------------
// Scenario 1 — optimistic generation: LM start within 200 ms of EOT.
// ---------------------------------------------------------------------------
describe("W3-9 — optimistic LM start within 200 ms of EOT", () => {
let rig: Rig;
beforeEach(() => {
rig = newRig();
});
afterEach(() => {
rig.coordinator.dispose();
});
it("fires the LM within 200 ms when policy is enabled and EOT clears threshold", () => {
// User has been speaking; VAD reports speech-start.
rig.vad.emit({ type: "speech-start" });
// Time passes — ASR partials arrive (modeled as turn-detector probability
// updates). At t=100 ms the turn detector hits the EOT threshold.
rig.advanceMs(100);
const eotProb = rig.turnDetector.emit(0.75);
const eotFiredAtMs = rig.clock();
// Optimistic gate fires.
const started = maybeStartOptimisticLm(rig, "room-A", "turn-1", eotProb, 0);
expect(started).toBe(true);
const elapsed =
(rig.lm.startedAt ?? Number.POSITIVE_INFINITY) - eotFiredAtMs;
expect(elapsed).toBeLessThanOrEqual(200);
expect(elapsed).toBeGreaterThanOrEqual(0);
// Token is live; the coordinator knows about it.
const token = rig.coordinator.current("room-A");
expect(token).not.toBeNull();
expect(token?.aborted).toBe(false);
expect(token?.runId).toBe("turn-1");
expect(token?.slot).toBe(0);
});
it("does NOT fire the LM when the policy is disabled (battery)", () => {
rig.policy.setPowerSource("battery");
const started = maybeStartOptimisticLm(rig, "room-A", "turn-1", 0.95, 0);
expect(started).toBe(false);
expect(rig.lm.startedAt).toBeNull();
expect(rig.coordinator.current("room-A")).toBeNull();
});
it("does NOT fire the LM when EOT is below threshold", () => {
const started = maybeStartOptimisticLm(rig, "room-A", "turn-1", 0.3, 0);
expect(started).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Scenario 2 — barge-in mid-response: TTS stops within 100 ms, LM aborts,
// new turn re-plans.
// ---------------------------------------------------------------------------
describe("W3-9 — barge-in mid-response cancels TTS + LM within 100 ms", () => {
let rig: Rig;
beforeEach(() => {
rig = newRig();
});
afterEach(() => {
rig.coordinator.dispose();
});
it("trips TTS drain + LM abort + runtime.abortTurn within one tick of speech-detected", () => {
// Set up a live turn: arm the token, the LM "starts", TTS starts playing.
rig.advanceMs(50);
const token = rig.coordinator.armTurn({
roomId: "room-A",
runId: "turn-1",
slot: 2,
});
rig.lm.start(token.signal);
rig.tts.startPlayback();
rig.tts.write(new Uint8Array(1024));
expect(rig.tts.playing).toBe(true);
expect(rig.lm.isAborted).toBe(false);
// User barges in.
rig.advanceMs(200);
const speechDetectedAtMs = rig.clock();
rig.coordinator.bargeIn("room-A");
// Within the same tick (no clock advance) — every fan-out fires:
// 1. token.signal aborts → LM aborted
// 2. ttsStop callback → TTS sink drained
// 3. slotAbort callback → slot abort recorded
// 4. runtime.abortTurn called with reason=barge-in
expect(rig.lm.isAborted).toBe(true);
expect(rig.tts.playing).toBe(false);
const ttsStopElapsed =
(rig.tts.stoppedAtMs ?? Number.POSITIVE_INFINITY) - speechDetectedAtMs;
expect(ttsStopElapsed).toBeLessThanOrEqual(100);
expect(ttsStopElapsed).toBeGreaterThanOrEqual(0);
expect(rig.slotAbortCalls).toEqual([
{ slot: 2, reason: "barge-in", atMs: speechDetectedAtMs },
]);
expect(rig.runtime.abortCalls).toEqual([
{ roomId: "room-A", reason: "barge-in", atMs: speechDetectedAtMs },
]);
// Token state.
expect(token.aborted).toBe(true);
expect(token.reason).toBe("barge-in");
});
it("re-plans: arming a fresh token after barge-in starts a new turn", () => {
// First turn lands and gets barged.
const first = rig.coordinator.armTurn({
roomId: "room-A",
runId: "turn-1",
slot: 2,
});
rig.lm.start(first.signal);
rig.tts.startPlayback();
rig.coordinator.bargeIn("room-A");
expect(first.aborted).toBe(true);
// User says something new; ASR comes in; optimistic-LM start arms a new
// token. The coordinator replaces the prior token cleanly.
rig.advanceMs(150);
const second = rig.coordinator.armTurn({
roomId: "room-A",
runId: "turn-2",
slot: 2,
});
expect(second.aborted).toBe(false);
expect(second.runId).toBe("turn-2");
expect(rig.coordinator.current("room-A")).toBe(second);
// First token stays aborted with its original reason — the
// re-arm does NOT retroactively change it.
expect(first.reason).toBe("barge-in");
});
it("LM signal is observed mid-stream — token abort halts further chunks", () => {
const token = rig.coordinator.armTurn({
roomId: "room-A",
runId: "turn-1",
slot: 0,
});
rig.lm.start(token.signal);
rig.lm.pushChunk("Hello");
rig.lm.pushChunk("world");
expect(rig.lm.chunks).toEqual(["Hello", "world"]);
rig.coordinator.bargeIn("room-A");
// After abort, the LM stops accepting new chunks (mimicking the
// signal.aborted check at each kernel boundary in the real LM).
rig.lm.pushChunk("blocked");
expect(rig.lm.chunks).toEqual(["Hello", "world"]);
expect(rig.lm.isAborted).toBe(true);
});
it("runtime-initiated abort propagates to TTS + LM (reverse direction)", () => {
const token = rig.coordinator.armTurn({
roomId: "room-A",
runId: "turn-1",
slot: 1,
});
rig.lm.start(token.signal);
rig.tts.startPlayback();
// Runtime aborts the turn for an unrelated reason (e.g. APP_PAUSE).
rig.runtime.emitEvent({
type: "aborted",
roomId: "room-A",
reason: "app-pause",
});
// Token aborts with reason=external; voice fan-out still fires.
expect(token.aborted).toBe(true);
expect(token.reason).toBe("external");
expect(rig.lm.isAborted).toBe(true);
expect(rig.tts.playing).toBe(false);
});
});
// ---------------------------------------------------------------------------
// Scenario 3 — sanity: idempotent and re-entrant.
// ---------------------------------------------------------------------------
describe("W3-9 — invariants", () => {
it("subsequent bargeIn calls on the same turn are no-ops", () => {
const rig = newRig();
rig.coordinator.armTurn({ roomId: "room-A", runId: "turn-1", slot: 0 });
expect(rig.coordinator.bargeIn("room-A")).toBe(true);
expect(rig.coordinator.bargeIn("room-A")).toBe(false);
expect(rig.runtime.abortCalls.length).toBe(1);
expect(rig.slotAbortCalls.length).toBe(1);
rig.coordinator.dispose();
});
it("disposing tears down all turns without leaking listeners", () => {
const rig = newRig();
const a = rig.coordinator.armTurn({
roomId: "room-A",
runId: "turn-A",
slot: 0,
});
const b = rig.coordinator.armTurn({
roomId: "room-B",
runId: "turn-B",
slot: 1,
});
rig.coordinator.dispose();
expect(a.aborted).toBe(true);
expect(b.aborted).toBe(true);
// After dispose, the runtime emitting an event for an already-armed
// room is harmless (no listener active).
rig.runtime.emitEvent({
type: "aborted",
roomId: "room-A",
reason: "any",
});
});
});
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"root": false,
"extends": ["../../biome.json"],
"files": {
"includes": ["src/**", "!src/**/*.d.ts", "!src/**/*.d.ts.map"]
}
}
+90
View File
@@ -0,0 +1,90 @@
# CI / production dockerignore for `eliza/packages/app-core/deploy/Dockerfile.ci`.
# Copy to the repo root as `.dockerignore` before `docker build` (context is `.`).
# Pre-built `dist/` and `packages/app/dist/` are expected in the build context.
.git
.git/**
**/.git
**/.git/**
.github
.claude
.devin
.vscode
.idea
.eliza.ci-disabled
.env
.env.local
.env.*.local
node_modules
node_modules/**
node_modules/.cache
packages/**/node_modules
!packages/agent/node_modules/
!packages/agent/node_modules/**
# `@elizaos/ui`'s Node barrel re-exports `App.js`, which imports `react/jsx-runtime`.
# The server agent pulls `@elizaos/ui` in transitively (via `@elizaos/app-core`'s
# `ui-compat` re-export), so `react` must be resolvable from `packages/ui/dist/`.
# bun's isolated install keeps it at `packages/ui/node_modules/react` (a symlink),
# so don't strip that tree.
!packages/ui/node_modules/
!packages/ui/node_modules/**
apps/**/node_modules
# `plugins/**/node_modules` is intentionally NOT excluded: bun's isolated
# install puts each workspace plugin's runtime deps under
# `plugins/<plugin>/node_modules/`. Stripping that breaks plugin loading
# (cross-spawn, fluent-ffmpeg, fflate, etc.) at runtime. The directories
# are mostly symlinks into `node_modules/.bun/`, so the size cost is
# negligible.
eliza/**/node_modules
!eliza/plugins/*/node_modules/
!eliza/plugins/*/node_modules/**
!eliza/packages/agent/node_modules/
!eliza/packages/agent/node_modules/**
!eliza/packages/ui/node_modules/
!eliza/packages/ui/node_modules/**
eliza/packages/app-core/deploy/**/node_modules
eliza/.turbo
eliza/packages/benchmarks
eliza/packages/examples
eliza/apps
eliza/cloud
!eliza/cloud/
!eliza/cloud/packages/
!eliza/cloud/packages/sdk/
!eliza/cloud/packages/sdk/package.json
!eliza/cloud/packages/sdk/dist/**
eliza/plugins/*/rust
eliza/plugins/*/python
eliza/plugins/*/.git
eliza/plugins/*/.eliza
eliza/plugins/*/.elizadb-test
packages/app/android
packages/app/ios
packages/app-core/platforms/electrobun
packages/app/.vite
packages/app/screenshots
packages/app/test-results
packages/app/test
packages/app/.eliza
apps/homepage
apps/home
apps/landing
apps/chrome-extension
apps/ui
docs/
coverage/
dev.log
install.sh
install.ps1
packaging/
artifacts/
.docker-home
.tmp
deploy/
# Other deploy Docker recipes — CI agent build only needs Dockerfile.ci
eliza/packages/app-core/deploy/Dockerfile.cloud
eliza/packages/app-core/deploy/Dockerfile.cloud-agent
eliza/packages/app-core/deploy/Dockerfile.sandbox
eliza/packages/app-core/deploy/Dockerfile.cloud.dockerignore
!eliza/packages/app-core/deploy/Dockerfile.ci
+241
View File
@@ -0,0 +1,241 @@
# syntax=docker/dockerfile:1.23
# elizaOS Agent — CI Docker Image
# Pre-built artifacts come from GHA (bun install + tsdown + vite build done before docker)
ARG NODE_VERSION=24
ARG BUN_VERSION=canary
ARG APP_CORE_DIR=packages/app-core
ARG AGENT_DIR=packages/agent
ARG APP_DIR=packages/app
ARG APP_ENTRYPOINT=packages/agent/dist/bin.js
# `--import` points at the standalone tsx loader installed by the `tsx` stage
# at /opt/tsx — avoids depending on workspace-resolved `tsx` in /app/node_modules.
ARG APP_CMD_START="node --import /opt/tsx/node_modules/tsx/dist/loader.mjs packages/agent/dist/bin.js start"
ARG APP_PORT=2138
ARG APP_API_BIND=127.0.0.1
ARG OCI_SOURCE=""
ARG OCI_TITLE="elizaOS Agent"
ARG OCI_DESCRIPTION="elizaOS agent runtime"
ARG OCI_LICENSES="MIT"
ARG VERSION=""
ARG VERSION_CLEAN=""
ARG REVISION=""
ARG TAILSCALE_VERSION=1.90.8
# Some relinked local packages expect their third-party (non-@elizaos) runtime
# dependencies to be resolvable from /app/node_modules. Host node_modules is
# excluded from the Docker context (see .dockerignore: **/node_modules), so
# install that runtime set in-image.
#
# The set is DERIVED, not hand-maintained: collect-docker-runtime-deps.mjs
# reads the declared `dependencies` of every workspace package that gets
# linked into the image (see link-docker-local-app-packages.mjs +
# relink-workspace-packages-to-dist.mjs), drops the @elizaos/* workspace
# entries (those are linked, not installed), and emits the union pinned to
# the versions the workspace lockfile resolves. `npm install` then pulls the
# full transitive closure automatically.
#
# This replaces the previous hand-maintained allowlist, which had to be
# extended by hand every time a newly-linked package introduced a new
# third-party dependency. Each omission was a hard boot crash of the form
# "Cannot find package '<dep>' imported from /app/packages/<pkg>/dist/..."
# (zod, then @elizaos/core, then chalk, ...). Deriving the closure removes
# that whole class of build/test loops: every dependency a linked package
# declares is present, so the image boots and any character config can load
# any bundled plugin without a missing-module crash.
#
# Pure browser/UI packages (notably @elizaos/ui and its radix/three/recharts
# tree) are excluded inside the script: they are linked for resolution
# integrity but never imported by the headless server runtime, so installing
# their frontend deps would only bloat the image.
#
# This stage deliberately COPYs ONLY the dependency manifests (bun.lock + the
# workspace package.json set — the sole inputs of
# collect-docker-runtime-deps.mjs), NOT the whole source tree. That keeps the
# expensive `npm install` layer's cache key independent of per-commit source
# changes: it only rebuilds when the lockfile, a workspace package.json, or
# the collector script itself changes. With the registry build cache this
# turns ~80s of npm install into a cache hit on the typical source-only
# commit.
FROM node:${NODE_VERSION}-slim AS runtime-deps
WORKDIR /app
ARG APP_CORE_DIR
COPY --parents bun.lock \
packages/*/package.json \
packages/cloud/*/package.json \
plugins/*/package.json \
${APP_CORE_DIR}/scripts/collect-docker-runtime-deps.mjs \
./
RUN mkdir -p /deps \
&& cd /deps \
&& npm init -y >/dev/null \
&& node "/app/${APP_CORE_DIR}/scripts/collect-docker-runtime-deps.mjs" \
> /deps/deps.txt \
&& echo "Installing $(wc -l < /deps/deps.txt) derived runtime deps" \
&& npm install --omit=dev --ignore-scripts --no-package-lock \
--legacy-peer-deps \
--fetch-retries=5 \
--fetch-retry-mintimeout=20000 \
--fetch-retry-maxtimeout=120000 \
$(cat /deps/deps.txt)
FROM node:${NODE_VERSION}-slim AS pruner
WORKDIR /app
COPY . .
ARG APP_CORE_DIR
ARG AGENT_DIR
ARG APP_DIR
RUN node "${APP_CORE_DIR}/scripts/relink-workspace-packages-to-dist.mjs" \
@elizaos/agent
# Overlay the derived third-party runtime closure (prepared by the
# runtime-deps stage above) onto /app/node_modules. The rm -rf pre-clean
# keeps the closure authoritative over any same-named copies a build context
# might carry, matching the previous in-stage install semantics.
RUN mkdir -p /app/node_modules \
&& node "/app/${APP_CORE_DIR}/scripts/collect-docker-runtime-deps.mjs" --names \
| while IFS= read -r dep; do [ -n "$dep" ] && rm -rf "/app/node_modules/$dep"; done
COPY --from=runtime-deps /deps/node_modules/ /app/node_modules/
RUN node "${APP_CORE_DIR}/scripts/link-docker-local-app-packages.mjs"
RUN node "${APP_CORE_DIR}/scripts/ensure-generated-core-proto-js.mjs"
# Patch plugin-agent-skills getCatalogStats crash (alpha.11 vs alpha.66 interface mismatch)
RUN sed -i 's/const stats = service.getCatalogStats();/const stats = typeof service.getCatalogStats === "function" ? service.getCatalogStats() : { loaded: 0, total: 0, storageType: "unknown" };/g' node_modules/@elizaos/plugin-agent-skills/dist/index.js 2>/dev/null || true
ARG VERSION_CLEAN
# Patch autonomous version
RUN if [ -n "${VERSION_CLEAN}" ]; then \
node -e " \
const fs = require('fs'); \
const v = '${VERSION_CLEAN}'; \
try { \
const p = JSON.parse(fs.readFileSync('node_modules/@elizaos/agent/package.json', 'utf8')); \
p.version = v; \
fs.writeFileSync('node_modules/@elizaos/agent/package.json', JSON.stringify(p, null, 2)); \
console.log('Patched autonomous to', v); \
} catch(e) { console.log('skip:', e.message); } \
"; \
fi
# plugin-agent-orchestrator is imported during agent boot
# (server-helpers-swarm.ts). Keep it linked in the image; the link step rewrites
# unbuilt package exports to source paths that the tsx loader resolves.
# Safe pruning only (GPU binaries + obvious dead weight)
RUN rm -rf \
node_modules/.bun/@node-llama-cpp+linux-x64-cuda-ext@* \
node_modules/.bun/@node-llama-cpp+linux-x64-cuda@* \
node_modules/.bun/@node-llama-cpp+linux-x64-vulkan@* \
node_modules/.bun/@node-llama-cpp+linux-arm64@* \
node_modules/.bun/@node-llama-cpp+linux-armv7l@* \
node_modules/@storybook \
node_modules/storybook \
"${APP_DIR}/public" \
2>/dev/null || true
# Remove source maps and test dirs
RUN find node_modules -name '*.map' -delete 2>/dev/null; \
find node_modules -type d -name '__tests__' -exec rm -rf {} + 2>/dev/null; \
find node_modules -type d -name 'test' -maxdepth 3 -exec rm -rf {} + 2>/dev/null; \
true
# Standalone tsx install — the runtime entrypoint is
# the absolute tsx loader plus `packages/agent/dist/...`, which avoids requiring
# `tsx` to be resolvable from /app. bun's workspace install on GHA can drop tsx from the build
# context (it lives under `node_modules/.bun/tsx@<v>/` but isn't always
# materialized at `node_modules/tsx/`, and dockerignore prunes per-package
# trees). So install tsx in a dedicated stage and COPY it into the final image
# at `/opt/tsx` (referenced absolutely by APP_CMD_START). Idempotent and
# version-pinned.
FROM node:${NODE_VERSION}-slim AS tsx
RUN npm install --prefix /opt/tsx --ignore-scripts --no-save tsx@4.21.0 \
&& npm cache clean --force
FROM node:${NODE_VERSION}-slim
ARG BUN_VERSION
ARG APP_CORE_DIR
ARG APP_ENTRYPOINT
ARG APP_CMD_START
ARG APP_PORT
ARG APP_API_BIND
ARG OCI_SOURCE
ARG OCI_TITLE
ARG OCI_DESCRIPTION
ARG OCI_LICENSES
ARG VERSION
ARG VERSION_CLEAN
ARG REVISION
ARG TAILSCALE_VERSION
LABEL org.opencontainers.image.title="${OCI_TITLE}" \
org.opencontainers.image.description="${OCI_DESCRIPTION}" \
org.opencontainers.image.source="${OCI_SOURCE}" \
org.opencontainers.image.url="${OCI_SOURCE}" \
org.opencontainers.image.version="${VERSION_CLEAN}" \
org.opencontainers.image.revision="${REVISION}" \
org.opencontainers.image.licenses="${OCI_LICENSES}"
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates curl ffmpeg libopus-dev \
&& rm -rf /var/lib/apt/lists/*
# Install tailscale so Cloud-managed agent sandboxes can join Headscale/Tailscale
# when TS_AUTHKEY is injected at runtime (see scripts/docker-entrypoint.sh). The
# download is integrity-checked against the upstream-published .sha256 sidecar.
RUN set -eux; \
case "$(dpkg --print-architecture)" in \
amd64) TAILSCALE_ARCH=amd64 ;; \
arm64) TAILSCALE_ARCH=arm64 ;; \
*) echo "Unsupported architecture for tailscale: $(dpkg --print-architecture)" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${TAILSCALE_VERSION}_${TAILSCALE_ARCH}.tgz" \
-o /tmp/tailscale.tgz; \
curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${TAILSCALE_VERSION}_${TAILSCALE_ARCH}.tgz.sha256" \
-o /tmp/tailscale.tgz.sha256; \
echo "$(cat /tmp/tailscale.tgz.sha256) /tmp/tailscale.tgz" | sha256sum -c -; \
tar -xzf /tmp/tailscale.tgz -C /tmp; \
install -m 0755 "/tmp/tailscale_${TAILSCALE_VERSION}_${TAILSCALE_ARCH}/tailscale" /usr/local/bin/tailscale; \
install -m 0755 "/tmp/tailscale_${TAILSCALE_VERSION}_${TAILSCALE_ARCH}/tailscaled" /usr/local/bin/tailscaled; \
rm -rf /tmp/tailscale.tgz /tmp/tailscale.tgz.sha256 "/tmp/tailscale_${TAILSCALE_VERSION}_${TAILSCALE_ARCH}"
# Install yt-dlp from the upstream GitHub release (latest), not the lagging distro package.
RUN ARCH="$(dpkg --print-architecture)" \
&& case "$ARCH" in \
amd64) YTDLP_ASSET=yt-dlp_linux ;; \
arm64) YTDLP_ASSET=yt-dlp_linux_aarch64 ;; \
armhf) YTDLP_ASSET=yt-dlp_linux_armv7l ;; \
*) echo "Unsupported architecture for yt-dlp: $ARCH" >&2; exit 1 ;; \
esac \
&& curl -fsSL "https://github.com/yt-dlp/yt-dlp/releases/latest/download/${YTDLP_ASSET}" \
-o /usr/local/bin/yt-dlp \
&& chmod +x /usr/local/bin/yt-dlp \
&& yt-dlp --version
WORKDIR /app
COPY --from=pruner /app /app
# tsx is required by APP_CMD_START. Bring it in
# from its own stage so it's self-contained (with esbuild + get-tsconfig
# deps) at /opt/tsx, independent of bun's workspace install.
COPY --from=tsx /opt/tsx /opt/tsx
ENV NODE_ENV=production
ENV ELIZA_DISABLE_DIRECT_RUN=1
ENV ELIZA_DISABLE_EDGE_TTS=1
ENV TSX_TSCONFIG_PATH=${APP_CORE_DIR}/deploy/tsx-runtime-tsconfig.json
ENV APP_CORE_DIR=${APP_CORE_DIR}
ENV APP_ENTRYPOINT=${APP_ENTRYPOINT}
ENV APP_CMD_START=${APP_CMD_START}
ENV APP_PORT=${APP_PORT}
ENV APP_API_BIND=${APP_API_BIND}
ENV ELIZA_PORT=${APP_PORT}
ENV ELIZA_API_BIND=${APP_API_BIND}
# Bake the cloud image version so the version-resolver picks it up at runtime
# (takes priority over package.json patching which can be fragile)
ENV ELIZA_BUNDLED_VERSION=${VERSION_CLEAN}
ENV ELIZA_ALLOWED_HOSTS=*
EXPOSE ${APP_PORT}
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 \
CMD sh -lc 'port="${PORT:-${APP_PORT:-${ELIZA_PORT:-2138}}}"; code="$(curl -s -o /dev/null -w "%{http_code}" "http://127.0.0.1:${port}/api/health")" && [ "$code" = "200" -o "$code" = "401" ]'
ENTRYPOINT ["sh", "-lc", "exec sh \"./${APP_CORE_DIR:-packages/app-core}/scripts/docker-entrypoint.sh\" \"$@\"", "--"]
CMD ["sh", "-lc", "exec ${APP_CMD_START:-node --import /opt/tsx/node_modules/tsx/dist/loader.mjs ${APP_ENTRYPOINT} start}"]
@@ -0,0 +1,96 @@
# syntax=docker/dockerfile:1.23
# ─────────────────────────────────────────────────────────────────────────────
# elizaOS Cloud Agent Container
# ─────────────────────────────────────────────────────────────────────────────
ARG APP_PORT=2138
ARG APP_BRIDGE_PORT=18790
ARG TAILSCALE_VERSION=1.90.8
# ─────────────────────────────────────────────────────────────────────────────
# Builder: precompile the standalone TS entrypoint to a single JS bundle so the
# runtime image starts with plain `node entrypoint.js` — no `tsx` transpile on
# the container cold-start path (issue #8812 F6). cloud-agent-shared.ts is
# inlined; the dynamic `@elizaos/*` imports stay external and resolve from
# /app/node_modules at runtime exactly as before.
# ─────────────────────────────────────────────────────────────────────────────
FROM node:24-bookworm-slim AS entrypoint-build
WORKDIR /build
COPY eliza/packages/app-core/deploy/cloud-agent-entrypoint.ts ./cloud-agent-entrypoint.ts
COPY eliza/packages/app-core/deploy/cloud-agent-shared.ts ./cloud-agent-shared.ts
# cloud-agent-shared.ts imports this via ../../shared/... in the repo layout.
# The builder copies TS files into /build, so mirror that relative path here for
# esbuild's resolver without adding a runtime dependency on @elizaos/shared.
COPY eliza/packages/shared/src/restart-exit-code.json /shared/src/restart-exit-code.json
RUN npx --yes esbuild@0.28.1 cloud-agent-entrypoint.ts \
--bundle --platform=node --format=esm --target=node24 \
--external:@elizaos/* --external:node:* \
--outfile=entrypoint.js
FROM node:24-bookworm-slim
ARG APP_PORT
ARG APP_BRIDGE_PORT
ARG TAILSCALE_VERSION
RUN apt-get update && \
apt-get install -y --no-install-recommends \
curl ca-certificates gosu && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN set -eux; \
case "$(dpkg --print-architecture)" in \
amd64) TAILSCALE_ARCH=amd64 ;; \
arm64) TAILSCALE_ARCH=arm64 ;; \
*) echo "Unsupported architecture for tailscale: $(dpkg --print-architecture)" >&2; exit 1 ;; \
esac; \
curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${TAILSCALE_VERSION}_${TAILSCALE_ARCH}.tgz" \
-o /tmp/tailscale.tgz; \
curl -fsSL "https://pkgs.tailscale.com/stable/tailscale_${TAILSCALE_VERSION}_${TAILSCALE_ARCH}.tgz.sha256" \
-o /tmp/tailscale.tgz.sha256; \
echo "$(cat /tmp/tailscale.tgz.sha256) /tmp/tailscale.tgz" | sha256sum -c -; \
tar -xzf /tmp/tailscale.tgz -C /tmp; \
install -m 0755 "/tmp/tailscale_${TAILSCALE_VERSION}_${TAILSCALE_ARCH}/tailscale" /usr/local/bin/tailscale; \
install -m 0755 "/tmp/tailscale_${TAILSCALE_VERSION}_${TAILSCALE_ARCH}/tailscaled" /usr/local/bin/tailscaled; \
rm -rf /tmp/tailscale.tgz /tmp/tailscale.tgz.sha256 "/tmp/tailscale_${TAILSCALE_VERSION}_${TAILSCALE_ARCH}"
WORKDIR /app
# Copy the prebuilt JS entrypoint (cloud-agent-shared.ts inlined by the builder
# stage). The container runs this with plain `node` — no `tsx` transpile on the
# cold-start path.
COPY --from=entrypoint-build /build/entrypoint.js ./entrypoint.js
COPY eliza/packages/app-core/deploy/cloud-agent-docker-entrypoint.sh /usr/local/bin/cloud-agent-docker-entrypoint
RUN chmod +x /usr/local/bin/cloud-agent-docker-entrypoint
# Install elizaOS runtime + plugins so the dynamic imports inside
# cloud-agent-shared.ts resolve and the agent runs in REAL mode (not echo).
# Pinned to the `alpha` dist-tag per repo conventions. plugin-workflow and
# plugin-elizacloud are not installable from npm right now (the former is
# unpublished; the latter@alpha has an unpublished transitive @elizaos/cloud-sdk
# pin). The entrypoint already tolerates their absence.
RUN npm install --omit=dev --no-audit --no-fund --loglevel=error \
@elizaos/core@alpha \
@elizaos/plugin-sql@alpha \
&& npm cache clean --force
# Ensure the entrypoint can resolve packages from /app/node_modules
ENV NODE_PATH=/app/node_modules
ENV NODE_ENV=production
ENV PORT=${APP_PORT}
ENV BRIDGE_PORT=${APP_BRIDGE_PORT}
# Health check endpoint
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD curl -f http://localhost:${PORT}/health || exit 1
EXPOSE ${APP_PORT}
EXPOSE ${APP_BRIDGE_PORT}
# Security: the container starts as root only so the entrypoint can initialize
# Tailscale/Headscale when Cloud injects TS_AUTHKEY, then it drops to agent.
RUN useradd -m agent && chown -R agent:agent /app
ENTRYPOINT ["cloud-agent-docker-entrypoint"]
CMD ["node", "entrypoint.js"]
@@ -0,0 +1,16 @@
FROM debian:bookworm-slim
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
bash \
ca-certificates \
curl \
git \
jq \
python3 \
ripgrep \
&& rm -rf /var/lib/apt/lists/*
CMD ["sleep", "infinity"]
+62
View File
@@ -0,0 +1,62 @@
# Deployment Toolkit
This directory contains the generic deployment assets for elizaOS apps: Dockerfiles, compose files, node rollout scripts, Cloudflare proxy sources, and the cloud-agent runtime helpers.
## Layout
- `deploy.defaults.env` — shared defaults loaded by the setup and rollout scripts
- `Dockerfile.ci` — canonical image for prebuilt runtime/UI artifacts
- `Dockerfile.cloud-agent` — subordinate cloud-agent runtime image
- `docker-compose.yml` — gateway plus interactive CLI services
- `docker-compose.supabase-db.yml` — optional local Postgres service
- `docker-setup.sh` — local image build plus compose-based onboarding flow
- `deploy-to-nodes.sh` — image load / restart helper for remote Docker nodes
- `cloudflare/eliza-cloud-proxy/` — proxy worker source and Wrangler example
## App Overrides
Create a repo-root `deploy/deploy.env` and override only what differs from `deploy.defaults.env`. The scripts look for that file in this order:
1. `DEPLOY_CONFIG`
2. `./deploy.env`
3. `../deploy/deploy.env`
`deploy-to-nodes.sh` also looks for `nodes.json` in the same order, using `DEPLOY_NODES_FILE`, `./nodes.json`, and `../deploy/nodes.json`.
## Common Commands
```bash
# Build a local image and walk through setup.
cd deploy
bash ../eliza/packages/app-core/deploy/docker-setup.sh
# Load the current image onto configured nodes.
cd deploy
bash ../eliza/packages/app-core/deploy/deploy-to-nodes.sh --status
```
## Variables
| Variable | Purpose | Default |
| --- | --- | --- |
| `APP_NAME` | App/project name used by compose and helper output | `eliza` |
| `APP_ENTRYPOINT` | Runtime entrypoint copied into images | `app.mjs` |
| `APP_CMD_START` | Startup command for the full app container | `node --import ./node_modules/tsx/dist/loader.mjs app.mjs start` |
| `APP_IMAGE` | Local Docker image name | `eliza:local` |
| `APP_REGISTRY` | Optional registry prefix used by node rollout image matching | _empty_ |
| `APP_PORT` | Primary app/API port inside the container | `2138` |
| `APP_GATEWAY_PORT` | Gateway listener port | `18789` |
| `APP_BRIDGE_PORT` | Cloud bridge port | `18790` |
| `APP_GATEWAY_BIND` | Gateway bind preset passed to the CLI | `lan` |
| `APP_STATE_DIR` | In-container state/config directory | `/home/node/.eliza` |
| `APP_CONFIG_DIR` | Host-side config directory mounted into the container | `${HOME}/.eliza` |
| `APP_WORKSPACE_DIR` | Host-side workspace directory mounted into the container | `${HOME}/.eliza/workspace` |
| `APP_DB_NAME` | Database name for the Postgres helper compose file | `eliza` |
| `APP_API_BIND` | Default API bind address baked into the image | `127.0.0.1` |
| `OCI_SOURCE` | OCI source metadata | _empty_ |
| `OCI_TITLE` | OCI image title | `elizaOS Agent` |
| `OCI_DESCRIPTION` | OCI image description | `elizaOS agent runtime` |
| `OCI_LICENSES` | OCI image license metadata | `MIT` |
| `CF_WORKER_NAME` | Suggested Cloudflare worker name | `eliza-cloud-proxy` |
| `CF_ALLOWED_ORIGINS` | Allowed CORS origins for the proxy worker | _empty_ |
| `CF_PROXY_PATH_PREFIXES` | Comma-separated path prefixes forwarded by the proxy worker | _empty_ |
@@ -0,0 +1,67 @@
#!/usr/bin/env sh
set -eu
start_tailscale_if_configured() {
if [ -z "${TS_AUTHKEY:-}" ]; then
return 0
fi
if [ "$(id -u)" != "0" ]; then
echo "[cloud-agent-entrypoint] TS_AUTHKEY is set but the container did not start as root" >&2
exit 1
fi
if ! command -v tailscaled >/dev/null 2>&1 || ! command -v tailscale >/dev/null 2>&1; then
echo "[cloud-agent-entrypoint] TS_AUTHKEY is set but tailscale/tailscaled is not installed" >&2
exit 1
fi
ts_state_dir="${TS_STATE_DIR:-/var/lib/tailscale}"
ts_socket="${TS_SOCKET:-/tmp/tailscaled.sock}"
ts_hostname="${TS_HOSTNAME:-${SANDBOX_AGENT_ID:-${STEWARD_AGENT_ID:-$(hostname)}}}"
mkdir -p "$ts_state_dir"
rm -f "$ts_socket"
tailscaled \
--state="${ts_state_dir}/tailscaled.state" \
--socket="$ts_socket" \
>/tmp/tailscaled.log 2>&1 &
export TS_SOCKET="$ts_socket"
i=0
while [ ! -S "$ts_socket" ] && [ ! -e "$ts_socket" ] && [ "$i" -lt 50 ]; do
sleep 0.1
i=$((i + 1))
done
if [ ! -S "$ts_socket" ] && [ ! -e "$ts_socket" ]; then
echo "[cloud-agent-entrypoint] tailscaled did not create its socket; last log lines:" >&2
tail -n 20 /tmp/tailscaled.log >&2 || true
exit 1
fi
login_server_arg=""
if [ -n "${HEADSCALE_URL:-}" ]; then
login_server_arg="--login-server=${HEADSCALE_URL}"
elif [ -n "${TS_CONTROL_URL:-}" ]; then
login_server_arg="--login-server=${TS_CONTROL_URL}"
fi
# TS_EXTRA_ARGS intentionally supports multiple CLI flags, e.g. "--accept-routes".
# shellcheck disable=SC2086
tailscale --socket="$ts_socket" up \
--auth-key="$TS_AUTHKEY" \
--hostname="$ts_hostname" \
$login_server_arg \
${TS_EXTRA_ARGS:-}
}
start_tailscale_if_configured
run_as_user="${CLOUD_AGENT_RUN_AS_USER:-agent}"
if [ "$(id -u)" = "0" ] && [ -n "$run_as_user" ]; then
exec gosu "$run_as_user" "$@"
fi
exec "$@"
@@ -0,0 +1,15 @@
/**
* Cloud Agent Entrypoint (main)
*
* Full-featured cloud agent with auth, chat mode, memory limits,
* and body-size guards. All logic lives in cloud-agent-shared.ts.
*/
import { startCloudAgent } from "./cloud-agent-shared.ts";
startCloudAgent({
bridgeSecret: process.env.BRIDGE_SECRET ?? "",
maxBodyBytes: 1_048_576,
maxMemories: 1_000,
enableChatMode: true,
});
@@ -0,0 +1,42 @@
/**
* Exercises cloud-agent bridge result shaping without booting a full runtime.
* The deployed image relies on these helpers to preserve model failure
* discriminators across the native JSON-RPC bridge.
*/
import { describe, expect, it } from "vitest";
import {
appendBridgeCallbackContent,
type BridgeMessageResult,
bridgeResultText,
} from "./cloud-agent-shared";
describe("cloud-agent bridge callback results", () => {
it("accumulates text while preserving the runtime failure discriminator", () => {
const result: BridgeMessageResult = { text: "" };
appendBridgeCallbackContent(result, { text: "provider " });
appendBridgeCallbackContent(result, {
text: "unavailable",
failureKind: "provider_issue",
});
appendBridgeCallbackContent(result, {
text: " ignored discriminator",
failureKind: "rate_limited",
});
expect(result).toEqual({
text: "provider unavailable ignored discriminator",
failureKind: "provider_issue",
});
});
it("uses the native no-response text without dropping failureKind", () => {
const result: BridgeMessageResult = { text: "" };
appendBridgeCallbackContent(result, { failureKind: "no_response" });
expect(bridgeResultText(result)).toBe("(no response)");
expect(result.failureKind).toBe("no_response");
});
});
@@ -0,0 +1,933 @@
/**
* Shared Cloud Agent Logic
*
* Single implementation of the cloud-agent runtime, health server, and
* bridge server. Both the main entrypoint and the template entrypoint
* import from here, passing a config that captures the small differences.
*/
import * as crypto from "node:crypto";
import * as http from "node:http";
import restartExitCodeDefinition from "../../shared/src/restart-exit-code.json" with {
type: "json",
};
const CLOUD_AGENT_RESTART_EXIT_CODE = restartExitCodeDefinition.restartExitCode;
// ─── Logger ─────────────────────────────────────────────────────────────
//
// This file is bundled with `--external:@elizaos/*` and runs in a minimal
// container before any `@elizaos/*` package is statically resolvable, so it
// cannot import the shared structured logger. This tiny shim gives the same
// `[ClassName]`-prefixed, level-tagged, single-line structured output the
// logger commandment requires (message + optional context object), without a
// hard dependency. All cloud-agent runtime logging goes through it.
const logger = {
info(message: string, context?: Record<string, unknown>): void {
if (context) console.log(`[cloud-agent] ${message}`, context);
else console.log(`[cloud-agent] ${message}`);
},
warn(message: string, context?: Record<string, unknown>): void {
if (context) console.warn(`[cloud-agent] ${message}`, context);
else console.warn(`[cloud-agent] ${message}`);
},
error(message: string, context?: Record<string, unknown>): void {
if (context) console.error(`[cloud-agent] ${message}`, context);
else console.error(`[cloud-agent] ${message}`);
},
};
/**
* `.catch` handler for an optional plugin dynamic import: keeps the degrade
* (agent boots without the plugin) but surfaces the import failure so a broken
* build is distinguishable from a plugin that is deliberately absent in this
* deploy shape.
*/
// error-policy:J4 optional plugin unavailable → degrade; failure surfaced via logger
function logPluginLoadFailure(id: string): (error: unknown) => null {
return (error) => {
logger.error(`failed to load ${id}; continuing without it`, {
error: error instanceof Error ? error.message : String(error),
});
return null;
};
}
// ─── Types ──────────────────────────────────────────────────────────────
export interface BridgeRpcParams {
text?: string;
roomId?: string;
mode?: string;
channelType?: string;
source?: string;
sender?: {
id?: string;
username?: string;
displayName?: string;
metadata?: Record<string, unknown>;
};
metadata?: Record<string, unknown>;
}
export type NormalizedBridgeMessage = {
text: string;
roomKey: string;
mode: "simple" | "power";
channelType: "DM" | "GROUP";
source: string;
sender?: {
id?: string;
username?: string;
displayName?: string;
metadata?: Record<string, unknown>;
};
metadata?: Record<string, unknown>;
};
export type BridgeMessageResult = {
text: string;
failureKind?: string;
};
type BridgeCallbackContent = {
text?: unknown;
failureKind?: unknown;
};
export function appendBridgeCallbackContent(
result: BridgeMessageResult,
content: BridgeCallbackContent,
): BridgeMessageResult {
if (typeof content.text === "string") result.text += content.text;
if (
!result.failureKind &&
typeof content.failureKind === "string" &&
content.failureKind.trim()
) {
result.failureKind = content.failureKind.trim();
}
return result;
}
export function bridgeResultText(result: BridgeMessageResult): string {
return result.text || "(no response)";
}
export function normalizeBridgeMessage(
params?: BridgeRpcParams,
): NormalizedBridgeMessage {
const trimmedRoomId =
typeof params?.roomId === "string" && params.roomId.trim().length > 0
? params.roomId.trim()
: "default";
const source =
typeof params?.source === "string" && params.source.trim().length > 0
? params.source.trim()
: "cloud-bridge";
const sender =
params?.sender && typeof params.sender === "object"
? {
...(typeof params.sender.id === "string" && params.sender.id.trim()
? { id: params.sender.id.trim() }
: {}),
...(typeof params.sender.username === "string" &&
params.sender.username.trim()
? { username: params.sender.username.trim() }
: {}),
...(typeof params.sender.displayName === "string" &&
params.sender.displayName.trim()
? { displayName: params.sender.displayName.trim() }
: {}),
...(params.sender.metadata &&
typeof params.sender.metadata === "object" &&
!Array.isArray(params.sender.metadata)
? { metadata: params.sender.metadata }
: {}),
}
: undefined;
return {
text: typeof params?.text === "string" ? params.text : "",
roomKey: trimmedRoomId,
mode: params?.mode === "simple" ? "simple" : "power",
channelType: params?.channelType === "GROUP" ? "GROUP" : "DM",
source,
...(sender && Object.keys(sender).length > 0 ? { sender } : {}),
...(params?.metadata &&
typeof params.metadata === "object" &&
!Array.isArray(params.metadata)
? { metadata: params.metadata }
: {}),
};
}
export interface CloudAgentConfig {
/** Health endpoint port. Default: 2138 */
port?: number;
/** Bridge server port. Default: 18790 */
bridgePort?: number;
/**
* If set, the bridge server requires `Authorization: Bearer <secret>`.
* Omit or pass empty string to disable auth.
*/
bridgeSecret?: string;
/** Max request body size in bytes. Default: 1 MB */
maxBodyBytes?: number;
/** Max memories kept in state. 0 = unlimited. Default: 0 */
maxMemories?: number;
/**
* Whether processMessage/processMessageStream accept a chat mode param.
* When false the mode parameter is ignored (template behaviour).
*/
enableChatMode?: boolean;
}
interface AgentRuntime {
processMessage: (params: BridgeRpcParams) => Promise<BridgeMessageResult>;
processMessageStream: (
params: BridgeRpcParams,
onChunk: (chunk: string) => void,
) => Promise<BridgeMessageResult>;
getMemories: () => Array<Record<string, unknown>>;
getConfig: () => Record<string, unknown>;
}
// ─── Main entry ─────────────────────────────────────────────────────────
export function startCloudAgent(userConfig: CloudAgentConfig = {}): void {
const PORT = userConfig.port ?? Number(process.env.PORT ?? "2138");
const BRIDGE_PORT =
userConfig.bridgePort ?? Number(process.env.BRIDGE_PORT ?? "18790");
const BRIDGE_SECRET = userConfig.bridgeSecret || crypto.randomUUID();
const bridgeSecretGenerated = !userConfig.bridgeSecret;
const MAX_BODY_BYTES = userConfig.maxBodyBytes ?? 1_048_576;
const MAX_MEMORIES = userConfig.maxMemories ?? 0;
const enableChatMode = userConfig.enableChatMode ?? false;
let agentRuntime: AgentRuntime | null = null;
/** In-memory state that persists across snapshots. */
const state = {
memories: [] as Array<Record<string, unknown>>,
config: {} as Record<string, unknown>,
workspaceFiles: {} as Record<string, string>,
startedAt: new Date().toISOString(),
lastActivityAt: new Date().toISOString(),
};
/** Trim memories array to MAX_MEMORIES, removing oldest entries first. */
function trimMemories(): void {
if (MAX_MEMORIES > 0 && state.memories.length > MAX_MEMORIES) {
state.memories.splice(0, state.memories.length - MAX_MEMORIES);
}
}
function readBody(req: http.IncomingMessage): Promise<string> {
return new Promise<string>((resolve, reject) => {
let body = "";
let totalBytes = 0;
req.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
if (MAX_BODY_BYTES > 0 && totalBytes > MAX_BODY_BYTES) {
req.destroy();
reject(new Error("Request body too large"));
return;
}
body += chunk;
});
req.on("end", () => resolve(body));
req.on("error", reject);
});
}
// ─── elizaOS Runtime ──────────────────────────────────────────────────
async function initRuntime(): Promise<void> {
const elizaAvailable = await import("@elizaos/core")
.then(() => true)
.catch(() => false);
if (elizaAvailable) {
const {
AgentRuntime: AgentRuntimeCtor,
createCharacter,
createMessageMemory,
stringToUuid,
ChannelType,
} = await import("@elizaos/core");
const character = createCharacter({
name: process.env.AGENT_NAME ?? "CloudAgent",
bio: "An elizaOS agent running in the cloud.",
settings: {
...(process.env.DATABASE_URL
? {
POSTGRES_URL: process.env.DATABASE_URL,
DATABASE_URL: process.env.DATABASE_URL,
}
: {}),
},
secrets: {
...(process.env.ELIZAOS_CLOUD_API_KEY
? { ELIZAOS_CLOUD_API_KEY: process.env.ELIZAOS_CLOUD_API_KEY }
: {}),
...(process.env.OPENAI_API_KEY
? { OPENAI_API_KEY: process.env.OPENAI_API_KEY }
: {}),
...(process.env.ANTHROPIC_API_KEY
? { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY }
: {}),
...(process.env.GOOGLE_API_KEY
? { GOOGLE_API_KEY: process.env.GOOGLE_API_KEY }
: {}),
...(process.env.XAI_API_KEY
? { XAI_API_KEY: process.env.XAI_API_KEY }
: {}),
...(process.env.GROQ_API_KEY
? { GROQ_API_KEY: process.env.GROQ_API_KEY }
: {}),
},
});
const plugins = [];
const cloudPlugin = await import("@elizaos/plugin-elizacloud")
.then((m) => m.default)
.catch(logPluginLoadFailure("@elizaos/plugin-elizacloud"));
if (cloudPlugin) plugins.push(cloudPlugin);
const sqlPlugin = await import("@elizaos/plugin-sql")
.then((m) => m.default)
.catch(logPluginLoadFailure("@elizaos/plugin-sql"));
if (sqlPlugin) plugins.push(sqlPlugin);
const workflowPlugin = await import("@elizaos/plugin-workflow")
.then((m) => m.default)
.catch(logPluginLoadFailure("@elizaos/plugin-workflow"));
if (workflowPlugin) plugins.push(workflowPlugin);
const runtime = new AgentRuntimeCtor({ character, plugins });
await runtime.initialize();
const runtimeWithBridge = runtime as typeof runtime & {
ensureWorldExists?: (world: Record<string, unknown>) => Promise<void>;
ensureRoomExists?: (room: Record<string, unknown>) => Promise<void>;
ensureParticipantInRoom?: (
entityId: ReturnType<typeof stringToUuid>,
roomId: ReturnType<typeof stringToUuid>,
) => Promise<void>;
getEntityById?: (
entityId: ReturnType<typeof stringToUuid>,
) => Promise<Record<string, unknown> | null>;
createEntity?: (entity: Record<string, unknown>) => Promise<void>;
updateEntity?: (entity: Record<string, unknown>) => Promise<void>;
};
const ensureBridgeContext = async (params: BridgeRpcParams) => {
const normalized = normalizeBridgeMessage(params);
const worldId = stringToUuid(
`${normalized.source}-${normalized.channelType.toLowerCase()}-world`,
);
const serverId = stringToUuid(`${normalized.source}-bridge-server`);
const roomId = stringToUuid(
`${normalized.source}-bridge-room-${normalized.roomKey}`,
);
const entityKey =
normalized.sender?.id ??
normalized.sender?.username ??
`${normalized.source}-bridge-user`;
const entityId = stringToUuid(
`${normalized.source}-bridge-user-${entityKey}`,
);
const channelType =
normalized.channelType === "GROUP"
? ChannelType.GROUP
: ChannelType.DM;
const displayName =
normalized.sender?.displayName ??
normalized.sender?.username ??
"BridgeUser";
if (typeof runtimeWithBridge.ensureWorldExists === "function") {
await runtimeWithBridge.ensureWorldExists({
id: worldId,
name: normalized.source === "discord" ? "Discord" : "Cloud Bridge",
agentId: runtime.agentId,
serverId,
});
}
if (typeof runtimeWithBridge.ensureRoomExists === "function") {
await runtimeWithBridge.ensureRoomExists({
id: roomId,
name: normalized.roomKey,
type: channelType,
channelId: normalized.roomKey,
worldId,
serverId,
agentId: runtime.agentId,
source: normalized.source,
});
}
const entityMetadata =
normalized.sender?.metadata &&
typeof normalized.sender.metadata === "object" &&
!Array.isArray(normalized.sender.metadata)
? normalized.sender.metadata
: undefined;
const entityPayload = {
id: entityId,
agentId: runtime.agentId,
names: Array.from(
new Set(
[displayName, normalized.sender?.username].filter(
(value): value is string => Boolean(value),
),
),
),
...(entityMetadata ? { metadata: entityMetadata } : {}),
};
try {
if (
typeof runtimeWithBridge.getEntityById === "function" &&
typeof runtimeWithBridge.updateEntity === "function"
) {
const existingEntity =
await runtimeWithBridge.getEntityById(entityId);
if (existingEntity) {
await runtimeWithBridge.updateEntity({
...existingEntity,
...entityPayload,
names:
entityPayload.names.length > 0
? entityPayload.names
: ((existingEntity.names as string[] | undefined) ?? []),
});
} else if (typeof runtimeWithBridge.createEntity === "function") {
await runtimeWithBridge.createEntity(entityPayload);
}
} else if (typeof runtimeWithBridge.createEntity === "function") {
await runtimeWithBridge.createEntity(entityPayload);
}
} catch {
// Best-effort entity sync. The room flow still works if the entity already exists.
}
if (typeof runtimeWithBridge.ensureParticipantInRoom === "function") {
await Promise.all([
runtimeWithBridge.ensureParticipantInRoom(runtime.agentId, roomId),
runtimeWithBridge.ensureParticipantInRoom(entityId, roomId),
]);
}
return { normalized, entityId, roomId, channelType };
};
agentRuntime = {
processMessage: async (
params: BridgeRpcParams,
): Promise<BridgeMessageResult> => {
const { normalized, entityId, roomId, channelType } =
await ensureBridgeContext(params);
const message = createMessageMemory({
id: crypto.randomUUID() as ReturnType<typeof stringToUuid>,
entityId,
roomId,
content: {
text: normalized.text,
...(enableChatMode
? {
mode: normalized.mode,
simple: normalized.mode === "simple",
}
: {}),
source: normalized.source,
channelType,
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
},
});
const response: BridgeMessageResult = { text: "" };
await runtime.messageService?.handleMessage(
runtime,
message,
async (content) => {
appendBridgeCallbackContent(
response,
content as BridgeCallbackContent,
);
return [];
},
);
state.lastActivityAt = new Date().toISOString();
state.memories.push({
role: "user",
text: normalized.text,
timestamp: Date.now(),
source: normalized.source,
roomId: normalized.roomKey,
});
state.memories.push({
role: "assistant",
text: bridgeResultText(response),
timestamp: Date.now(),
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
});
trimMemories();
return {
text: bridgeResultText(response),
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
};
},
processMessageStream: async (
params: BridgeRpcParams,
onChunk: (chunk: string) => void,
): Promise<BridgeMessageResult> => {
const { normalized, entityId, roomId, channelType } =
await ensureBridgeContext(params);
const message = createMessageMemory({
id: crypto.randomUUID() as ReturnType<typeof stringToUuid>,
entityId,
roomId,
content: {
text: normalized.text,
...(enableChatMode
? {
mode: normalized.mode,
simple: normalized.mode === "simple",
}
: {}),
source: normalized.source,
channelType,
...(normalized.metadata ? { metadata: normalized.metadata } : {}),
},
});
const response: BridgeMessageResult = { text: "" };
await runtime.messageService?.handleMessage(
runtime,
message,
async (content) => {
const previousTextLength = response.text.length;
appendBridgeCallbackContent(
response,
content as BridgeCallbackContent,
);
if (response.text.length > previousTextLength) {
onChunk(response.text.slice(previousTextLength));
}
return [];
},
);
state.lastActivityAt = new Date().toISOString();
state.memories.push({
role: "user",
text: normalized.text,
timestamp: Date.now(),
source: normalized.source,
roomId: normalized.roomKey,
});
state.memories.push({
role: "assistant",
text: bridgeResultText(response),
timestamp: Date.now(),
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
});
trimMemories();
return {
text: bridgeResultText(response),
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
};
},
getMemories: () => state.memories,
getConfig: () => state.config,
};
logger.info("elizaOS runtime initialized with real agent");
} else {
logger.warn("@elizaos/core not available, running in echo mode");
agentRuntime = {
processMessage: async (
params: BridgeRpcParams,
): Promise<BridgeMessageResult> => {
const normalized = normalizeBridgeMessage(params);
state.memories.push({
role: "user",
text: normalized.text,
timestamp: Date.now(),
source: normalized.source,
roomId: normalized.roomKey,
});
const reply = `[echo] ${normalized.text}`;
state.memories.push({
role: "assistant",
text: reply,
timestamp: Date.now(),
});
trimMemories();
return { text: reply };
},
processMessageStream: async (
params: BridgeRpcParams,
onChunk: (chunk: string) => void,
): Promise<BridgeMessageResult> => {
const normalized = normalizeBridgeMessage(params);
state.memories.push({
role: "user",
text: normalized.text,
timestamp: Date.now(),
source: normalized.source,
roomId: normalized.roomKey,
});
const reply = `[echo] ${normalized.text}`;
onChunk(reply);
state.memories.push({
role: "assistant",
text: reply,
timestamp: Date.now(),
});
trimMemories();
return { text: reply };
},
getMemories: () => state.memories,
getConfig: () => state.config,
};
}
}
// ─── Health endpoint ──────────────────────────────────────────────────
/** Consider the runtime hung if no activity for 10 minutes after init. */
const HUNG_RUNTIME_THRESHOLD_MS = 10 * 60_000;
const healthServer = http.createServer((req, res) => {
if (
req.method === "GET" &&
(req.url === "/health" || req.url === "/api/health")
) {
const lastActivityAge =
Date.now() - new Date(state.lastActivityAt).getTime();
const possiblyHung =
agentRuntime !== null &&
state.memories.length > 0 &&
lastActivityAge > HUNG_RUNTIME_THRESHOLD_MS;
let status: string;
if (!agentRuntime) {
status = "initializing";
} else if (possiblyHung) {
status = "possibly_hung";
} else {
status = "healthy";
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
status,
uptime: process.uptime(),
startedAt: state.startedAt,
lastActivityAt: state.lastActivityAt,
memoryUsage: process.memoryUsage().rss,
runtimeReady: agentRuntime !== null,
}),
);
return;
}
if (req.method === "GET" && req.url === "/") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
service: "elizaos-cloud-agent",
status: "running",
}),
);
return;
}
res.writeHead(404);
res.end("Not Found");
});
healthServer.listen(PORT, "0.0.0.0", () => {
logger.info(`Health endpoint listening on port ${PORT}`);
});
// ─── Bridge HTTP server ───────────────────────────────────────────────
const bridgeServer = http.createServer(async (req, res) => {
res.setHeader("Content-Type", "application/json");
// Auth check (only when BRIDGE_SECRET is configured)
if (BRIDGE_SECRET) {
const authHeader = req.headers.authorization ?? "";
if (authHeader !== `Bearer ${BRIDGE_SECRET}`) {
res.writeHead(401);
res.end(JSON.stringify({ error: "Unauthorized" }));
return;
}
}
if (req.method === "POST" && req.url === "/api/snapshot") {
res.writeHead(200);
res.end(
JSON.stringify({
memories: state.memories,
config: state.config,
workspaceFiles: state.workspaceFiles,
timestamp: new Date().toISOString(),
}),
);
return;
}
if (req.method === "POST" && req.url === "/api/restore") {
const body = await readBody(req);
let incoming: Partial<typeof state>;
try {
incoming = JSON.parse(body) as Partial<typeof state>;
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
if (incoming.memories) state.memories = incoming.memories;
if (incoming.config) state.config = incoming.config;
if (incoming.workspaceFiles)
state.workspaceFiles = incoming.workspaceFiles;
logger.info("State restored from snapshot");
res.writeHead(200);
res.end(JSON.stringify({ success: true }));
return;
}
// ── SSE streaming endpoint ────────────────────────────────────────
if (req.method === "POST" && req.url === "/bridge/stream") {
if (!agentRuntime) {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Agent runtime not ready" }));
return;
}
const body = await readBody(req);
let rpc: {
jsonrpc: string;
id?: string | number;
method?: string;
params?: BridgeRpcParams;
};
try {
rpc = JSON.parse(body);
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
if (rpc.method !== "message.send") {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "Only message.send is streamable" }));
return;
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
const sendEvent = (event: string, data: unknown) => {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
sendEvent("connected", { rpcId: rpc.id, timestamp: Date.now() });
const response = await agentRuntime.processMessageStream(
rpc.params ?? {},
(chunk: string) => {
sendEvent("chunk", { text: chunk });
},
);
sendEvent("done", {
rpcId: rpc.id,
timestamp: Date.now(),
...(response.failureKind ? { failureKind: response.failureKind } : {}),
});
res.end();
return;
}
if (req.method === "POST" && req.url === "/bridge") {
const body = await readBody(req);
let rpc: {
jsonrpc: string;
id?: string | number;
method?: string;
params?: BridgeRpcParams;
};
try {
rpc = JSON.parse(body);
} catch {
res.writeHead(400);
res.end(JSON.stringify({ error: "Invalid JSON" }));
return;
}
if (rpc.method === "message.send") {
if (!agentRuntime) {
res.writeHead(503);
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
error: {
code: -32000,
message: "Agent runtime not ready",
},
}),
);
return;
}
const response = await agentRuntime.processMessage(rpc.params ?? {});
res.writeHead(200);
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
result: {
text: response.text,
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
metadata: {
timestamp: Date.now(),
...(response.failureKind
? { failureKind: response.failureKind }
: {}),
},
},
}),
);
return;
}
if (rpc.method === "status.get") {
res.writeHead(200);
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
result: {
status: agentRuntime ? "running" : "initializing",
uptime: process.uptime(),
memoriesCount: state.memories.length,
startedAt: state.startedAt,
},
}),
);
return;
}
if (rpc.method === "heartbeat") {
res.writeHead(200);
res.end(
JSON.stringify({
jsonrpc: "2.0",
method: "heartbeat.ack",
params: { timestamp: Date.now() },
}),
);
return;
}
res.writeHead(200);
res.end(
JSON.stringify({
jsonrpc: "2.0",
id: rpc.id,
error: {
code: -32601,
message: `Method not found: ${rpc.method}`,
},
}),
);
return;
}
res.writeHead(404);
res.end(JSON.stringify({ error: "Not Found" }));
});
const bridgeBindAddress = bridgeSecretGenerated ? "127.0.0.1" : "0.0.0.0";
bridgeServer.listen(BRIDGE_PORT, bridgeBindAddress, () => {
logger.info(
`Bridge server listening on ${bridgeBindAddress}:${BRIDGE_PORT}`,
);
if (bridgeSecretGenerated) {
logger.warn(
"CRITICAL: No BRIDGE_SECRET configured — generated ephemeral secret and bound to 127.0.0.1 only",
);
logger.info(`Generated BRIDGE_SECRET: ${BRIDGE_SECRET}`);
}
});
// ─── Startup ──────────────────────────────────────────────────────────
function shutdown() {
logger.info("Shutting down...");
healthServer.close();
bridgeServer.close();
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
// Crash guards. This file is bundled by esbuild for the cloud-agent image,
// which only installs @elizaos/core + @elizaos/plugin-sql, so we cannot import
// @elizaos/shared's installProcessCrashGuards here — the guards are inlined.
// A rejected background promise must never take down the container; a truly
// uncaught exception exits non-zero so the orchestrator (Docker
// `--restart unless-stopped` / K8s `restartPolicy: Always`) relaunches a clean
// container. Exit code matches @elizaos/shared RESTART_EXIT_CODE.
process.on("unhandledRejection", (reason) => {
logger.error("Unhandled promise rejection (non-fatal)", {
err:
reason instanceof Error
? (reason.stack ?? reason.message)
: String(reason),
});
});
process.on("uncaughtException", (error) => {
logger.error("Uncaught exception — exiting for orchestrator restart", {
err:
error instanceof Error ? (error.stack ?? error.message) : String(error),
});
process.exit(CLOUD_AGENT_RESTART_EXIT_CODE);
});
initRuntime()
.then(() => {
logger.info("Ready");
})
.catch((err) => {
logger.error("Runtime init failed", {
err: err instanceof Error ? err.message : String(err),
});
process.exit(1);
});
}
@@ -0,0 +1,11 @@
/**
* Cloud Agent Entrypoint (template)
*
* Lightweight template variant — no auth, no chat mode, unlimited
* memories. Shipped inside the cloud-agent-template package for
* standalone deployment. All logic lives in ../cloud-agent-shared.ts.
*/
import { startCloudAgent } from "../cloud-agent-shared.ts";
startCloudAgent();
@@ -0,0 +1,14 @@
{
"name": "eliza-cloud-agent",
"type": "module",
"scripts": {
"start": "tsx entrypoint.ts"
},
"dependencies": {
"@elizaos/core": "workspace:*",
"@elizaos/plugin-sql": "workspace:*",
"@elizaos/plugin-elizacloud": "workspace:*",
"@elizaos/plugin-workflow": "workspace:*",
"tsx": "4.21.0"
}
}
@@ -0,0 +1,110 @@
/** Runs the Cloudflare proxy boundary for app-core hosted dashboard traffic. */
const DEFAULT_UPSTREAM_ORIGIN = "https://www.elizacloud.ai";
const DEFAULT_ALLOWED_ORIGINS: string[] = [];
type Env = {
ELIZA_CLOUD_ORIGIN?: string;
CF_ALLOWED_ORIGINS?: string;
CF_PROXY_PATH_PREFIXES?: string;
ALLOWED_ORIGINS?: string;
PROXY_PATH_PREFIXES?: string;
};
function resolveUpstreamOrigin(env: Env): string {
return (env.ELIZA_CLOUD_ORIGIN || DEFAULT_UPSTREAM_ORIGIN).replace(
/\/+$/,
"",
);
}
function resolveAllowedOrigins(env: Env): Set<string> {
const configured = (env.CF_ALLOWED_ORIGINS || env.ALLOWED_ORIGINS)
?.split(",")
.map((item) => item.trim())
.filter(Boolean);
return new Set(configured?.length ? configured : DEFAULT_ALLOWED_ORIGINS);
}
function resolveProxyPathPrefixes(env: Env): string[] {
return (env.CF_PROXY_PATH_PREFIXES || env.PROXY_PATH_PREFIXES || "")
.split(",")
.map((item) => item.trim())
.filter(Boolean);
}
function shouldProxyPath(pathname: string, pathPrefixes: string[]): boolean {
return pathPrefixes.some((prefix) =>
prefix.endsWith("/")
? pathname.startsWith(prefix)
: pathname === prefix || pathname.startsWith(`${prefix}/`),
);
}
function applyCorsHeaders(
responseHeaders: Headers,
requestOrigin: string | null,
allowedOrigins: Set<string>,
): void {
responseHeaders.set(
"Access-Control-Allow-Methods",
"GET,POST,DELETE,OPTIONS",
);
responseHeaders.set(
"Access-Control-Allow-Headers",
"Authorization, Content-Type, X-Service-Key, X-API-Key",
);
responseHeaders.set("Access-Control-Max-Age", "86400");
responseHeaders.set("Vary", "Origin");
if (requestOrigin && allowedOrigins.has(requestOrigin)) {
responseHeaders.set("Access-Control-Allow-Origin", requestOrigin);
} else {
responseHeaders.delete("Access-Control-Allow-Origin");
}
}
export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
const pathPrefixes = resolveProxyPathPrefixes(env);
if (!shouldProxyPath(url.pathname, pathPrefixes)) {
return new Response("Not found", { status: 404 });
}
const allowedOrigins = resolveAllowedOrigins(env);
const requestOrigin = request.headers.get("Origin");
if (request.method === "OPTIONS") {
const headers = new Headers();
applyCorsHeaders(headers, requestOrigin, allowedOrigins);
return new Response(null, { status: 204, headers });
}
const upstreamUrl = new URL(
`${url.pathname}${url.search}`,
`${resolveUpstreamOrigin(env)}/`,
);
const headers = new Headers(request.headers);
headers.set("host", upstreamUrl.host);
const upstreamResponse = await fetch(upstreamUrl.toString(), {
method: request.method,
headers,
body:
request.method === "GET" || request.method === "HEAD"
? undefined
: request.body,
redirect: "manual",
});
const responseHeaders = new Headers(upstreamResponse.headers);
applyCorsHeaders(responseHeaders, requestOrigin, allowedOrigins);
responseHeaders.delete("content-length");
return new Response(upstreamResponse.body, {
status: upstreamResponse.status,
statusText: upstreamResponse.statusText,
headers: responseHeaders,
});
},
};
@@ -0,0 +1,8 @@
name = "${APP_NAME}-eliza-cloud-proxy"
main = "worker.ts"
compatibility_date = "2026-03-15"
[vars]
ELIZA_CLOUD_ORIGIN = "https://www.elizacloud.ai"
CF_ALLOWED_ORIGINS = ""
CF_PROXY_PATH_PREFIXES = ""
+279
View File
@@ -0,0 +1,279 @@
#!/usr/bin/env bash
# deploy-to-nodes.sh — Deploy eliza/agent image to Docker nodes
#
# Usage:
# ./deploy/deploy-to-nodes.sh [OPTIONS]
#
# Options:
# --image TAG Image to deploy (default: eliza/agent:latest)
# --nodes LIST Comma-separated node list: name:ip (overrides defaults)
# --node NAME Deploy to a single node by name (agent-node-1 or nyx-node)
# --restart Restart all eliza containers after loading image
# --rolling Rolling restart (one container at a time, wait for healthy)
# --snapshot Create container snapshots before restarting (default when --restart)
# --no-snapshot Skip snapshot before restart
# --list Just list running containers, don't deploy
# --status Show image and container status on all nodes
# --dry-run Show what would be done
# -h, --help Show this help
#
# Examples:
# ./deploy/deploy-to-nodes.sh --status # Check current state
# ./deploy/deploy-to-nodes.sh --list # List running containers
# ./deploy/deploy-to-nodes.sh # Load image to all nodes
# ./deploy/deploy-to-nodes.sh --restart # Load + restart all containers
# ./deploy/deploy-to-nodes.sh --restart --rolling # Rolling restart
# ./deploy/deploy-to-nodes.sh --node agent-node-1 --list # List on one node
set -euo pipefail
# ── Config ────────────────────────────────────────────────────────────────────
DEFAULT_IMAGE="eliza/agent:latest"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/clawdnet_nodes}"
SSH_USER="${SSH_USER:-root}"
SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=15"
declare -A NODE_MAP=(
["agent-node-1"]="37.27.190.196"
["nyx-node"]="89.167.49.4"
)
ALL_NODES=("agent-node-1" "nyx-node")
# ── Defaults ──────────────────────────────────────────────────────────────────
IMAGE="$DEFAULT_IMAGE"
DO_RESTART=false
DO_ROLLING=false
DO_SNAPSHOT=true # default: snapshot before restart
DO_LIST=false
DO_STATUS=false
DRY_RUN=false
SELECTED_NODES=()
# ── Colors ────────────────────────────────────────────────────────────────────
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
log() { echo -e "${BLUE}[deploy]${NC} $*"; }
ok() { echo -e "${GREEN}[✓]${NC} $*"; }
warn() { echo -e "${YELLOW}[!]${NC} $*"; }
err() { echo -e "${RED}[✗]${NC} $*" >&2; }
hdr() { echo -e "\n${CYAN}━━━ $* ━━━${NC}"; }
ssh_cmd() {
local ip="$1"; shift
ssh $SSH_OPTS -i "$SSH_KEY" "${SSH_USER}@${ip}" "$@"
}
list_remote_images_cmd() {
cat <<'EOF'
docker images --format 'table {{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.ID}}\t{{.CreatedSince}}' | grep -E '^(eliza/agent|ghcr\.io/eliza-ai/agent|ghcr\.io/eliza-ai/eliza/agent):' || true
EOF
}
list_remote_containers_cmd() {
cat <<'EOF'
{
printf 'NAMES|IMAGE|STATUS|PORTS\n'
docker ps --format '{{.Names}}|{{.Image}}|{{.Status}}|{{.Ports}}'
} | awk -F'|' 'NR == 1 || $2 ~ /^(eliza\/agent|ghcr\.io\/eliza-ai\/agent|ghcr\.io\/eliza-ai\/eliza\/agent)(:|@)/'
EOF
}
list_remote_container_names_cmd() {
cat <<'EOF'
docker ps --format '{{.Names}}|{{.Image}}' | awk -F'|' '$2 ~ /^(eliza\/agent|ghcr\.io\/eliza-ai\/agent|ghcr\.io\/eliza-ai\/eliza\/agent)(:|@)/ { print $1 }'
EOF
}
# ── Parse Args ────────────────────────────────────────────────────────────────
while [[ $# -gt 0 ]]; do
case $1 in
--image) IMAGE="$2"; shift 2 ;;
--node)
if [[ -z "${NODE_MAP[$2]+x}" ]]; then
err "Unknown node: $2 (known: ${ALL_NODES[*]})"
exit 1
fi
SELECTED_NODES+=("$2"); shift 2 ;;
--restart) DO_RESTART=true; shift ;;
--rolling) DO_ROLLING=true; DO_RESTART=true; shift ;;
--snapshot) DO_SNAPSHOT=true; shift ;;
--no-snapshot) DO_SNAPSHOT=false; shift ;;
--list) DO_LIST=true; shift ;;
--status) DO_STATUS=true; shift ;;
--dry-run) DRY_RUN=true; shift ;;
-h|--help)
head -28 "$0" | grep '^#' | sed 's/^# \?//'
exit 0
;;
*)
err "Unknown option: $1"
exit 1
;;
esac
done
# Default to all nodes if none selected
if [[ ${#SELECTED_NODES[@]} -eq 0 ]]; then
SELECTED_NODES=("${ALL_NODES[@]}")
fi
# ── Status mode ───────────────────────────────────────────────────────────────
if $DO_STATUS; then
for node in "${SELECTED_NODES[@]}"; do
ip="${NODE_MAP[$node]}"
hdr "$node ($ip)"
echo -e "${YELLOW}Images:${NC}"
ssh_cmd "$ip" "$(list_remote_images_cmd)" 2>/dev/null || warn "Failed to connect"
echo -e "\n${YELLOW}Running containers:${NC}"
ssh_cmd "$ip" "$(list_remote_containers_cmd)" 2>/dev/null || true
done
exit 0
fi
# ── List mode ─────────────────────────────────────────────────────────────────
if $DO_LIST; then
for node in "${SELECTED_NODES[@]}"; do
ip="${NODE_MAP[$node]}"
hdr "$node ($ip)"
ssh_cmd "$ip" "$(list_remote_containers_cmd)" 2>/dev/null || warn "Failed to connect"
done
exit 0
fi
# ── Deploy image ──────────────────────────────────────────────────────────────
log "Image: ${YELLOW}${IMAGE}${NC}"
log "Nodes: ${YELLOW}${SELECTED_NODES[*]}${NC}"
if $DRY_RUN; then
warn "DRY RUN mode"
fi
# Check image exists locally
if ! docker image inspect "$IMAGE" &>/dev/null; then
err "Image $IMAGE not found locally. Build it first:"
echo " ./scripts/build-image.sh --tag latest"
exit 1
fi
# Save image to temp file
TMPFILE=$(mktemp /tmp/eliza-deploy-XXXXXX.tar)
trap "rm -f $TMPFILE" EXIT
if ! $DRY_RUN; then
log "Saving image to tarball..."
docker save "$IMAGE" > "$TMPFILE"
TAR_SIZE=$(du -h "$TMPFILE" | cut -f1)
ok "Saved ($TAR_SIZE)"
fi
# Push to each node
for node in "${SELECTED_NODES[@]}"; do
ip="${NODE_MAP[$node]}"
hdr "$node ($ip)"
if $DRY_RUN; then
echo " Would load $IMAGE"
if $DO_RESTART; then echo " Would restart eliza containers"; fi
continue
fi
# Load image
log "Loading image..."
LOAD_START=$(date +%s)
ssh_cmd "$ip" "docker load" < "$TMPFILE"
LOAD_END=$(date +%s)
ok "Loaded in $((LOAD_END - LOAD_START))s"
# Get running eliza containers
CONTAINERS=$(ssh_cmd "$ip" "$(list_remote_container_names_cmd)" 2>/dev/null || true)
if [[ -z "$CONTAINERS" ]]; then
warn "No running eliza containers on $node"
continue
fi
CONTAINER_COUNT=$(echo "$CONTAINERS" | wc -l)
log "Found ${YELLOW}${CONTAINER_COUNT}${NC} running container(s)"
echo "$CONTAINERS" | while read -r c; do echo " - $c"; done
# Restart containers if requested
if $DO_RESTART; then
echo "$CONTAINERS" | while read -r container; do
[[ -z "$container" ]] && continue
log "Restarting ${YELLOW}${container}${NC}..."
# Snapshot before restart
if $DO_SNAPSHOT; then
SNAP_NAME="${container}-pre-deploy-$(date +%Y%m%d-%H%M%S)"
log "Creating snapshot: $SNAP_NAME"
ssh_cmd "$ip" "docker commit $container $SNAP_NAME" 2>/dev/null && \
ok "Snapshot created" || \
warn "Snapshot failed (continuing anyway)"
fi
# Get container config for recreation
CONTAINER_ENV=$(ssh_cmd "$ip" "docker inspect --format '{{range .Config.Env}}--env {{.}} {{end}}' $container" 2>/dev/null || true)
CONTAINER_PORTS=$(ssh_cmd "$ip" "docker inspect --format '{{range \$p, \$conf := .NetworkSettings.Ports}}{{range \$conf}}-p {{.HostIp}}:{{.HostPort}}:{{index (split \$p \"/\") 0}} {{end}}{{end}}' $container" 2>/dev/null || true)
CONTAINER_VOLS=$(ssh_cmd "$ip" "docker inspect --format '{{range .Mounts}}-v {{.Source}}:{{.Destination}} {{end}}' $container" 2>/dev/null || true)
CONTAINER_NET=$(ssh_cmd "$ip" "docker inspect --format '{{range \$net, \$conf := .NetworkSettings.Networks}}--network {{\$net}} {{end}}' $container" 2>/dev/null || true)
CONTAINER_RESTART=$(ssh_cmd "$ip" "docker inspect --format '{{.HostConfig.RestartPolicy.Name}}' $container" 2>/dev/null || echo "unless-stopped")
# Stop old container
log "Stopping $container..."
ssh_cmd "$ip" "docker stop -t 30 $container" 2>/dev/null
ssh_cmd "$ip" "docker rm $container" 2>/dev/null
# Start new container
log "Starting $container with new image..."
ssh_cmd "$ip" "docker run -d \
--name $container \
--restart=$CONTAINER_RESTART \
$CONTAINER_ENV \
$CONTAINER_PORTS \
$CONTAINER_VOLS \
$CONTAINER_NET \
$IMAGE" 2>/dev/null
# Wait for healthy if rolling
if $DO_ROLLING; then
log "Waiting for $container to become healthy..."
for i in $(seq 1 60); do
STATUS=$(ssh_cmd "$ip" "docker inspect --format '{{.State.Health.Status}}' $container" 2>/dev/null || echo "unknown")
if [[ "$STATUS" == "healthy" ]]; then
ok "$container is healthy"
break
fi
if [[ "$STATUS" == "unhealthy" ]]; then
err "$container is unhealthy! Check logs:"
echo " ssh -i $SSH_KEY ${SSH_USER}@${ip} 'docker logs --tail 50 $container'"
break
fi
sleep 5
done
if [[ "$STATUS" != "healthy" && "$STATUS" != "unhealthy" ]]; then
warn "Timed out waiting for $container health check (status: $STATUS)"
fi
fi
ok "$container restarted"
done
fi
done
echo ""
ok "Deploy complete!"
if ! $DO_RESTART; then
echo ""
echo " Image loaded but containers not restarted."
echo " To restart containers:"
echo " $0 --restart"
echo " $0 --restart --rolling # one at a time"
fi
@@ -0,0 +1,27 @@
# Default deployment configuration for elizaOS apps.
APP_NAME=eliza
APP_ENTRYPOINT=packages/agent/dist/bin.js
# tsx is installed at /opt/tsx in the deploy images (Dockerfile.ci /
# Dockerfile.cloud), not at the workspace root, so the loader must be
# referenced by absolute path. A bare `--import tsx` fails at runtime with
# ERR_MODULE_NOT_FOUND ("Cannot find package 'tsx' imported from /app/").
APP_CMD_START="node --import /opt/tsx/node_modules/tsx/dist/loader.mjs packages/agent/dist/bin.js start"
APP_IMAGE=eliza:local
APP_REGISTRY=
APP_PORT=2138
APP_GATEWAY_PORT=18789
APP_BRIDGE_PORT=18790
APP_GATEWAY_BIND=lan
APP_STATE_DIR=/home/node/.eliza
APP_CONFIG_DIR="${HOME}/.eliza"
APP_WORKSPACE_DIR="${HOME}/.eliza/workspace"
APP_DB_NAME=eliza
APP_API_BIND=127.0.0.1
OCI_SOURCE=""
OCI_TITLE="elizaOS Agent"
OCI_DESCRIPTION="elizaOS agent runtime"
OCI_LICENSES=MIT
CF_WORKER_NAME=eliza-cloud-proxy
CF_ALLOWED_ORIGINS=
CF_PROXY_PATH_PREFIXES=
@@ -0,0 +1,33 @@
# Local Postgres for Eliza using Supabase's Postgres image.
#
# Eliza only needs a PostgreSQL database (plugin-sql). You do NOT need the
# full Supabase stack (auth/rest/realtime/storage) unless you want Studio.
#
# Usage:
# docker compose -f deploy/docker-compose.supabase-db.yml up -d
# # then set in ~/.eliza/eliza.json:
# # database.provider = "postgres"
# # database.postgres.connectionString = "postgresql://postgres:postgres@localhost:54322/eliza"
#
services:
eliza-db:
# If this tag ever fails to pull, try: supabase/postgres:latest
image: supabase/postgres:17.6.1.113
container_name: eliza-db
restart: unless-stopped
ports:
- "54322:5432"
environment:
POSTGRES_DB: eliza
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
volumes:
- eliza-db-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d eliza"]
interval: 5s
timeout: 3s
retries: 20
volumes:
eliza-db-data:
@@ -0,0 +1,57 @@
services:
eliza-gateway:
image: ${ELIZA_IMAGE:-eliza:local}
environment:
HOME: /home/node
TERM: xterm-256color
ELIZA_GATEWAY_TOKEN: ${ELIZA_GATEWAY_TOKEN}
ELIZA_ALLOWED_ORIGINS: ${ELIZA_ALLOWED_ORIGINS}
# Set ELIZA_API_TOKEN for non-localhost deployments to require auth on API routes
ELIZA_API_TOKEN: ${ELIZA_API_TOKEN:-}
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
volumes:
- ${ELIZA_CONFIG_DIR}:/home/node/.eliza
- ${ELIZA_WORKSPACE_DIR}:/home/node/.eliza/workspace
ports:
- "${ELIZA_GATEWAY_PORT:-18789}:${ELIZA_GATEWAY_PORT:-18789}"
- "${ELIZA_BRIDGE_PORT:-18790}:18790"
init: true
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${ELIZA_GATEWAY_PORT:-18789}/api/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
command:
[
"node",
"dist/index.js",
"gateway",
"--bind",
"${ELIZA_GATEWAY_BIND:-lan}",
"--port",
"${ELIZA_GATEWAY_PORT:-18789}",
]
eliza-cli:
image: ${ELIZA_IMAGE:-eliza:local}
environment:
HOME: /home/node
TERM: xterm-256color
ELIZA_GATEWAY_TOKEN: ${ELIZA_GATEWAY_TOKEN}
# Set ELIZA_API_TOKEN for non-localhost deployments to require auth on API routes
ELIZA_API_TOKEN: ${ELIZA_API_TOKEN:-}
BROWSER: echo
CLAUDE_AI_SESSION_KEY: ${CLAUDE_AI_SESSION_KEY}
CLAUDE_WEB_SESSION_KEY: ${CLAUDE_WEB_SESSION_KEY}
CLAUDE_WEB_COOKIE: ${CLAUDE_WEB_COOKIE}
volumes:
- ${ELIZA_CONFIG_DIR}:/home/node/.eliza
- ${ELIZA_WORKSPACE_DIR}:/home/node/.eliza/workspace
stdin_open: true
tty: true
init: true
entrypoint: ["node", "dist/index.js"]
+214
View File
@@ -0,0 +1,214 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
COMPOSE_FILE="$ROOT_DIR/docker-compose.yml"
EXTRA_COMPOSE_FILE="$ROOT_DIR/docker-compose.extra.yml"
IMAGE_NAME="${ELIZA_IMAGE:-eliza:local}"
EXTRA_MOUNTS="${ELIZA_EXTRA_MOUNTS:-}"
HOME_VOLUME_NAME="${ELIZA_HOME_VOLUME:-}"
require_cmd() {
if ! command -v "$1" >/dev/null 2>&1; then
echo "Missing dependency: $1" >&2
exit 1
fi
}
require_cmd docker
if ! docker compose version >/dev/null 2>&1; then
echo "Docker Compose not available (try: docker compose version)" >&2
exit 1
fi
ELIZA_CONFIG_DIR="${ELIZA_CONFIG_DIR:-$HOME/.eliza}"
ELIZA_WORKSPACE_DIR="${ELIZA_WORKSPACE_DIR:-$HOME/.eliza/workspace}"
mkdir -p "$ELIZA_CONFIG_DIR"
mkdir -p "$ELIZA_WORKSPACE_DIR"
export ELIZA_CONFIG_DIR
export ELIZA_WORKSPACE_DIR
export ELIZA_GATEWAY_PORT="${ELIZA_GATEWAY_PORT:-18789}"
export ELIZA_BRIDGE_PORT="${ELIZA_BRIDGE_PORT:-18790}"
export ELIZA_GATEWAY_BIND="${ELIZA_GATEWAY_BIND:-lan}"
export ELIZA_IMAGE="$IMAGE_NAME"
export ELIZA_DOCKER_APT_PACKAGES="${ELIZA_DOCKER_APT_PACKAGES:-}"
export ELIZA_EXTRA_MOUNTS="$EXTRA_MOUNTS"
export ELIZA_HOME_VOLUME="$HOME_VOLUME_NAME"
if [[ -z "${ELIZA_GATEWAY_TOKEN:-}" ]]; then
if command -v openssl >/dev/null 2>&1; then
ELIZA_GATEWAY_TOKEN="$(openssl rand -hex 32)"
else
ELIZA_GATEWAY_TOKEN="$(python3 - <<'PY'
import secrets
print(secrets.token_hex(32))
PY
)"
fi
fi
export ELIZA_GATEWAY_TOKEN
COMPOSE_FILES=("$COMPOSE_FILE")
COMPOSE_ARGS=()
write_extra_compose() {
local home_volume="$1"
shift
local -a mounts=("$@")
local mount
cat >"$EXTRA_COMPOSE_FILE" <<'YAML'
services:
eliza-gateway:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
printf ' - %s:/home/node\n' "$home_volume" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/.eliza\n' "$ELIZA_CONFIG_DIR" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/.eliza/workspace\n' "$ELIZA_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "${mounts[@]}"; do
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
cat >>"$EXTRA_COMPOSE_FILE" <<'YAML'
eliza-cli:
volumes:
YAML
if [[ -n "$home_volume" ]]; then
printf ' - %s:/home/node\n' "$home_volume" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/.eliza\n' "$ELIZA_CONFIG_DIR" >>"$EXTRA_COMPOSE_FILE"
printf ' - %s:/home/node/.eliza/workspace\n' "$ELIZA_WORKSPACE_DIR" >>"$EXTRA_COMPOSE_FILE"
fi
for mount in "${mounts[@]}"; do
printf ' - %s\n' "$mount" >>"$EXTRA_COMPOSE_FILE"
done
if [[ -n "$home_volume" && "$home_volume" != *"/"* ]]; then
cat >>"$EXTRA_COMPOSE_FILE" <<YAML
volumes:
${home_volume}:
YAML
fi
}
VALID_MOUNTS=()
if [[ -n "$EXTRA_MOUNTS" ]]; then
IFS=',' read -r -a mounts <<<"$EXTRA_MOUNTS"
for mount in "${mounts[@]}"; do
mount="${mount#"${mount%%[![:space:]]*}"}"
mount="${mount%"${mount##*[![:space:]]}"}"
if [[ -n "$mount" ]]; then
VALID_MOUNTS+=("$mount")
fi
done
fi
if [[ -n "$HOME_VOLUME_NAME" || ${#VALID_MOUNTS[@]} -gt 0 ]]; then
write_extra_compose "$HOME_VOLUME_NAME" "${VALID_MOUNTS[@]}"
COMPOSE_FILES+=("$EXTRA_COMPOSE_FILE")
fi
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_ARGS+=("-f" "$compose_file")
done
COMPOSE_HINT="docker compose"
for compose_file in "${COMPOSE_FILES[@]}"; do
COMPOSE_HINT+=" -f ${compose_file}"
done
ENV_FILE="$ROOT_DIR/.env"
upsert_env() {
local file="$1"
shift
local -a keys=("$@")
local tmp
tmp="$(mktemp)"
declare -A seen=()
if [[ -f "$file" ]]; then
while IFS= read -r line || [[ -n "$line" ]]; do
local key="${line%%=*}"
local replaced=false
for k in "${keys[@]}"; do
if [[ "$key" == "$k" ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
seen["$k"]=1
replaced=true
break
fi
done
if [[ "$replaced" == false ]]; then
printf '%s\n' "$line" >>"$tmp"
fi
done <"$file"
fi
for k in "${keys[@]}"; do
if [[ -z "${seen[$k]:-}" ]]; then
printf '%s=%s\n' "$k" "${!k-}" >>"$tmp"
fi
done
mv "$tmp" "$file"
}
upsert_env "$ENV_FILE" \
ELIZA_CONFIG_DIR \
ELIZA_WORKSPACE_DIR \
ELIZA_GATEWAY_PORT \
ELIZA_BRIDGE_PORT \
ELIZA_GATEWAY_BIND \
ELIZA_GATEWAY_TOKEN \
ELIZA_IMAGE \
ELIZA_EXTRA_MOUNTS \
ELIZA_HOME_VOLUME \
ELIZA_DOCKER_APT_PACKAGES
echo "==> Building Docker image: $IMAGE_NAME"
docker build \
--build-arg "ELIZA_DOCKER_APT_PACKAGES=${ELIZA_DOCKER_APT_PACKAGES}" \
-t "$IMAGE_NAME" \
-f "$ROOT_DIR/Dockerfile" \
"$ROOT_DIR"
echo ""
echo "==> Onboarding (interactive)"
echo "When prompted:"
echo " - Gateway bind: lan"
echo " - Gateway auth: token"
echo " - Gateway token: $ELIZA_GATEWAY_TOKEN"
echo " - Tailscale exposure: Off"
echo " - Install Gateway daemon: No"
echo ""
docker compose "${COMPOSE_ARGS[@]}" run --rm eliza-cli setup
echo ""
echo "==> Provider setup (optional)"
echo "WhatsApp (QR):"
echo " ${COMPOSE_HINT} run --rm eliza-cli channels login"
echo "Telegram (bot token):"
echo " ${COMPOSE_HINT} run --rm eliza-cli channels add --channel telegram --token <token>"
echo "Discord (bot token):"
echo " ${COMPOSE_HINT} run --rm eliza-cli channels add --channel discord --token <token>"
echo "Docs: https://docs.eliza.ai/channels"
echo ""
echo "==> Starting gateway"
docker compose "${COMPOSE_ARGS[@]}" up -d eliza-gateway
echo ""
echo "Gateway running with host port mapping."
echo "Access from tailnet devices via the host's tailnet IP."
echo "Config: $ELIZA_CONFIG_DIR"
echo "Workspace: $ELIZA_WORKSPACE_DIR"
echo "Token: $ELIZA_GATEWAY_TOKEN"
echo ""
echo "Commands:"
echo " ${COMPOSE_HINT} logs -f eliza-gateway"
echo " ${COMPOSE_HINT} exec eliza-gateway node dist/index.js health --token \"$ELIZA_GATEWAY_TOKEN\""
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "NodeNext",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"skipLibCheck": true,
"strict": true,
"jsx": "react-jsx"
}
}
+74
View File
@@ -0,0 +1,74 @@
{
"$schema": "https://unpkg.com/knip@5/schema.json",
"entry": [
"src/**/*.{ts,tsx}",
"scripts/**/*.{mjs,ts}",
"test/**/*.{ts,tsx}",
"platforms/**/*.{ts,tsx}",
"packaging/**/*.{mjs,ts}",
"vitest.config.ts",
"vitest.e2e.config.ts",
"playwright.config.ts"
],
"project": [
"src/**/*.{ts,tsx}",
"scripts/**/*.{mjs,ts}",
"test/**/*.{ts,tsx}",
"platforms/**/*.{ts,tsx}",
"packaging/**/*.{mjs,ts}",
"*.ts"
],
"ignore": [
"dist/**",
"test/contracts/**",
"platforms/**/build/**",
"platforms/**/artifacts/**"
],
"ignoreDependencies": [
"@capacitor/barcode-scanner",
"@capacitor/core",
"@capacitor/haptics",
"@capacitor/keyboard",
"@capacitor/preferences",
"@capacitor/push-notifications",
"@elizaos/agent",
"@elizaos/capacitor-llama",
"@elizaos/core",
"@elizaos/plugin-anthropic",
"@elizaos/plugin-aosp-local-inference",
"@elizaos/plugin-browser",
"@elizaos/plugin-edge-tts",
"@elizaos/plugin-elizacloud",
"@elizaos/plugin-groq",
"@elizaos/plugin-openai",
"@elizaos/plugin-sql",
"@elizaos/plugin-wechat",
"@elizaos/plugin-workflow",
"@elizaos/shared",
"@elizaos/ui",
"@elizaos/vault",
"@playwright/test",
"@types/react-dom",
"@types/three",
"@xyflow/react",
"discord.js",
"electrobun",
"electrobun/bun",
"electrobun/view",
"figlet",
"lucide-react",
"node:undici",
"nodemailer",
"playwright",
"puppeteer-core",
"tailwindcss"
],
"ignoreUnresolved": [
"tsx",
"../../src/config/app-config",
"../../src/i18n",
"../../eliza/agent/src/runtime/trajectory-storage"
],
"vite": false,
"vitest": false
}
+320
View File
@@ -0,0 +1,320 @@
{
"name": "@elizaos/app-core",
"version": "2.0.3-beta.7",
"description": "Shared application core for elizaOS white-label agent apps.",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/elizaOS/eliza",
"repository": {
"type": "git",
"url": "https://github.com/elizaOS/eliza.git",
"directory": "packages/app-core"
},
"keywords": [
"elizaos",
"app-core",
"react",
"white-label",
"agent-framework"
],
"main": "./dist/index.js",
"files": [
"dist"
],
"engines": {
"node": ">=24.0.0"
},
"scripts": {
"clean": "node ../scripts/rm-path-recursive.mjs dist",
"prepublishOnly": "bun run build:dist",
"build": "bun run build:dist",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "node ../scripts/run-vitest.mjs run --config vitest.config.ts",
"test:auth": "node ../scripts/run-vitest.mjs run --config vitest.config.ts --no-file-parallelism src/api/auth src/api/auth-bootstrap-routes src/services/auth-store src/cli/program/register.auth",
"test:local-provisioning": "node scripts/run-node-tsx.mjs scripts/check-real-local-provisioning.ts",
"test:local-chat": "node scripts/run-node-tsx.mjs scripts/check-real-local-chat.ts",
"test:local-reset": "node scripts/run-node-tsx.mjs scripts/check-real-local-reset.ts",
"serve:local-agent:e2e": "node scripts/run-node-tsx.mjs scripts/serve-real-local-agent.ts",
"lint": "bunx @biomejs/biome check --write src",
"lint:check": "bunx @biomejs/biome check src",
"format": "bunx @biomejs/biome format --write src",
"format:check": "bunx @biomejs/biome format src",
"build:dist": "node ../scripts/rm-path-recursive.mjs dist && tsc --noCheck -p tsconfig.build.json && node ../scripts/flatten-tsc-package-output.mjs packages/app-core && node ../scripts/copy-package-assets.mjs packages/app-core src/styles scripts platforms packaging patches test/scripts test/helpers && node ../scripts/prepare-package-dist.mjs packages/app-core && node ../scripts/rewrite-dist-relative-imports-node-esm.mjs packages/app-core",
"pack:dry-run": "cd dist && npm pack --dry-run",
"stability:suite": "node scripts/stability-suite.mjs",
"voice:latency-report": "node ./scripts/voice-latency-report.mjs",
"voice:interactive": "bun ./scripts/voice-interactive.mjs",
"voice:duet": "bun ./scripts/voice-duet.mjs",
"voice:create-profile": "bun ./scripts/voice-create-profile.mjs",
"build:fused-desktop": "node ./scripts/stage-desktop-fused-lib.mjs",
"build:fused-desktop:ensure": "node ./scripts/stage-desktop-fused-lib.mjs --ensure",
"build:fused-desktop:check": "node ./scripts/stage-desktop-fused-lib.mjs --check",
"build:flatpak": "node ./scripts/build-flatpak.mjs",
"build:flatpak:store": "ELIZA_BUILD_VARIANT=store node ./scripts/build-flatpak.mjs",
"build:flatpak:direct": "ELIZA_BUILD_VARIANT=direct node ./scripts/build-flatpak.mjs",
"audit:apple-store-sandbox": "node ./scripts/audit-apple-store-sandbox.mjs && node ./scripts/lib/apple-entitlement-audit.mjs",
"entitlements:audit": "node ./scripts/lib/apple-entitlement-audit.mjs",
"test:apple-entitlements": "node ../scripts/run-vitest.mjs run --config vitest.config.ts scripts/lib/apple-entitlement-audit.test.mjs",
"verify:android-native-plugins": "node scripts/verify-android-native-plugins.mjs",
"codesign:mas": "node ./scripts/codesign-mas.mjs",
"codesign:mas:dry-run": "node ./scripts/codesign-mas.mjs --dry-run",
"sms-gateway:audit": "node ./scripts/check-sms-gateway-completion-audit.mjs",
"sms-gateway:doctor": "node ./scripts/install-android-sms-gateway.mjs --doctor",
"sms-gateway:build:android": "node ./scripts/run-mobile-build.mjs android-sms-gateway",
"sms-gateway:continue": "node ./scripts/continue-sms-gateway-work.mjs",
"sms-gateway:deploy:cloud-prod": "node ./scripts/deploy-cloud-api-production-gateway.mjs",
"sms-gateway:homepage": "node ./scripts/check-homepage-public-readiness.mjs",
"sms-gateway:homepage:dns": "node ./scripts/sync-homepage-porkbun-dns.mjs",
"sms-gateway:install": "node ./scripts/install-android-sms-gateway.mjs --wait-device 300 --grant-role --clear-logcat --watch-logs 60",
"sms-gateway:maintain:cloud-prod": "node ./scripts/maintain-cloud-api-production-gateway.mjs",
"sms-gateway:pair": "node ./scripts/install-android-sms-gateway.mjs --pair auto --wait-pair 300 --connect auto --wait-device 60 --grant-role --clear-logcat --watch-logs 60",
"sms-gateway:readiness": "node ./scripts/check-sms-gateway-readiness.mjs",
"sms-gateway:status": "node ./scripts/sms-gateway-status.mjs",
"sms-gateway:test:software": "node ./scripts/test-sms-gateway-software.mjs",
"sms-gateway:validate:bluebubbles": "node ./scripts/validate-bluebubbles-outbound.mjs",
"sms-gateway:verify:cloud-onboarding": "node ./scripts/verify-cloud-sms-onboarding-flow.mjs",
"sms-gateway:verify:bluebubbles": "node ./scripts/verify-bluebubbles-gateway-e2e.mjs",
"sms-gateway:verify:cloud-prod": "node ./scripts/verify-cloud-api-production-deploy.mjs",
"sms-gateway:verify": "node ./scripts/verify-android-sms-gateway-e2e.mjs",
"sms-gateway:watch": "node ./scripts/watch-sms-gateway-readiness.mjs",
"sms-gateway:watch:pair": "node ./scripts/watch-sms-gateway-readiness.mjs --timeout 300 --interval 5 --run-install",
"test:app-real-e2e": "node ../scripts/run-vitest.mjs run --config vitest.app-real-e2e.config.ts"
},
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./entry": {
"types": "./dist/entry.d.ts",
"import": "./dist/entry.js",
"default": "./dist/entry.js"
},
"./account-pool": {
"types": "./src/account-pool.ts",
"eliza-source": {
"types": "./src/account-pool.ts",
"import": "./src/account-pool.ts",
"default": "./src/account-pool.ts"
},
"bun": {
"types": "./src/account-pool.ts",
"import": "./src/account-pool.ts",
"default": "./src/account-pool.ts"
},
"import": "./dist/account-pool.js",
"default": "./dist/account-pool.js"
},
"./api/automation-node-contributors": {
"types": "./dist/api/automation-node-contributors.d.ts",
"import": "./dist/api/automation-node-contributors.js",
"default": "./dist/api/automation-node-contributors.js"
},
"./api/auth": {
"types": "./dist/api/auth.d.ts",
"import": "./dist/api/auth.js",
"default": "./dist/api/auth.js"
},
"./api/cloud-pair-route": {
"types": "./dist/api/cloud-pair-route.d.ts",
"import": "./dist/api/cloud-pair-route.js",
"default": "./dist/api/cloud-pair-route.js"
},
"./api/compat-route-shared": {
"types": "./dist/api/compat-route-shared.d.ts",
"import": "./dist/api/compat-route-shared.js",
"default": "./dist/api/compat-route-shared.js"
},
"./api/ios-local-agent-transport": {
"types": "./dist/api/ios-local-agent-transport.d.ts",
"import": "./dist/api/ios-local-agent-transport.js",
"default": "./dist/api/ios-local-agent-transport.js"
},
"./api/response": {
"types": "./dist/api/response.d.ts",
"import": "./dist/api/response.js",
"default": "./dist/api/response.js"
},
"./first-run/first-run-config": {
"types": "./dist/first-run/first-run-config.d.ts",
"import": "./dist/first-run/first-run-config.js",
"default": "./dist/first-run/first-run-config.js"
},
"./registry": {
"types": "./dist/registry/index.d.ts",
"import": "./dist/registry/index.js",
"default": "./dist/registry/index.js"
},
"./services/vault-mirror": {
"types": "./dist/services/vault-mirror.d.ts",
"import": "./dist/services/vault-mirror.js",
"default": "./dist/services/vault-mirror.js"
},
"./security/agent-vault-id": {
"types": "./dist/security/agent-vault-id.d.ts",
"import": "./dist/security/agent-vault-id.js",
"default": "./dist/security/agent-vault-id.js"
},
"./security/platform-secure-store": {
"types": "./dist/security/platform-secure-store.d.ts",
"import": "./dist/security/platform-secure-store.js",
"default": "./dist/security/platform-secure-store.js"
},
"./security/platform-secure-store-node": {
"types": "./dist/security/platform-secure-store-node.d.ts",
"import": "./dist/security/platform-secure-store-node.js",
"default": "./dist/security/platform-secure-store-node.js"
},
"./services/steward-credentials": {
"types": "./dist/services/steward-credentials.d.ts",
"import": "./dist/services/steward-credentials.js",
"default": "./dist/services/steward-credentials.js"
},
"./services/steward-sidecar/helpers": {
"types": "./dist/services/steward-sidecar/helpers.d.ts",
"import": "./dist/services/steward-sidecar/helpers.js",
"default": "./dist/services/steward-sidecar/helpers.js"
},
"./services/task-host-capabilities": {
"types": "./dist/services/task-host-capabilities.d.ts",
"import": "./dist/services/task-host-capabilities.js",
"default": "./dist/services/task-host-capabilities.js"
},
"./services/app-updates/update-policy": {
"types": "./dist/services/app-updates/update-policy.d.ts",
"import": "./dist/services/app-updates/update-policy.js",
"default": "./dist/services/app-updates/update-policy.js"
},
"./platform/native-plugin-entrypoints": {
"types": "./dist/platform/native-plugin-entrypoints.d.ts",
"import": "./dist/platform/native-plugin-entrypoints.js",
"default": "./dist/platform/native-plugin-entrypoints.js"
},
"./platform/ios-runtime-backends": {
"types": "./dist/platform/ios-runtime-backends.d.ts",
"import": "./dist/platform/ios-runtime-backends.js",
"default": "./dist/platform/ios-runtime-backends.js"
},
"./platform/empty-node-module": {
"types": "./dist/platform/empty-node-module.d.ts",
"import": "./dist/platform/empty-node-module.js",
"default": "./dist/platform/empty-node-module.js"
},
"./platform/native-library-policy": {
"types": "./dist/platform/native-library-policy.d.ts",
"import": "./dist/platform/native-library-policy.js",
"default": "./dist/platform/native-library-policy.js"
},
"./ui-compat": {
"types": "./dist/ui-compat.d.ts",
"import": "./dist/ui-compat.js",
"default": "./dist/ui-compat.js"
}
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"three": "^0.184.0"
},
"optionalDependencies": {
"@elizaos/capacitor-appblocker": "workspace:*",
"@elizaos/capacitor-browser-surface": "workspace:*",
"@elizaos/capacitor-bun-runtime": "workspace:*",
"@elizaos/capacitor-camera": "workspace:*",
"@elizaos/capacitor-canvas": "workspace:*",
"@elizaos/capacitor-contacts": "workspace:*",
"@elizaos/capacitor-gateway": "workspace:*",
"@elizaos/capacitor-location": "workspace:*",
"@elizaos/capacitor-messages": "workspace:*",
"@elizaos/capacitor-mlkit-text": "workspace:*",
"@elizaos/capacitor-mobile-agent-bridge": "workspace:*",
"@elizaos/capacitor-mobile-signals": "workspace:*",
"@elizaos/capacitor-phone": "workspace:*",
"@elizaos/capacitor-screencapture": "workspace:*",
"@elizaos/capacitor-swabble": "workspace:*",
"@elizaos/capacitor-system": "workspace:*",
"@elizaos/capacitor-talkmode": "workspace:*",
"@elizaos/capacitor-websiteblocker": "workspace:*",
"sharp": "^0.34.5"
},
"dependencies": {
"@capacitor-community/sqlite": "^8.1.0",
"@capacitor/background-runner": "^3.0.0",
"@capacitor/barcode-scanner": "^3.0.0",
"@capacitor/core": "8.4.0",
"@capacitor/haptics": "8.0.2",
"@capacitor/keyboard": "8.0.3",
"@capacitor/local-notifications": "^8.0.0",
"@capacitor/preferences": "^8.0.1",
"@capacitor/push-notifications": "^8.0.0",
"@clack/prompts": "^1.0.0",
"@elizaos/agent": "workspace:*",
"@elizaos/contracts": "workspace:*",
"@elizaos/core": "workspace:*",
"@elizaos/plugin-anthropic": "workspace:*",
"@elizaos/plugin-browser": "workspace:*",
"@elizaos/plugin-edge-tts": "workspace:*",
"@elizaos/plugin-elizacloud": "workspace:*",
"@elizaos/plugin-groq": "workspace:*",
"@elizaos/plugin-health": "workspace:*",
"@elizaos/plugin-local-inference": "workspace:*",
"@elizaos/plugin-openai": "workspace:*",
"@elizaos/plugin-registry": "workspace:*",
"@elizaos/plugin-sql": "workspace:*",
"@elizaos/plugin-wechat": "workspace:*",
"@elizaos/plugin-workflow": "workspace:*",
"@elizaos/registry": "workspace:*",
"@elizaos/shared": "workspace:*",
"@elizaos/skills": "workspace:*",
"@elizaos/ui": "workspace:*",
"@elizaos/vault": "workspace:*",
"@node-rs/argon2": "^2.0.2",
"@upstash/redis": "^1.37.0",
"chalk": "^5.3.0",
"commander": "^15.0.0",
"dotenv": "^17.2.3",
"drizzle-orm": "0.45.2",
"ethers": "^6.16.0",
"jose": "^6.2.2",
"json5": "^2.2.3",
"undici": "8.5.0",
"ws": "^8.19.0",
"zod": "^4.4.3",
"@elizaos/auth": "workspace:*"
},
"publishConfig": {
"access": "public",
"directory": "dist"
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ws": "^8.18.1",
"ai": "^6.0.23",
"bun-types": "^1.3.12",
"playwright": "^1.59.1",
"playwright-core": "^1.58.2",
"puppeteer-core": "^24.43.1",
"react-test-renderer": "^19.2.4",
"sharp": "^0.34.5",
"tailwindcss": "^4.3.1",
"tsx": "^4.21.0",
"typescript": "^6.0.3",
"vite": "^8.0.8",
"vitest": "^4.0.18",
"yaml": "^2.8.4"
},
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"elizaos": {
"scripts": {
"coreBuild": true,
"testLanes": [
"server"
]
}
}
}
@@ -0,0 +1,707 @@
# Eliza — Package Publishing Guide
This guide covers the **human steps** required to publish Eliza across all five package managers. The packaging configs are ready — this document walks through account setup, credential configuration, and publishing commands.
---
## Table of Contents
1. [PyPI (eliza)](#1-pypi-eliza)
2. [Homebrew](#2-homebrew)
3. [apt (Debian/Ubuntu)](#3-apt-debianubuntu)
4. [Snap](#4-snap)
5. [Flatpak](#5-flatpak)
6. [Google Play Store (Android)](#6-google-play-store-android)
7. [CI/CD Automation](#7-cicd-automation)
8. [iOS App Store](#8-ios-app-store)
9. [Mac App Store](#9-mac-app-store)
10. [Version Bumping Checklist](#10-version-bumping-checklist)
---
## 1. PyPI (eliza)
The `eliza` package on PyPI is a **dynamic loader** — a thin Python wrapper that auto-installs and delegates to the Node.js eliza CLI.
### 1.1 Account Setup (one-time)
1. **Create a PyPI account** at https://pypi.org/account/register/
2. **Enable 2FA** (required for new projects) at https://pypi.org/manage/account/two-factor/
3. **Create an API token**:
- Go to https://pypi.org/manage/account/token/
- Scope: "Entire account" (for first upload) or project-scoped after first publish
- Save the token — it starts with `pypi-`
4. **Configure credentials** locally:
```bash
# Option A: Using a ~/.pypirc file
cat > ~/.pypirc << 'EOF'
[distutils]
index-servers = pypi
[pypi]
username = __token__
password = pypi-YOUR_TOKEN_HERE
EOF
chmod 600 ~/.pypirc
```
```bash
# Option B: Environment variable (better for CI)
export TWINE_USERNAME=__token__
export TWINE_PASSWORD=pypi-YOUR_TOKEN_HERE
```
### 1.2 Test on TestPyPI First (recommended)
1. Create account at https://test.pypi.org/account/register/
2. Create API token at https://test.pypi.org/manage/account/token/
```bash
cd packaging/pypi
# Install build tools
pip install build twine
# Build the package
python -m build
# Upload to TestPyPI
twine upload --repository testpypi dist/*
# Test installation from TestPyPI
pip install --index-url https://test.pypi.org/simple/ eliza
eliza --help
```
### 1.3 Publish to PyPI
```bash
cd packaging/pypi
# Build
python -m build
# Upload (uses ~/.pypirc or TWINE env vars)
twine upload dist/*
# Verify
pip install eliza
eliza --version
```
### 1.4 Reserve the Package Name
If you want to claim the `eliza` name immediately before the full release:
```bash
cd packaging/pypi
python -m build
twine upload dist/*
```
The beta version (`2.0.0b0`) is fine for name reservation.
---
## 2. Homebrew
### 2.1 Create the Tap Repository (one-time)
A Homebrew "tap" is just a GitHub repo with a naming convention.
1. **Create a GitHub repo** named `homebrew-tap` under the `elizaOS` org:
- URL will be: `https://github.com/elizaOS/homebrew-tap`
2. **Initialize the repo**:
```bash
# Clone and set up
git clone https://github.com/elizaOS/homebrew-tap.git
cd homebrew-tap
# Copy the formula
mkdir -p Formula
cp /path/to/eliza/packaging/homebrew/eliza.rb Formula/eliza.rb
```
3. **Get the SHA256 hash** of the npm tarball:
```bash
# Download the tarball and compute hash
curl -fsSL "https://registry.npmjs.org/elizaos/-/elizaos-2.0.0-beta.0.tgz" -o eliza.tgz
shasum -a 256 eliza.tgz
# Replace PLACEHOLDER_SHA256 in eliza.rb with the actual hash
```
4. **Push the formula**:
```bash
git add Formula/eliza.rb
git commit -m "Add eliza formula"
git push origin main
```
### 2.2 Test the Formula
```bash
# Test locally before pushing
brew install --build-from-source Formula/eliza.rb
# Or after pushing to the tap repo
brew tap elizaOS/tap
brew install eliza
```
### 2.3 Users Install With
```bash
brew tap elizaOS/tap
brew install eliza
```
Or one-liner:
```bash
brew install elizaOS/tap/eliza
```
### 2.4 Updating for New Releases
```bash
# Compute new SHA256
curl -fsSL "https://registry.npmjs.org/elizaai/-/elizaai-NEW_VERSION.tgz" -o eliza.tgz
shasum -a 256 eliza.tgz
# Update the formula: change url and sha256
# Push to homebrew-tap repo
```
---
## 3. apt (Debian/Ubuntu)
There are two approaches: a **PPA** (easier, Ubuntu-focused) or a **self-hosted apt repo** (works with all Debian-based distros).
### 3.1 Option A: Launchpad PPA (Ubuntu)
1. **Create a Launchpad account** at https://launchpad.net/+login
2. **Create a GPG key** and upload to Launchpad:
```bash
# Generate a GPG key
gpg --full-generate-key
# Choose RSA, 4096 bits, email matching your Launchpad account
# Upload to keyserver
gpg --send-keys YOUR_KEY_ID
# Add to Launchpad at https://launchpad.net/~/+editpgpkeys
```
3. **Create a PPA**:
- Go to https://launchpad.net/~/+activate-ppa
- Name: `eliza`
- Display name: "Eliza — Personal AI Assistant"
4. **Build and upload the source package**:
```bash
cd /path/to/eliza
# Copy debian/ packaging into place
cp -r packaging/debian .
# Build the source package
dpkg-buildpackage -S -sa -k"YOUR_GPG_KEY_ID"
# Upload to PPA
dput ppa:YOUR_USERNAME/eliza ../eliza_2.0.0~beta0-1_source.changes
```
5. **Users install with**:
```bash
sudo add-apt-repository ppa:YOUR_USERNAME/eliza
sudo apt update
sudo apt install eliza
```
### 3.2 Option B: Self-Hosted apt Repository
This gives you more control and works across all Debian-based distros.
1. **Build the .deb package**:
```bash
cd /path/to/eliza
cp -r packaging/debian .
# Install build dependencies
sudo apt install debhelper nodejs npm
# Build the package
dpkg-buildpackage -us -uc -b
# The .deb will be in the parent directory
ls ../eliza_*.deb
```
2. **Set up a repo using GitHub Pages or a server**:
```bash
# Create repo structure
mkdir -p apt-repo/pool/main/m/eliza
mkdir -p apt-repo/dists/stable/main/binary-amd64
# Copy the .deb
cp ../eliza_*.deb apt-repo/pool/main/m/eliza/
# Generate Packages index
cd apt-repo
dpkg-scanpackages pool/ /dev/null | gzip -9c > dists/stable/main/binary-amd64/Packages.gz
dpkg-scanpackages pool/ /dev/null > dists/stable/main/binary-amd64/Packages
# Create Release file
cd dists/stable
apt-ftparchive release . > Release
# Sign with GPG
gpg --armor --detach-sign -o Release.gpg Release
gpg --armor --clearsign -o InRelease Release
```
3. **Host the repo** (GitHub Pages, S3, Cloudflare R2, etc.)
4. **Users install with**:
```bash
# Add the GPG key
curl -fsSL https://apt.eliza.ai/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/eliza.gpg
# Add the repo
echo "deb [signed-by=/usr/share/keyrings/eliza.gpg] https://apt.eliza.ai stable main" | \
sudo tee /etc/apt/sources.list.d/eliza.list
sudo apt update
sudo apt install eliza
```
---
## 4. Snap
### 4.1 Account Setup (one-time)
1. **Create a Snapcraft account** at https://snapcraft.io/account
- Uses Ubuntu One SSO
2. **Install snapcraft**:
```bash
sudo snap install snapcraft --classic
```
3. **Login**:
```bash
snapcraft login
```
4. **Register the snap name**:
```bash
snapcraft register eliza
```
### 4.2 Build the Snap
```bash
cd /path/to/eliza
# Copy snapcraft.yaml into place
mkdir -p snap
cp packaging/snap/snapcraft.yaml snap/
# Build the snap (requires LXD or Multipass)
snapcraft
# This produces: eliza_2.0.0-beta.0_amd64.snap
```
### 4.3 Test Locally
```bash
# Install the local snap
sudo snap install eliza_*.snap --classic --dangerous
# Test
eliza --version
eliza --help
```
### 4.4 Publish to Snap Store
```bash
# Upload to edge channel first
snapcraft upload eliza_*.snap --release=edge
# After testing, promote to stable
snapcraft release eliza <revision> stable
```
### 4.5 Users Install With
```bash
sudo snap install eliza --classic
```
---
## 5. Flatpak
### 5.1 Setup (one-time)
1. **Install Flatpak build tools**:
```bash
# Debian/Ubuntu
sudo apt install flatpak flatpak-builder
# Fedora
sudo dnf install flatpak flatpak-builder
```
2. **Install the SDK**:
```bash
flatpak install flathub org.freedesktop.Platform//23.08
flatpak install flathub org.freedesktop.Sdk//23.08
```
3. **Create a Flathub account** (for Flathub distribution):
- Submit at https://github.com/flathub/flathub/issues/new
- Or self-host a Flatpak repo
### 5.2 Update SHA256 Hashes
Before building, you need the actual SHA256 hashes for the Node.js binaries:
```bash
# x86_64
curl -fsSL "https://nodejs.org/dist/v22.12.0/node-v22.12.0-linux-x64.tar.xz" -o node-x64.tar.xz
shasum -a 256 node-x64.tar.xz
# Replace PLACEHOLDER_SHA256_X64 in the manifest
# ARM64
curl -fsSL "https://nodejs.org/dist/v22.12.0/node-v22.12.0-linux-arm64.tar.xz" -o node-arm64.tar.xz
shasum -a 256 node-arm64.tar.xz
# Replace PLACEHOLDER_SHA256_ARM64 in the manifest
```
### 5.3 Build the Flatpak
```bash
cd packaging/flatpak
# Build
flatpak-builder --repo=repo build-dir ai.elizaos.App.yml
# Create a bundle for testing
flatpak build-bundle repo eliza.flatpak ai.elizaos.App
```
### 5.4 Test Locally
```bash
# Install from local bundle
flatpak --user install eliza.flatpak
# Run
flatpak run ai.elizaos.App --version
flatpak run ai.elizaos.App start
```
### 5.5 Publish to Flathub
1. Fork https://github.com/flathub/flathub
2. Create a new repo: `github.com/flathub/ai.elizaos.App`
3. Add the manifest and supporting files
4. Submit a PR — Flathub maintainers will review
### 5.6 Users Install With
```bash
flatpak install flathub ai.elizaos.App
flatpak run ai.elizaos.App start
```
---
## 6. Google Play Store (Android)
### 6.1 Account Setup (one-time)
1. **Create a Google Play Developer account** at https://play.google.com/console/signup
- One-time $25 registration fee
- Requires identity verification
2. **Create the app listing**:
- Go to Google Play Console → "Create app"
- App name: "Eliza"
- Default language: English (United States)
- App type: App
- Free / Paid: Free
3. **Set up Google Play App Signing**:
- Go to Release → Setup → App signing
- Choose "Let Google manage and protect your app signing key" (recommended)
- Generate an **upload keystore** for CI:
```bash
keytool -genkeypair -alias eliza-upload -keyalg RSA -keysize 2048 -validity 10000 -keystore eliza-upload.jks -storepass YOUR_STORE_PASSWORD -dname "CN=Eliza AI, O=elizaOS, L=Internet, C=US"
```
4. **Upload the upload key certificate** to Play Console:
```bash
keytool -export -alias eliza-upload -keystore eliza-upload.jks -rfc > eliza-upload-cert.pem
```
Upload `eliza-upload-cert.pem` in Play Console → App signing.
5. **Create a service account for CI**:
- Go to Play Console → Setup → API access
- Link to Google Cloud project
- Create a service account with "Release manager" role
- Download the JSON key file
### 6.2 Required GitHub Secrets
| Secret | Description |
|---|---|
| `ANDROID_KEYSTORE_BASE64` | `base64 -w0 eliza-upload.jks` |
| `ANDROID_KEYSTORE_PASSWORD` | Keystore password |
| `ANDROID_KEY_ALIAS` | `eliza-upload` |
| `ANDROID_KEY_PASSWORD` | Key password |
| `PLAY_STORE_SERVICE_ACCOUNT_JSON` | `base64 -w0 play-store-key.json` |
### 6.3 Build the AAB Locally
```bash
cd apps/app
# Build web assets
bun run build
# Sync to Android
npx cap sync android
# Build signed AAB
cd android
ELIZA_KEYSTORE_PATH=/path/to/eliza-upload.jks ELIZA_KEYSTORE_PASSWORD=yourpass ELIZA_KEY_ALIAS=eliza-upload ELIZA_KEY_PASSWORD=yourpass ./gradlew bundleRelease
# AAB is at app/build/outputs/bundle/release/app-release.aab
```
### 6.4 Publish via Fastlane
```bash
cd apps/app/android
# Install Fastlane
bundle install
# Upload to internal testing
PLAY_STORE_JSON_KEY=/path/to/play-store-key.json ELIZA_KEYSTORE_PATH=/path/to/eliza-upload.jks ELIZA_KEYSTORE_PASSWORD=yourpass ELIZA_KEY_ALIAS=eliza-upload ELIZA_KEY_PASSWORD=yourpass bundle exec fastlane internal
# Promote to beta
bundle exec fastlane beta
# Promote to production
bundle exec fastlane production
```
### 6.5 Store Listing Checklist
Complete these in Play Console before first release:
- [ ] App name and description (`fastlane/metadata/android/en-US/`)
- [ ] Feature graphic (1024x500px)
- [ ] App icon (512x512px)
- [ ] Phone screenshots (minimum 2, 16:9 or 9:16)
- [ ] Privacy policy URL
- [ ] Data safety section (declare: network access, API keys stored locally)
- [ ] Content rating (IARC questionnaire)
- [ ] Target audience declaration
- [ ] App category: Tools → Productivity
### 6.6 Data Safety Declarations
| Question | Answer |
|---|---|
| Does the app collect data? | Yes (user-provided API keys, chat messages) |
| Is data shared with third parties? | Yes (AI providers: Anthropic, OpenAI, etc. — user-selected) |
| Is data encrypted in transit? | Yes (HTTPS to all AI providers) |
| Can users request data deletion? | Yes (local data, users delete the app or clear data) |
| Data stored on device | API keys, chat history, agent configuration |
| Data sent to servers | Chat messages to user-selected AI provider |
## 7. CI/CD Automation
### GitHub Actions release topology
The repo now uses a two-stage release model:
1. **`agent-release.yml`** validates the heavy build matrix and publishes the GitHub Release only after the blocking lanes are green.
2. **`release-orchestrator.yml`** handles post-release distribution and fans out to reusable child workflows:
- `publish-npm.yml`
- `publish-packages.yml`
- `android-release.yml`
- `apple-store-release.yml`
- `update-homebrew.yml`
- `deploy-web.yml`
Why this split exists:
- A published GitHub Release is the single durable release event.
- Store-specific retries should not require retagging or rebuilding Electrobun.
- Stable vs pre-release routing differs by channel:
- npm: `latest` for stable, `next` / `beta` / `nightly` for prereleases
- Android: `production` for stable, `internal` for prereleases
- Apple: `app-store` for stable, `testflight` for prereleases
- Flatpak and Homebrew: stable-only by default
Manual recovery path:
```bash
# Re-run only the post-release distribution layer for an existing release
gh workflow run release-orchestrator.yml -f version=2.0.0-beta.0
```
### Required GitHub Secrets
| Secret | Where to get it | Used by |
|---|---|---|
| `SNAP_STORE_CREDENTIALS` | `snapcraft export-login --snaps=eliza --acls=package_push -` | Snap publishing |
| `HOMEBREW_TAP_TOKEN` | GitHub PAT with `repo` scope for `elizaOS/homebrew-tap` | Homebrew formula updates |
| `PYPI_API_TOKEN` | https://pypi.org/manage/account/token/ (or use trusted publishing) | PyPI uploads |
| `ANDROID_KEYSTORE_BASE64` | `base64 -w0 eliza-upload.jks` | Android AAB signing |
| `ANDROID_KEYSTORE_PASSWORD` | Android upload keystore password | Android AAB signing |
| `ANDROID_KEY_ALIAS` | Android upload key alias | Android AAB signing |
| `ANDROID_KEY_PASSWORD` | Android upload key password | Android AAB signing |
| `PLAY_STORE_SERVICE_ACCOUNT_JSON` | Google Cloud Console service account JSON (base64) | Play Store uploads |
| `APPLE_ID` | Apple ID email | Apple store publishing |
| `APPLE_TEAM_ID` | 10-char Apple team ID | Apple store publishing |
| `APPLE_APP_SPECIFIC_PASSWORD` | App-specific password from appleid.apple.com | Apple store publishing |
### PyPI Trusted Publishing (recommended)
Instead of API tokens, use OIDC trusted publishing:
1. Go to https://pypi.org/manage/project/eliza/settings/publishing/
2. Add a "GitHub Actions" publisher:
- Owner: `elizaOS`
- Repository: `eliza`
- Workflow: `publish-packages.yml`
- Environment: (leave blank or set one)
This eliminates the need for `PYPI_API_TOKEN` — GitHub Actions authenticates directly.
---
## 8. iOS App Store
### 8.1 Apple Developer Program (one-time)
1. **Enroll** at https://developer.apple.com/programs/ ($99/year)
2. **Create App ID**: Bundle ID `ai.eliza.app`, enable Push Notifications
3. **Create private certificates repo** `elizaOS/certificates` for Fastlane Match
4. **Create App Store Connect app**: Platform iOS, Bundle ID `ai.eliza.app`
### 8.2 Required GitHub Secrets
| Secret | Description |
|---|---|
| `APPLE_ID` | Apple ID email |
| `APPLE_TEAM_ID` | 10-char Apple Developer Team ID |
| `APPLE_APP_SPECIFIC_PASSWORD` | Generated at appleid.apple.com |
| `ITC_TEAM_ID` | App Store Connect team ID |
| `APP_STORE_APP_ID` | Numeric Apple ID from App Store Connect |
| `MATCH_PASSWORD` | Encryption password for Match certificates |
| `MATCH_GIT_URL` | URL to certificates repo |
| `MATCH_GIT_BASIC_AUTHORIZATION` | base64(username:PAT) for certificates repo |
### 8.3 App Privacy Nutrition Labels
| Data Type | Collected | Linked to Identity | Tracking |
|---|---|---|---|
| Usage Data | Yes | No | No |
| Location | Yes (optional) | No | No |
| Photos | Yes (optional) | No | No |
| User Content (chat) | Yes | No | No |
Data is stored on-device only. Chat messages sent to user-selected AI provider.
## 9. Mac App Store
### 9.1 Additional Secrets
| Secret | Description |
|---|---|
| `MAS_CSC_LINK` | base64-encoded Apple Distribution .p12 |
| `MAS_CSC_KEY_PASSWORD` | Password for the .p12 |
| `MAS_INSTALLER_CERT` | base64-encoded 3rd Party Mac Developer Installer .p12 |
| `MAS_INSTALLER_KEY_PASSWORD` | Password for installer .p12 |
| `APP_STORE_API_KEY_ID` | App Store Connect API key ID |
| `APP_STORE_API_ISSUER_ID` | App Store Connect API issuer ID |
### 9.2 Sandboxing
Mac App Store requires App Sandbox. Entitlements at
`apps/app/electrobun/entitlements/mas.entitlements` configure network,
file access, camera, microphone, and JIT compilation for Bun runtime.
## 10. Version Bumping Checklist
When releasing a new version, update these files:
| File | Field to Update |
|---|---|
| `package.json` | `version` |
| `packaging/pypi/pyproject.toml` | `version` (use PEP 440: `2.0.0b0` not `2.0.0-beta.0`) |
| `packaging/pypi/eliza/__init__.py` | `__version__` |
| `packaging/snap/snapcraft.yaml` | `version` |
| `packaging/debian/changelog` | Add new entry at top |
| `packaging/homebrew/eliza.rb` | `url` + `sha256` (after npm publish) |
| `packaging/flatpak/ai.elizaos.App.metainfo.xml` | Add new `<release>` entry |
| `apps/app/android/app/build.gradle` | `versionCode` + `versionName` (via env vars in CI) |
### Version Format Mapping
| Platform | Format | Example |
|---|---|---|
| npm | semver pre-release | `2.0.0-beta.0` |
| PyPI (PEP 440) | beta suffix | `2.0.0b0` |
| Debian | tilde for pre-release | `2.0.0~beta0-1` |
| Snap | semver-ish | `2.0.0-beta.0` |
| Flatpak | semver | `2.0.0-beta.0` |
| Homebrew | follows npm tarball URL | (automatic) |
---
## Quick Reference: User Install Commands
| Platform | Command |
|---|---|
| **npm** | `npm install -g elizaai` |
| **PyPI** | `pip install eliza` |
| **Homebrew** | `brew install elizaOS/tap/eliza` |
| **apt** | `sudo apt install eliza` (after adding repo) |
| **Snap** | `sudo snap install eliza --classic` |
| **Flatpak** | `flatpak install flathub ai.elizaos.App` |
| **Google Play** | Search "Eliza" on Play Store |
| **iOS App Store** | Search "Eliza" on App Store |
| **Mac App Store** | Search "Eliza" on Mac App Store |
| **npx** | `npx elizaai` (no install) |
| **pipx** | `pipx install eliza` |
@@ -0,0 +1,18 @@
# elizaOS apt Repository
Add the elizaOS apt repository to get automatic updates:
```bash
curl -fsSL https://apt.elizaos.ai/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/elizaos.gpg
echo "deb [signed-by=/usr/share/keyrings/elizaos.gpg] https://apt.elizaos.ai stable main" | \
sudo tee /etc/apt/sources.list.d/elizaos.list
sudo apt update && sudo apt install elizaos-app
```
## CI secrets required
- `DEBIAN_GPG_PRIVATE_KEY` — armored GPG private key (`gpg --armor --export-secret-keys <key-id>`)
- `DEBIAN_GPG_KEY_ID` — 16-char key ID
- `DEBIAN_GPG_PASSPHRASE` — passphrase (if the key has one)
The repo is hosted via GitHub Pages on the `apt-repo` branch.
@@ -0,0 +1,11 @@
Codename: stable
Components: main
Architectures: amd64 arm64
SignWith: default
Description: elizaOS App stable release
Codename: beta
Components: main
Architectures: amd64 arm64
SignWith: default
Description: elizaOS App beta release
@@ -0,0 +1,9 @@
elizaos-app (2.0.0~beta0-1) unstable; urgency=medium
* Initial Debian packaging
* Personal AI assistant built on elizaOS
* CLI with interactive onboarding
* Multi-provider AI support (Anthropic, OpenAI, Google, Ollama, etc.)
* Web dashboard on port 18789
-- elizaos <dev@app.elizaos.ai> Sat, 07 Feb 2026 00:00:00 +0000
@@ -0,0 +1,26 @@
Source: elizaos-app
Section: misc
Priority: optional
Maintainer: elizaos <dev@app.elizaos.ai>
Build-Depends: debhelper-compat (= 13), nodejs (>= 22.12.0), npm
Standards-Version: 4.6.2
Homepage: https://app.elizacloud.ai
Vcs-Git: https://github.com/elizaos/elizaos-app.git
Vcs-Browser: https://github.com/elizaos/elizaos-app
Rules-Requires-Root: no
Package: elizaos-app
Architecture: all
Depends: nodejs (>= 22.12.0), ${misc:Depends}
Recommends: npm, systemd
Description: Personal AI assistant built on elizaOS
elizaOS App is a personal AI assistant you run on your own devices, built
on elizaOS. It provides zero-config onboarding, multi-provider AI
support, a web dashboard, and a plugin system.
.
Features:
- Interactive setup on first run
- Support for Anthropic, OpenAI, Google, Ollama, and more
- Web dashboard at http://localhost:18789
- Plugin system for extensibility
- Web3 wallet integration (EVM + Solana)
@@ -0,0 +1,27 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: elizaos-app
Upstream-Contact: dev@app.elizaos.ai
Source: https://github.com/elizaos/elizaos-app
Files: *
Copyright: 2024-2026 elizaos
License: MIT
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the "Software"),
to deal in the Software without restriction, including without limitation
the rights to use, copy, modify, merge, publish, distribute, sublicense,
and/or sell copies of the Software, and to permit persons to whom the
Software is furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL
THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,15 @@
[Unit]
Description=elizaOS App — Personal AI Assistant
Documentation=https://github.com/elizaos/elizaos-app
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
ExecStart=/usr/bin/elizaos-app start
Restart=on-failure
RestartSec=5
Environment=NODE_ENV=production
[Install]
WantedBy=default.target
@@ -0,0 +1,4 @@
dist/* usr/lib/elizaos-app/dist/
elizaos-app.mjs usr/lib/elizaos-app/
package.json usr/lib/elizaos-app/
plugins.json usr/lib/elizaos-app/
+28
View File
@@ -0,0 +1,28 @@
#!/bin/sh
set -e
case "$1" in
configure)
# Install systemd user service
if [ -d /usr/lib/systemd/user ]; then
cp /usr/lib/elizaos-app/elizaos-app.service /usr/lib/systemd/user/elizaos-app.service 2>/dev/null || true
systemctl --global daemon-reload 2>/dev/null || true
fi
echo ""
echo " elizaOS App installed! Get started:"
echo ""
echo " elizaos-app start — Start interactively"
echo " elizaos-app setup — Initialize workspace"
echo " elizaos-app --help — Show all commands"
echo ""
echo " To run as a background service:"
echo ""
echo " systemctl --user enable --now elizaos-app"
echo ""
;;
esac
#DEBHELPER#
exit 0
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
set -e
case "$1" in
remove|upgrade)
# Stop and disable user service if running
systemctl --user stop elizaos-app 2>/dev/null || true
systemctl --user disable elizaos-app 2>/dev/null || true
;;
esac
#DEBHELPER#
exit 0
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_build:
# Use bun if available (installed by CI), fallback to npm
if command -v bun >/dev/null 2>&1; then \
node scripts/init-submodules.mjs || true; \
node scripts/disable-local-eliza-workspace.mjs || true; \
ELIZAOS_APP_SKIP_LOCAL_UPSTREAMS=1 bun install --ignore-scripts; \
bun run build; \
else \
npm install --ignore-scripts; \
npm run build; \
fi
override_dh_auto_install:
# Install the built distribution
install -d debian/elizaos-app/usr/lib/elizaos-app
cp -r dist/ debian/elizaos-app/usr/lib/elizaos-app/
cp elizaos-app.mjs debian/elizaos-app/usr/lib/elizaos-app/
cp package.json debian/elizaos-app/usr/lib/elizaos-app/
cp -r node_modules/ debian/elizaos-app/usr/lib/elizaos-app/ 2>/dev/null || true
# Install the plugins manifest
install -m 644 plugins.json debian/elizaos-app/usr/lib/elizaos-app/
# Create the CLI wrapper
install -d debian/elizaos-app/usr/bin
printf '#!/bin/sh\nexec /usr/bin/node /usr/lib/elizaos-app/elizaos-app.mjs "$$@"\n' \
> debian/elizaos-app/usr/bin/elizaos-app
chmod 755 debian/elizaos-app/usr/bin/elizaos-app
# Install systemd user service
install -m 644 packaging/debian/elizaos-app.service debian/elizaos-app/usr/lib/elizaos-app/elizaos-app.service
override_dh_auto_test:
# Skip tests during package build
true
override_dh_auto_clean:
node ../scripts/rm-path-recursive.mjs dist node_modules
dh_auto_clean
@@ -0,0 +1 @@
3.0 (quilt)
@@ -0,0 +1,160 @@
# Flatpak packaging — store and direct variants
Two manifests live in this directory. They produce the same `ai.elizaos.App`
app-id, but with very different sandbox postures.
| Variant | Manifest | Wrapper | Posture | Distribution |
|---------|----------|---------|---------|--------------|
| **Store** (Flathub) | `ai.elizaos.App.store.yml` | `elizaos-app-wrapper.store.sh` | Locked-down sandbox, no host escape | Flathub |
| **Direct** (power-user) | `ai.elizaos.App.yml` | `elizaos-app-wrapper.sh` | Full `$HOME` access, host shell reach, EXECUTE_CODE permitted | Self-hosted repo, side-loaded bundles |
Pick the variant that matches the audience. Flathub will reject the direct
manifest on review. Power users who want host-Ollama, docker reach, and
EXECUTE_CODE want the direct manifest (or, equivalently, the AppImage /
.deb / .rpm builds).
The build is selected by the `ELIZA_BUILD_VARIANT` env var at build time —
see `bun run build:flatpak` (`packages/app-core/scripts/build-flatpak.mjs`).
## Sandbox philosophy (store variant)
The store manifest grants only the capabilities a managed-cloud Eliza
agent needs:
- `--share=network` — cloud APIs, model providers, plugin registry, the
loopback web dashboard.
- `--share=ipc` — required for localhost loopback the dashboard binds.
- `--socket=wayland` + `--socket=fallback-x11` — desktop integration.
- `--filesystem=xdg-documents/Eliza:create` — a single user-granted
workspace folder under `~/Documents/Eliza`. The user picks (or
confirms) this through the FileChooser portal at first run.
- `--filesystem=xdg-config/elizaos-app:create` — config and account
storage under `~/.config/elizaos-app`.
- `--persist=.eliza``~/.eliza` is rewritten transparently by
Flatpak to `~/.var/app/ai.elizaos.App/.eliza`, surviving upgrades.
- `--talk-name=org.freedesktop.Notifications` — desktop notifications.
- `--talk-name=org.kde.StatusNotifierWatcher` — system tray.
What it explicitly does NOT grant — and what bubblewrap therefore blocks
unconditionally:
- **No `--filesystem=home` / `--filesystem=host`.** No reading or writing
outside the granted folders.
- **No `--talk-name=org.freedesktop.Flatpak`.** That D-Bus name is the
host-spawn portal; it is the standard way for sandboxed apps to escape
the sandbox and run host commands. The store posture forbids host
escape, so it is excluded.
- **No `--device=all`.** No raw device access.
- **No `--socket=session-bus` / `--socket=system-bus`.** Direct D-Bus
exposure would let the runtime talk to anything; we go through the
portal stack instead.
The runtime reads `ELIZA_BUILD_VARIANT=store` (set by the store wrapper)
and gates off:
- PATH-lookup CLI spawning (no `bun`, `python`, `git`, `docker`, etc. on
the host PATH — they're not in `$PATH` inside the sandbox anyway, but
the runtime reports the disablement explicitly so users see "local
agent execution disabled in this build" instead of opaque ENOENT).
- The `EXECUTE_CODE` action.
- Host-Ollama discovery (no `127.0.0.1:11434` reach — the sandbox sees
its own loopback, not the host's, so Ollama-on-host wouldn't work
even if we tried).
Hosting flips to the cloud-only routing: the agent talks to Eliza Cloud
for inference, plugin registry, app deploys, billing, and any other
backend that would otherwise have a local fallback.
## Required portals
The store variant relies on these portals (ambient on the
`org.freedesktop.Platform//24.08` runtime — no extra `--talk-name=`
needed):
| Portal | Purpose |
|--------|---------|
| `org.freedesktop.portal.FileChooser` | Workspace folder picker (first run + "open in workspace") |
| `org.freedesktop.portal.OpenURI` | Browser launches for OAuth flows (Eliza Cloud sign-in, app domain verification) |
| `org.freedesktop.portal.Notification` | Notification fallback (the older `org.freedesktop.Notifications` D-Bus name is also granted) |
If a future feature needs camera, microphone, or location, add the
corresponding portal — do NOT add a raw `--device=` or `--socket=` rule.
## Local testing
```bash
# Install build tooling.
sudo apt install flatpak flatpak-builder # Debian / Ubuntu
sudo dnf install flatpak flatpak-builder # Fedora
# Install the runtime + SDK once.
flatpak install --user flathub org.freedesktop.Platform//24.08
flatpak install --user flathub org.freedesktop.Sdk//24.08
# Build the store variant.
ELIZA_BUILD_VARIANT=store bun run build:flatpak
# Or call flatpak-builder directly.
cd packages/app-core/packaging/flatpak
flatpak-builder --user --install --force-clean build-dir ai.elizaos.App.store.yml
# Run.
flatpak run ai.elizaos.App --version
flatpak run ai.elizaos.App start
# Inspect the granted permissions to confirm the lockdown.
flatpak info --show-permissions ai.elizaos.App
# Expect: shared=network;ipc; sockets=wayland;fallback-x11; filesystems
# limited to xdg-documents/Eliza and xdg-config/elizaos-app; talk-names
# limited to Notifications + StatusNotifierWatcher.
```
## Flathub submission checklist
When you're ready to submit the store variant to Flathub:
1. **Vendor the npm tree as offline sources.** Flathub's build
infrastructure does not allow network access during `build`. The
`test-flatpak.yml` CI workflow regenerates `node-sources.json` on
every run via `./generate-sources.sh` and uploads it as the
`flatpak-node-sources` artifact. To refresh the committed copy
locally (Linux only):
```bash
./generate-sources.sh # writes node-sources.json next to this README
```
Or download the CI artifact from the most recent successful
`Test Flatpak Build` workflow run on `develop` and drop it next to
this README. Once `node-sources.json` is committed, the manifest can
build offline (`npm install -g --offline`) and the
`build-options.build-args: --share=network` shim is no longer needed.
2. **Replace screenshot URLs** in `ai.elizaos.App.metainfo.xml`. Three
placeholder `<screenshot>` entries currently point at
`https://app.elizacloud.ai/screenshots/{dashboard,onboarding,plugins}.png`
— host the real 1280×720 PNGs at those paths (or update the URLs to
wherever they're served from) before submitting. Flathub fetches the
URLs at review time.
3. **Verify the manifest** with `appstream-util validate` and
`flatpak-builder --show-manifest --show-deps`.
4. **Open a submission issue at https://github.com/flathub/flathub/issues/new**
— pick the "App submission" template, link to this manifest in the
public elizaos repo, and explicitly call out:
- The store variant uses portal-mediated FS access (no
`--filesystem=home`).
- The runtime hosting mode is forced to Eliza Cloud — no local CLI
spawning, no host Ollama.
- This Flathub submission is the **store** variant; an unrestricted
"direct" build for power users is published separately as a
side-loadable bundle and is **not** on Flathub.
5. **Hand over** to a Flathub maintainer who creates
`github.com/flathub/ai.elizaos.App` and applies the manifest +
supporting files.
## Direct variant
The direct manifest (`ai.elizaos.App.yml`) keeps the existing
`--filesystem=home` posture so power users who self-host a Flatpak repo
or side-load a bundle get the same experience as the AppImage / .deb /
.rpm builds. It does NOT set `ELIZA_BUILD_VARIANT=store`, so the
runtime exposes the full coding-agent surface.
Don't submit this manifest to Flathub. It will fail review.
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=elizaOS App
GenericName=AI Assistant
Comment=Personal AI assistant built on elizaOS
Exec=elizaos-app start
Icon=ai.elizaos.App
Terminal=true
Categories=Development;Utility;
Keywords=ai;assistant;agent;elizaos;llm;chatbot;
StartupNotify=false
@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<component type="console-application">
<id>ai.elizaos.App</id>
<metadata_license>MIT</metadata_license>
<project_license>MIT</project_license>
<name>elizaOS App</name>
<summary>Personal AI assistant built on elizaOS</summary>
<developer id="ai.elizaos">
<name>elizaOS</name>
</developer>
<description>
<p>
elizaOS App is a personal AI assistant that runs on your own devices, built on
elizaOS. It provides zero-config onboarding, multi-provider AI support,
a web dashboard, and a plugin system for extensibility.
</p>
<p>Features:</p>
<ul>
<li>Interactive setup on first run — pick a name, personality, and AI provider</li>
<li>Support for Anthropic (Claude), OpenAI, Google Gemini, Ollama, and more</li>
<li>Web dashboard at localhost for agent management</li>
<li>Plugin system with 100+ official plugins</li>
<li>Connects to Telegram, Discord, Slack, and other platforms</li>
<li>Web3 wallet integration (EVM and Solana)</li>
</ul>
</description>
<url type="homepage">https://app.elizacloud.ai</url>
<url type="bugtracker">https://github.com/elizaos/elizaos-app/issues</url>
<url type="vcs-browser">https://github.com/elizaos/elizaos-app</url>
<url type="help">https://docs.elizaos.ai</url>
<provides>
<binary>elizaos-app</binary>
</provides>
<categories>
<category>Development</category>
<category>Utility</category>
</categories>
<launchable type="desktop-id">ai.elizaos.App.desktop</launchable>
<!--
Required for Flathub submission. URLs point at repository-hosted PNG assets
so `appstream-util validate-relax` can verify them without depending on a
marketing host.
-->
<screenshots>
<screenshot type="default">
<caption>elizaOS App agent chat dashboard</caption>
<image type="source" width="1920" height="1200">https://raw.githubusercontent.com/elizaOS/eliza/develop/packages/docs/images/shakespeare-screenshot.jpeg</image>
</screenshot>
<screenshot>
<caption>Discord connector conversation</caption>
<image type="source" width="1920" height="1200">https://raw.githubusercontent.com/elizaOS/eliza/develop/packages/docs/images/shakespeare-discord-screenshot.jpeg</image>
</screenshot>
<screenshot>
<caption>Voice channel connection flow</caption>
<image type="source" width="1920" height="1200">https://raw.githubusercontent.com/elizaOS/eliza/develop/packages/docs/images/ask-to-join-vc.jpeg</image>
</screenshot>
</screenshots>
<releases>
<release version="2.0.0-beta.0" date="2026-05-05">
<description>
<p>Beta Flatpak release of elizaOS App.</p>
</description>
</release>
</releases>
<content_rating type="oars-1.1">
<content_attribute id="social-chat">moderate</content_attribute>
</content_rating>
<keywords>
<keyword>ai</keyword>
<keyword>assistant</keyword>
<keyword>agent</keyword>
<keyword>elizaos</keyword>
<keyword>llm</keyword>
<keyword>chatbot</keyword>
</keywords>
</component>
@@ -0,0 +1,142 @@
# Flatpak manifest — Flathub (store) variant for elizaOS App.
#
# This is the LOCKED-DOWN sandbox build, intended for distribution through
# Flathub. It is the Flatpak equivalent of an app-store / MAS / MSIX build:
# the runtime cannot reach the host shell, cannot execute arbitrary CLIs from
# `$PATH`, and cannot read or write outside its own per-app data directory or
# user-granted portal-mediated paths.
#
# When you want the unrestricted, power-user experience that mirrors the
# AppImage / .deb / .rpm distribution (full $HOME access, host shell, host
# Ollama, docker, etc.), use `ai.elizaos.App.yml` instead. That manifest is
# the "direct" variant and is NOT acceptable on Flathub — Flathub reviewers
# will reject `--filesystem=home` and similar host-escape sockets.
#
# Build (store / Flathub):
# flatpak-builder --repo=repo --force-clean build-dir ai.elizaos.App.store.yml
# Install:
# flatpak --user install repo ai.elizaos.App
# Bundle:
# flatpak build-bundle repo elizaos-app.flatpak ai.elizaos.App
#
# See ./README.md for the full sandbox philosophy and required portals.
app-id: ai.elizaos.App
runtime: org.freedesktop.Platform
runtime-version: '24.08'
sdk: org.freedesktop.Sdk
command: elizaos-app
finish-args:
# ── Required ────────────────────────────────────────────────────────────
# Network: cloud APIs, model providers, plugin registry, web dashboard.
- --share=network
# IPC: required for localhost loopback that the dashboard binds.
- --share=ipc
# GUI display sockets — Wayland with X11 fallback. The CLI is a console
# app, but the same image hosts the local dashboard the user opens in a
# browser, and Flathub expects desktop apps to declare their display
# sockets here so the portal stack works correctly.
- --socket=wayland
- --socket=fallback-x11
# Persisted state — keeps `~/.eliza` (app data, prompts, trajectories)
# inside the per-app sandboxed home so it survives upgrades. Flatpak
# rewrites this to `~/.var/app/ai.elizaos.App/.eliza` transparently.
- --persist=.eliza
# Portal-managed user-granted workspace folder. The user picks this
# explicitly via the FileChooser portal at first run; everything outside
# it stays unreadable.
- --filesystem=xdg-documents/Eliza:create
# XDG config persistence (config file, account storage, settings).
- --filesystem=xdg-config/elizaos-app:create
# Desktop notifications.
- --talk-name=org.freedesktop.Notifications
# System tray / status notifier (KDE + GNOME shell extension).
- --talk-name=org.kde.StatusNotifierWatcher
# OpenURI portal — used to launch the user's browser for OAuth flows
# (Eliza Cloud sign-in, app domain verification redirects). The
# portal namespace is implicit on org.freedesktop.Platform//24.08;
# we do NOT need to talk-name org.freedesktop.portal.* directly.
# ── Explicitly DENIED (do not add these) ────────────────────────────────
# See README.md for the full list of forbidden grants. In short: no
# filesystem=home, no filesystem=host, no host-spawn portal
# (org.freedesktop.Flatpak), no device=all, no raw session-bus or
# system-bus sockets. These would defeat the Flathub sandbox posture
# and reviewers will reject them. The forbidden-list strings are
# intentionally written without the leading `--` so the
# test-packaging.sh sandbox-lockdown grep checks (which match the
# actual `--option=...` argument form, with leading whitespace)
# don't false-positive on these comment lines.
build-options:
no-debuginfo: true
# Flathub's build infrastructure does NOT allow network access during
# the build phase. To submit this manifest to Flathub you must vendor
# the npm tree as offline sources first, e.g.:
#
# flatpak-node-generator npm package-lock.json -o npm-sources.json
#
# then add `npm-sources.json` to the elizaos-app sources list and drop
# the `--share=network` build-arg below.
#
# For self-hosted CI builds (the `bun run build:flatpak` script under
# ../../scripts/build-flatpak.mjs), the network flag stays on so we can
# `npm install -g elizaos` straight from the registry.
build-args:
- --share=network
modules:
# ── Node.js 22 LTS ───────────────────────────────────────────────────
- name: nodejs
buildsystem: simple
build-commands:
- cp -r lib /app/lib
- cp -r include /app/include
- install -Dm755 bin/node /app/bin/node
- cp -P bin/npm /app/bin/npm
- cp -P bin/npx /app/bin/npx
sources:
- type: archive
url: https://nodejs.org/dist/v22.12.0/node-v22.12.0-linux-x64.tar.xz
sha256: 22982235e1b71fa8850f82edd09cdae7e3f32df1764a9ec298c72d25ef2c164f
only-arches:
- x86_64
- type: archive
url: https://nodejs.org/dist/v22.12.0/node-v22.12.0-linux-arm64.tar.xz
sha256: 8cfd5a8b9afae5a2e0bd86b0148ca31d2589c0ea669c2d0b11c132e35d90ed68
only-arches:
- aarch64
# ── elizaOS App CLI (store variant) ──────────────────────────────────
# The store wrapper exports ELIZA_BUILD_VARIANT=store before exec'ing
# the runtime, which the agent reads to gate off PATH-lookup CLI
# spawning, EXECUTE_CODE, and host-Ollama discovery. The bubblewrap
# sandbox enforces the same constraints unconditionally; the env var
# gives the runtime a clean signal so it can render correct UI
# ("Local agent execution disabled in this build — switch to AppImage
# or .deb for the full power-user experience") instead of failing
# opaquely when an action tries to spawn `bun` or `python`.
- name: elizaos-app
buildsystem: simple
build-options:
env:
npm_config_nodedir: /app
build-commands:
- /app/bin/npm install -g elizaos --prefix=/app --ignore-scripts
- install -Dm755 elizaos-app-wrapper.store.sh /app/bin/elizaos-app
- install -Dm644 ai.elizaos.App.desktop /app/share/applications/ai.elizaos.App.desktop
- install -Dm644 ai.elizaos.App.metainfo.xml /app/share/metainfo/ai.elizaos.App.metainfo.xml
- install -Dm644 icons/128x128/ai.elizaos.App.png /app/share/icons/hicolor/128x128/apps/ai.elizaos.App.png
- install -Dm644 icons/256x256/ai.elizaos.App.png /app/share/icons/hicolor/256x256/apps/ai.elizaos.App.png
- install -Dm644 icons/512x512/ai.elizaos.App.png /app/share/icons/hicolor/512x512/apps/ai.elizaos.App.png
sources:
- type: file
path: ai.elizaos.App.desktop
- type: file
path: ai.elizaos.App.metainfo.xml
- type: file
path: elizaos-app-wrapper.store.sh
- type: dir
path: icons
dest: icons
@@ -0,0 +1,128 @@
# Flatpak manifest — DIRECT (power-user) variant for elizaOS App.
#
# This is the unrestricted build that mirrors the AppImage / .deb / .rpm
# distribution: full $HOME access, host shell reach via xdg-run, host Ollama
# and docker discoverable, EXECUTE_CODE permitted. It is intended for
# self-hosted Flatpak repos and side-loaded bundles, NOT for Flathub.
#
# For Flathub submission use `ai.elizaos.App.store.yml`, which removes
# `--filesystem=home` and other host-escape sockets that Flathub reviewers
# will reject.
#
# Build (direct):
# flatpak-builder --repo=repo --force-clean build-dir ai.elizaos.App.yml
# Install:
# flatpak --user install repo ai.elizaos.App
# Bundle:
# flatpak build-bundle repo elizaos-app.flatpak ai.elizaos.App
#
# See ./README.md for the difference between store and direct variants.
app-id: ai.elizaos.App
runtime: org.freedesktop.Platform
runtime-version: '24.08'
sdk: org.freedesktop.Sdk
command: elizaos-app
finish-args:
# Network access (required for AI provider APIs, plugin downloads, web dashboard)
- --share=network
# IPC namespace (required for localhost web dashboard binding)
- --share=ipc
# Home directory access (config, workspace, plugins, local models)
- --filesystem=home
# XDG config for .config/elizaos-app persistence
- --filesystem=xdg-config/elizaos-app:create
# D-Bus access for desktop notifications
- --talk-name=org.freedesktop.Notifications
# D-Bus access for system tray / status notifier
- --talk-name=org.kde.StatusNotifierWatcher
build-options:
no-debuginfo: true
build-args:
- --share=network
modules:
# ── Node.js 22 LTS ───────────────────────────────────────────────────
- name: nodejs
buildsystem: simple
build-commands:
# Copy `lib` and `include` FIRST so the npm/npx symlinks in `bin`
# point at files that actually exist under /app when they're
# copied preserving symlink semantics.
- cp -r lib /app/lib
- cp -r include /app/include
# `bin/node` is a real file; use `install` so it's executable
# and placed with explicit permissions.
- install -Dm755 bin/node /app/bin/node
# `bin/npm` and `bin/npx` in the nodejs tarball are relative
# symlinks pointing at `../lib/node_modules/npm/bin/npm-cli.js`
# and `../lib/node_modules/npm/bin/npx-cli.js`. Using
# `install -Dm755` DEREFERENCES the symlink and writes the
# target JS file as `/app/bin/npm`, which then fails at runtime
# with `Cannot find module '../lib/cli.js'` because the relative
# require no longer resolves from `/app/bin/`. Use `cp -P` (POSIX
# "don't follow symbolic links") to preserve the symlink so
# /app/bin/npm → /app/lib/node_modules/npm/bin/npm-cli.js resolves
# the same way it does in the upstream tarball.
- cp -P bin/npm /app/bin/npm
- cp -P bin/npx /app/bin/npx
sources:
- type: archive
url: https://nodejs.org/dist/v22.12.0/node-v22.12.0-linux-x64.tar.xz
sha256: 22982235e1b71fa8850f82edd09cdae7e3f32df1764a9ec298c72d25ef2c164f
only-arches:
- x86_64
- type: archive
url: https://nodejs.org/dist/v22.12.0/node-v22.12.0-linux-arm64.tar.xz
sha256: 8cfd5a8b9afae5a2e0bd86b0148ca31d2589c0ea669c2d0b11c132e35d90ed68
only-arches:
- aarch64
# ── elizaOS App CLI ──────────────────────────────────────────────────
- name: elizaos-app
buildsystem: simple
build-options:
env:
npm_config_nodedir: /app
npm_config_cache: /run/build/elizaos-app/flatpak-node/npm-cache
build-commands:
- /app/bin/npm install -g --prefix=/app --ignore-scripts elizaos
# Install wrapper script from source
- install -Dm755 elizaos-app-wrapper.sh /app/bin/elizaos-app
# Install desktop entry and AppStream metadata
- install -Dm644 ai.elizaos.App.desktop /app/share/applications/ai.elizaos.App.desktop
- install -Dm644 ai.elizaos.App.metainfo.xml /app/share/metainfo/ai.elizaos.App.metainfo.xml
# Install icons at standard hicolor sizes
- install -Dm644 icons/128x128/ai.elizaos.App.png /app/share/icons/hicolor/128x128/apps/ai.elizaos.App.png
- install -Dm644 icons/256x256/ai.elizaos.App.png /app/share/icons/hicolor/256x256/apps/ai.elizaos.App.png
- install -Dm644 icons/512x512/ai.elizaos.App.png /app/share/icons/hicolor/512x512/apps/ai.elizaos.App.png
sources:
# Vendored npm tarballs + integrity hashes for `elizaos@latest`.
# Regenerated by `./generate-sources.sh` and uploaded as the
# `flatpak-node-sources` artifact on every CI run.
# NOTE: bare string (not `type: file`) — flatpak-builder reads this
# as a flatpak-node-generator manifest and dispatches its archive
# entries, script entries, etc., rather than treating it as a single
# opaque file to copy.
- node-sources.json
- type: file
path: ai.elizaos.App.desktop
- type: file
path: ai.elizaos.App.metainfo.xml
- type: file
path: elizaos-app-wrapper.sh
# Flatpak's `type: dir` source copies the *contents* of `path`
# into the build working directory, not the directory itself. So
# without `dest: icons`, the files would land at `128x128/...`
# and the `install icons/128x128/...` commands above would fail
# with "No such file or directory". Preserve the `icons/`
# subdirectory so the install paths match.
- type: dir
path: icons
dest: icons
@@ -0,0 +1,5 @@
#!/bin/sh
export NODE_PATH="/app/lib/node_modules"
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
export ELIZAOS_APP_DATA_DIR="${XDG_CONFIG_HOME}/elizaos-app"
exec /app/bin/node /app/lib/node_modules/elizaos/elizaos-app.mjs "$@"
@@ -0,0 +1,16 @@
#!/bin/sh
# Store-variant Flatpak wrapper.
#
# This is the entrypoint Flatpak invokes when the user runs
# `flatpak run ai.elizaos.App` for the Flathub-distributed build. It
# differs from the direct-variant wrapper (`elizaos-app-wrapper.sh`)
# only by exporting `ELIZA_BUILD_VARIANT=store`, which the agent
# runtime reads to gate off PATH-lookup CLI spawning, EXECUTE_CODE,
# and host-Ollama discovery — capabilities bubblewrap blocks anyway,
# but signaling the variant explicitly lets the runtime render correct
# UI instead of failing opaquely.
export ELIZA_BUILD_VARIANT=store
export NODE_PATH="/app/lib/node_modules"
export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}"
export ELIZAOS_APP_DATA_DIR="${XDG_CONFIG_HOME}/elizaos-app"
exec /app/bin/node /app/lib/node_modules/elizaos/elizaos-app.mjs "$@"
@@ -0,0 +1,79 @@
#!/usr/bin/env bash
# Generate offline node sources for the elizaOS App Flatpak.
#
# Flathub's build infrastructure forbids network access during the build
# phase. This script invokes `flatpak-node-generator` to produce a
# `node-sources.json` manifest that lists every npm tarball + hash the
# `elizaos` CLI install needs, so the Flathub build can populate
# /app via vendored sources instead of `npm install -g elizaos`.
#
# Prerequisites (Linux only — flatpak-builder is Linux-only):
# - python3
# - pipx (recommended) OR pip
# - network access (this script is the one place we DO need it)
#
# Usage:
# ./generate-sources.sh # writes node-sources.json next to the manifest
# ./generate-sources.sh /tmp/out # writes /tmp/out/node-sources.json
#
# After running, commit node-sources.json alongside the manifest so the
# Flathub build can reproduce the install offline.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../../../.." && pwd)"
RM_PATH_RECURSIVE="${REPO_ROOT}/packages/scripts/rm-path-recursive.mjs"
OUT_DIR="${1:-$SCRIPT_DIR}"
OUT_FILE="$OUT_DIR/node-sources.json"
if ! command -v flatpak-node-generator >/dev/null 2>&1; then
echo "flatpak-node-generator not found on PATH." >&2
echo "Install it with one of:" >&2
echo " pipx install flatpak-node-generator" >&2
echo " pip install --user flatpak-node-generator" >&2
echo "Source: https://github.com/flatpak/flatpak-builder-tools/tree/master/node" >&2
exit 1
fi
# flatpak-node-generator needs a package-lock.json or yarn.lock to derive
# the exact dependency closure. We don't ship a lockfile inside the flatpak
# packaging dir, so synthesize one from a fresh install of the latest
# published `elizaos` CLI in a temp dir.
WORK_DIR="$(mktemp -d)"
cleanup() {
node "${RM_PATH_RECURSIVE}" "${WORK_DIR}"
}
trap cleanup EXIT
echo "[generate-sources] Resolving elizaos dependency closure in $WORK_DIR" >&2
(
cd "$WORK_DIR"
# Seed a minimal package.json with a `name` field. Without this,
# `npm install --package-lock-only` writes a `package.json` that
# only contains a `dependencies` block, and flatpak-node-generator
# 0.1.1 crashes with `KeyError: 'name'` when it parses the root
# package entry of the resulting lockfile.
cat > package.json <<'PKG'
{
"name": "elizaos-flatpak-sources-shim",
"version": "0.0.0",
"private": true
}
PKG
# `npm install --package-lock-only` writes the lockfile without
# actually fetching/extracting tarballs into node_modules. We just
# need the resolved graph for flatpak-node-generator.
npm install --package-lock-only --ignore-scripts elizaos@latest
)
echo "[generate-sources] Generating $OUT_FILE" >&2
flatpak-node-generator npm \
--recursive \
-o "$OUT_FILE" \
"$WORK_DIR/package-lock.json"
echo "[generate-sources] Wrote $OUT_FILE" >&2
echo "[generate-sources] Add the following to ai.elizaos.App.yml under the elizaos-app module sources:" >&2
echo " - type: file" >&2
echo " path: node-sources.json" >&2
Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,80 @@
# Homebrew Distribution
This directory contains Homebrew formula and cask definitions for elizaOS App.
## Files
- `elizaos-app.rb` — Formula for the CLI tool (installed via npm)
- `elizaos-app.cask.rb` — Cask for the desktop app (DMG installer)
## Setup
### 1. Create Homebrew Tap Repository
Create a new repo: `elizaos/homebrew-elizaos-app`
Structure:
```
homebrew-elizaos-app/
├── Formula/
│ └── elizaos-app.rb
├── Casks/
│ └── elizaos-app.cask.rb
└── README.md
```
### 2. Update SHA256 Hashes
Before publishing, replace placeholder hashes:
**For the cask (DMG files):**
```bash
# Download and hash ARM64 DMG
curl -sL https://github.com/elizaos/elizaos-app/releases/download/v2.0.0-beta.0/ElizaOSApp-2.0.0-beta.0-arm64.dmg | shasum -a 256
# Download and hash Intel DMG
curl -sL https://github.com/elizaos/elizaos-app/releases/download/v2.0.0-beta.0/ElizaOSApp-2.0.0-beta.0.dmg | shasum -a 256
```
**For the formula (npm tarball):**
```bash
curl -sL https://registry.npmjs.org/elizaos/-/elizaos-2.0.0-beta.0.tgz | shasum -a 256
```
### 3. Users Can Install
```bash
# Add tap
brew tap elizaos/elizaos-app
# Install desktop app
brew install --cask elizaos-app
# Or install CLI only
brew install elizaos-app
```
## Auto-Update Workflow
See the publishing guide at `../PUBLISHING_GUIDE.md` for full instructions.
## Testing Locally
```bash
# Test formula syntax
brew audit --strict elizaos-app.rb
# Test cask syntax
brew audit --cask --strict elizaos-app.cask.rb
# Test installation (from local file)
brew install --formula ./elizaos-app.rb
brew install --cask ./elizaos-app.cask.rb
```
## Notes
- The cask requires macOS Monterey (12.0) or later
- The formula requires Node.js 22+ (installed as dependency)
- Both support auto-updates via Homebrew's built-in mechanisms
- Desktop app also has built-in auto-update via the native Electrobun updater
@@ -0,0 +1,60 @@
# frozen_string_literal: true
# Homebrew Cask for the elizaOS App desktop app (DMG).
# Use this for the tap's Casks/elizaos-app.cask.rb entry.
#
# Key fixes from the original:
# - URL matches actual release asset naming (canary-macos-{arch}-ElizaOSApp-canary.dmg)
# - App identifier uses ai.elizaos.app (from actual metadata)
# - SHA256 for both architectures
cask "elizaos-app" do
arch arm: "arm64", intel: "x64"
version "2.0.0-alpha.84"
on_arm do
sha256 "a348cc3c619e8445270e4a2ebfc07c14ec56384893c48452832dadb01d17448b"
end
on_intel do
sha256 "5a40d3a4f9e7a7302cf4f4102ed7dbd81c8cb57083d1ff8b94e167f214d4d9f6"
end
url "https://github.com/elizaos/elizaos-app/releases/download/v#{version}/canary-macos-#{arch}-ElizaOSApp-canary.dmg",
verified: "github.com/elizaos/elizaos-app/"
name "elizaOS App"
desc "Personal AI assistant — cute agents for the acceleration"
homepage "https://github.com/elizaos/elizaos-app"
livecheck do
url "https://github.com/elizaos/elizaos-app/releases"
strategy :github_latest
regex(/v?(\d+(?:\.\d+)+(?:-[a-z]+\.\d+)?)/i)
end
auto_updates true
depends_on macos: ">= :monterey"
app "ElizaOSApp.app"
zap trash: [
"~/Library/Application Support/ElizaOSApp",
"~/Library/Caches/ai.elizaos.app",
"~/Library/Caches/ai.elizaos.app.ShipIt",
"~/Library/Preferences/ai.elizaos.app.plist",
"~/Library/Saved Application State/ai.elizaos.app.savedState",
"~/.elizaos-app",
]
caveats <<~EOS
elizaOS App desktop app has been installed.
On first launch, you'll be guided through setup to:
- Choose your agent's name and personality
- Connect an AI provider (Anthropic, OpenAI, Ollama, etc.)
The CLI is also available via: brew install elizaos-app (without --cask)
EOS
end
@@ -0,0 +1,49 @@
# frozen_string_literal: true
# Homebrew formula for elizaOS App — personal AI assistant built on elizaOS
# Use this for the tap's Formula/elizaos-app.rb entry.
#
# Key fixes from the original:
# - npm package name is "elizaos"
# - URL points to correct npm registry path
# - Added livecheck block for auto-update detection
# - Added head for --HEAD installs from develop branch
class ElizaosApp < Formula
desc "Personal AI assistant — cute agents for the acceleration"
homepage "https://github.com/elizaos/elizaos-app"
url "https://registry.npmjs.org/elizaos/-/elizaos-2.0.0-alpha.76.tgz"
sha256 "3f3749c0e591547eac1992ae90eb20ccdc10b899dd3b9edce9801ac416e3a60a"
license "MIT"
head "https://github.com/elizaos/elizaos-app.git", branch: "develop"
livecheck do
url "https://registry.npmjs.org/elizaos"
regex(/["']version["']:\s*["']([^"']+)["']/i)
end
depends_on "node@22"
def install
system "npm", "install", *std_npm_args
bin.install_symlink Dir["#{libexec}/bin/*"]
end
def caveats
<<~EOS
elizaOS App requires Node.js 22+.
Get started:
elizaos-app start Start the agent runtime
elizaos-app setup Run workspace setup
elizaos-app configure Configuration guidance
Dashboard: http://localhost:2138
Docs: https://docs.elizaos.ai
EOS
end
test do
assert_match version.to_s, shell_output("#{bin}/elizaos-app --version")
end
end
@@ -0,0 +1,53 @@
#define MyAppId "__APP_ID__"
#define MyAppName "__APP_NAME__"
#define MyAppVersion "__APP_VERSION__"
#define MyAppPublisher "elizaOS"
#define MyAppExeName "bin\launcher.exe"
#define MyDefaultDirName "__DEFAULT_DIR_NAME__"
#define MyDefaultGroupName "__DEFAULT_GROUP_NAME__"
#define MyOutputDir "__OUTPUT_DIR__"
#define MyOutputBaseFilename "__OUTPUT_BASE_FILENAME__"
#define MySourceDir "__SOURCE_DIR__"
#define MySetupIconFile "__ICON_FILE__"
#define MyAppIconFile "ElizaOSApp.ico"
[Setup]
AppId={#MyAppId}
AppName={#MyAppName}
AppVersion={#MyAppVersion}
AppPublisher={#MyAppPublisher}
AppPublisherURL=https://app.elizacloud.ai
AppSupportURL=https://github.com/elizaos/elizaos-app/issues
AppUpdatesURL=https://github.com/elizaos/elizaos-app/releases
DefaultDirName={#MyDefaultDirName}
DefaultGroupName={#MyDefaultGroupName}
DisableProgramGroupPage=yes
OutputDir={#MyOutputDir}
OutputBaseFilename={#MyOutputBaseFilename}
SetupIconFile={#MySetupIconFile}
UninstallDisplayIcon={app}\{#MyAppIconFile}
Compression=lzma2/ultra64
SolidCompression=yes
ArchitecturesAllowed=x64compatible
ArchitecturesInstallIn64BitMode=x64compatible
PrivilegesRequired=lowest
WizardStyle=modern
SetupLogging=yes
CloseApplications=no
RestartIfNeededByRun=no
__SIGN_SETUP_LINES__
[Languages]
Name: "en"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "Create a desktop shortcut"; GroupDescription: "Additional shortcuts:"
[Files]
Source: "{#MySourceDir}\*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
Source: "{#MySetupIconFile}"; DestDir: "{app}"; DestName: "{#MyAppIconFile}"; Flags: ignoreversion
[Icons]
Name: "{autoprograms}\{#MyDefaultGroupName}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; IconFilename: "{app}\{#MyAppIconFile}"
Name: "{autoprograms}\{#MyDefaultGroupName}\Uninstall {#MyAppName}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon; IconFilename: "{app}\{#MyAppIconFile}"
@@ -0,0 +1,256 @@
# build-inno.ps1 — Build a standalone Inno Setup installer from a packaged Electrobun Windows app.
#
# Usage:
# pwsh -File packaging/inno/build-inno.ps1 `
# -BuildDir ./apps/app/electrobun/build `
# -OutputDir ./apps/app/electrobun/artifacts `
# -Version 2.0.0-beta.0 `
# -Channel canary
param(
[Parameter(Mandatory)][string]$BuildDir,
[Parameter(Mandatory)][string]$OutputDir,
[Parameter(Mandatory)][string]$Version,
[Parameter(Mandatory)][string]$Channel,
[string]$CompilerPath = $env:ELIZAOS_APP_INNO_SETUP_COMPILER
)
$ErrorActionPreference = "Stop"
function Get-IsccPath {
param([string]$PreferredPath)
$candidates = @()
if ($PreferredPath) {
$candidates += $PreferredPath
}
$candidates += @(
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe",
"C:\Program Files\Inno Setup 6\ISCC.exe"
)
foreach ($candidate in $candidates) {
if ($candidate -and (Test-Path $candidate)) {
return $candidate
}
}
$command = Get-Command "ISCC.exe" -ErrorAction SilentlyContinue
if ($command) {
return $command.Source
}
throw "ISCC.exe not found. Install Inno Setup 6.7.1 first."
}
function Escape-InnoValue {
param([string]$Value)
return $Value.Replace('"', '""')
}
# App identity is env-driven (ELIZA_APP_NAME / ELIZA_APP_ID) and defaults to the
# existing elizaOS values when unset, so the produced installer stays
# byte-identical unless the brand env is provided.
function Get-AppLabelRoot {
$name = $env:ELIZA_APP_NAME
if ($name -and $name.Trim()) {
return $name.Trim()
}
return "elizaOS App"
}
function Get-AppFileRoot {
# File/install root has no whitespace; derive it from the env only when set,
# otherwise keep the existing literal (note: not derivable from the label).
$name = $env:ELIZA_APP_NAME
if ($name -and $name.Trim()) {
return ($name.Trim() -replace '\s+', '')
}
return "ElizaOSApp"
}
function Get-ChannelLabel {
param([string]$NormalizedChannel)
$root = Get-AppLabelRoot
switch ($NormalizedChannel) {
"stable" { return $root }
"canary" { return "$root Canary" }
default { return "$root $([char]::ToUpper($NormalizedChannel[0]))$($NormalizedChannel.Substring(1))" }
}
}
function Get-ChannelInstallName {
param([string]$NormalizedChannel)
$root = Get-AppFileRoot
if ($NormalizedChannel -eq "stable") {
return $root
}
return "$root-$NormalizedChannel"
}
function Get-InstallerSignSection {
$certBase64 = $env:WINDOWS_SIGN_CERT_BASE64
$certPassword = $env:WINDOWS_SIGN_CERT_PASSWORD
$timestampUrl = if ($env:WINDOWS_SIGN_TIMESTAMP_URL) {
$env:WINDOWS_SIGN_TIMESTAMP_URL
} else {
"http://timestamp.digicert.com"
}
if (-not $certBase64) {
Write-Host "::warning::WINDOWS_SIGN_CERT_BASE64 not set - building unsigned Inno Setup installer"
return "; installer signing disabled"
}
if (-not $certPassword) {
throw "WINDOWS_SIGN_CERT_BASE64 is set but WINDOWS_SIGN_CERT_PASSWORD is missing"
}
$signtool = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" -ErrorAction SilentlyContinue |
Where-Object { $_.FullName -match "x64" } |
Sort-Object { [version]($_.FullName -replace '.*\\(\d+\.\d+\.\d+\.\d+)\\.*', '$1') } -Descending |
Select-Object -First 1 -ExpandProperty FullName
if (-not $signtool) {
throw "signtool.exe not found. Ensure Windows SDK is installed."
}
$pfxPath = Join-Path $env:RUNNER_TEMP "elizaos-app-inno-signing-cert.pfx"
[System.IO.File]::WriteAllBytes($pfxPath, [System.Convert]::FromBase64String($certBase64))
$escapedPfxPath = Escape-InnoValue $pfxPath
$escapedPassword = Escape-InnoValue $certPassword
$escapedTimestampUrl = Escape-InnoValue $timestampUrl
return @(
"SignTool=signtool sign /f `$q$escapedPfxPath`$q /p `$q$escapedPassword`$q /fd sha256 /tr `$q$escapedTimestampUrl`$q /td sha256 /v `$f",
"SignedUninstaller=yes"
) -join "`r`n"
}
$normalizedChannel = $Channel.Trim().ToLowerInvariant()
if ([string]::IsNullOrWhiteSpace($normalizedChannel)) {
throw "Channel must not be empty"
}
$isccPath = Get-IsccPath -PreferredPath $CompilerPath
$templatePath = Join-Path $PSScriptRoot "ElizaOSApp.iss"
$iconPath = Join-Path $PSScriptRoot "..\..\platforms\electrobun\assets\appIcon.ico"
if (-not (Test-Path $templatePath)) {
throw "Inno Setup template not found: $templatePath"
}
if (-not (Test-Path $iconPath)) {
throw "Windows icon not found: $iconPath"
}
$launcher = Get-ChildItem -Path $BuildDir -Recurse -File -Filter "launcher.exe" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending |
Select-Object -First 1
if (-not $launcher) {
throw "launcher.exe not found under $BuildDir"
}
$launcherParent = Split-Path -Parent $launcher.FullName
# launcher.exe lives under bin/ in the Electrobun app bundle; the app root is one level up
$sourceDir = if ((Split-Path -Leaf $launcherParent) -eq "bin") {
Split-Path -Parent $launcherParent
} else {
$launcherParent
}
$elizaosDistEntry = Join-Path $sourceDir "Resources\app\eliza-dist\entry.js"
if (-not (Test-Path $elizaosDistEntry)) {
throw "Packaged app directory does not contain Resources\app\eliza-dist\entry.js: $sourceDir"
}
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
$channelInstallName = Get-ChannelInstallName -NormalizedChannel $normalizedChannel
$appName = Get-ChannelLabel -NormalizedChannel $normalizedChannel
$appFileRoot = Get-AppFileRoot
$appIdBase = if ($env:ELIZA_APP_ID -and $env:ELIZA_APP_ID.Trim()) { $env:ELIZA_APP_ID.Trim() } else { "ai.elizaos.app" }
$appId = if ($normalizedChannel -eq "stable") {
$appIdBase
} else {
"$appIdBase.$normalizedChannel"
}
# Keep install root short to avoid MAX_PATH (Error 206) when extracting deep
# runtime dependency trees on systems where long paths are not fully enabled.
$defaultDirName = "{localappdata}\$appFileRoot\$normalizedChannel"
$outputBaseFilename = "$appFileRoot-Setup-$normalizedChannel"
$signSection = Get-InstallerSignSection
$template = Get-Content $templatePath -Raw
$generated = $template
$generated = $generated.Replace("__APP_ID__", (Escape-InnoValue $appId))
$generated = $generated.Replace("__APP_NAME__", (Escape-InnoValue $appName))
$generated = $generated.Replace("__APP_VERSION__", (Escape-InnoValue $Version))
$generated = $generated.Replace("__DEFAULT_DIR_NAME__", (Escape-InnoValue $defaultDirName))
$generated = $generated.Replace("__DEFAULT_GROUP_NAME__", (Escape-InnoValue $appName))
$generated = $generated.Replace("__OUTPUT_DIR__", (Escape-InnoValue (Resolve-Path $OutputDir).Path))
$generated = $generated.Replace("__OUTPUT_BASE_FILENAME__", (Escape-InnoValue $outputBaseFilename))
$generated = $generated.Replace("__SOURCE_DIR__", (Escape-InnoValue (Resolve-Path $sourceDir).Path))
$generated = $generated.Replace("__ICON_FILE__", (Escape-InnoValue (Resolve-Path $iconPath).Path))
$generated = $generated.Replace("__SIGN_SETUP_LINES__", $signSection)
$generatedIssPath = Join-Path $env:RUNNER_TEMP "elizaos-app-$normalizedChannel-installer.iss"
Set-Content -Path $generatedIssPath -Value $generated -Encoding utf8
try {
$isccTimeout = [TimeSpan]::FromMinutes(25)
$isccHeartbeatInterval = [TimeSpan]::FromSeconds(30)
$isccArguments = @("/Qp", $generatedIssPath)
$isccArgumentDisplay = $isccArguments | ForEach-Object { if ($_ -match '\s') { "`"$_`"" } else { $_ } }
$isccStartedAt = Get-Date
Write-Host "Starting ISCC.exe: $isccPath $($isccArgumentDisplay -join ' ')"
$isccProcess = Start-Process -FilePath $isccPath -ArgumentList $isccArguments -PassThru -NoNewWindow
while (-not $isccProcess.HasExited) {
Start-Sleep -Milliseconds ([int]$isccHeartbeatInterval.TotalMilliseconds)
$isccProcess.Refresh()
if ($isccProcess.HasExited) {
break
}
$elapsed = (Get-Date) - $isccStartedAt
Write-Host "ISCC.exe still running after $([math]::Round($elapsed.TotalMinutes, 1)) minutes..."
if ($elapsed -ge $isccTimeout) {
try {
Stop-Process -Id $isccProcess.Id -Force -ErrorAction Stop
} catch {
Write-Warning "Failed to terminate hung ISCC.exe process $($isccProcess.Id): $($_.Exception.Message)"
}
throw "ISCC.exe timed out after $([int]$isccTimeout.TotalMinutes) minutes while building the Windows installer."
}
}
if ($isccProcess.ExitCode -ne 0) {
throw "ISCC.exe failed with exit code $($isccProcess.ExitCode)"
}
$installerPath = Join-Path (Resolve-Path $OutputDir).Path "$outputBaseFilename.exe"
if (-not (Test-Path $installerPath)) {
throw "Expected Inno Setup output not found: $installerPath"
}
$installer = Get-Item $installerPath
$minimumBytes = 50MB
if ($installer.Length -lt $minimumBytes) {
throw "Windows installer looks incomplete ($($installer.Length) bytes < $minimumBytes bytes). Refusing to publish a likely bootstrap stub."
}
Write-Host "Inno Setup installer created: $installerPath"
Write-Host "Installer size: $([math]::Round($installer.Length / 1MB, 1)) MB"
} finally {
Remove-Item $generatedIssPath -Force -ErrorAction SilentlyContinue
Remove-Item (Join-Path $env:RUNNER_TEMP "elizaos-app-inno-signing-cert.pfx") -Force -ErrorAction SilentlyContinue
}
@@ -0,0 +1,79 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
AppxManifest.store.xml — AppContainer-sandboxed manifest for Microsoft Store distribution.
This is the store-build flavor (ELIZA_BUILD_VARIANT=store). It deliberately omits
runFullTrust and windows.fullTrustApplication so the app runs as a regular AppContainer
process. As a consequence, local-agent execution paths that rely on PATH-lookup, spawning
external CLIs (node, bun, codex, claude), or arbitrary filesystem access are NOT available
in the store build — the app is forced into Cloud hosting mode (sandbox-runtime gating).
For the desktop build that can run local agents (NSIS/MSI / direct download), see
AppxManifest.xml.
Identity Name / Publisher / Version MUST match AppxManifest.xml so both flavors are
recognized as the same product by users.
-->
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="ElizaOS.App"
Publisher="CN=elizaOS"
Version="0.0.0.0"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>elizaOS App</DisplayName>
<PublisherDisplayName>elizaOS</PublisherDisplayName>
<Logo>assets\StoreLogo.png</Logo>
<Description>Personal AI assistant — cute agents for the acceleration</Description>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.22621.0" />
</Dependencies>
<Resources>
<Resource Language="en-us" />
</Resources>
<Applications>
<Application Id="ElizaOSApp"
Executable="launcher.exe">
<uap:VisualElements
DisplayName="elizaOS App"
Description="Personal AI assistant"
BackgroundColor="transparent"
Square150x150Logo="assets\Square150x150Logo.png"
Square44x44Logo="assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="assets\Wide310x150Logo.png" Square310x310Logo="assets\LargeTile.png" />
</uap:VisualElements>
<Extensions>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="elizaos-app" />
</uap:Extension>
</Extensions>
</Application>
</Applications>
<Capabilities>
<!--
AppContainer capability set. Intentionally minimal:
- internetClient: outbound HTTPS to Eliza Cloud and model providers.
- internetClientServer: the renderer talks to the bundled local API process
on a loopback port; loopback bind requires this capability.
- privateNetworkClientServer: NOT included by default. Add only if a future
feature needs to talk to a LAN device (e.g. local Ollama on another host).
- broadFileSystemAccess: NOT included. The app uses the file picker contract
for any file the user explicitly selects.
- runFullTrust: NOT included. This is the whole point of the store build —
stay inside the AppContainer sandbox.
-->
<Capability Name="internetClient" />
<Capability Name="internetClientServer" />
</Capabilities>
</Package>
@@ -0,0 +1,53 @@
<?xml version="1.0" encoding="utf-8"?>
<Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
IgnorableNamespaces="uap rescap">
<Identity
Name="ElizaOS.App"
Publisher="CN=elizaOS"
Version="0.0.0.0"
ProcessorArchitecture="x64" />
<Properties>
<DisplayName>elizaOS App</DisplayName>
<PublisherDisplayName>elizaOS</PublisherDisplayName>
<Logo>assets\StoreLogo.png</Logo>
<Description>Personal AI assistant — cute agents for the acceleration</Description>
</Properties>
<Dependencies>
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.22621.0" />
</Dependencies>
<Resources>
<Resource Language="en-us" />
</Resources>
<Applications>
<Application Id="ElizaOSApp"
Executable="launcher.exe"
EntryPoint="Windows.FullTrustApplication">
<uap:VisualElements
DisplayName="elizaOS App"
Description="Personal AI assistant"
BackgroundColor="transparent"
Square150x150Logo="assets\Square150x150Logo.png"
Square44x44Logo="assets\Square44x44Logo.png">
<uap:DefaultTile Wide310x150Logo="assets\Wide310x150Logo.png" Square310x310Logo="assets\LargeTile.png" />
</uap:VisualElements>
<Extensions>
<uap:Extension Category="windows.protocol">
<uap:Protocol Name="elizaos-app" />
</uap:Extension>
</Extensions>
</Application>
</Applications>
<Capabilities>
<Capability Name="internetClient" />
<rescap:Capability Name="runFullTrust" />
</Capabilities>
</Package>
+221
View File
@@ -0,0 +1,221 @@
# MSIX Packaging for Microsoft Store
## Overview
This directory builds MSIX packages for two distinct Windows distribution flavors,
selected via the `ELIZA_BUILD_VARIANT` env var:
| Variant | Manifest | Sandbox | Local agents | Distribution |
|---------|----------|---------|---------------|--------------|
| `direct` (default) | `AppxManifest.xml` | Full-trust (`runFullTrust`) | Yes | NSIS / MSI / direct download |
| `store` | `AppxManifest.store.xml` | AppContainer | **No** (cloud only) | Microsoft Store |
The two manifests share the same `Identity Name` (`ElizaOS.App`), `Publisher`
(`CN=elizaOS`), and `Version` placeholder, so users see them as the same product.
## Files
| File | Purpose |
|------|---------|
| `AppxManifest.xml` | Direct-build manifest (full-trust desktop) |
| `AppxManifest.store.xml` | Store-build manifest (AppContainer-sandboxed) |
| `build-msix.ps1` | Build script — picks manifest by `ELIZA_BUILD_VARIANT`, signs, verifies |
| `generate-placeholder-assets.ps1` | Creates placeholder visual assets |
| `assets/` | Tile / logo artwork |
| `store/` | Partner Center listing metadata + screenshots |
## Why two manifests
The original single manifest declared both `internetClient` AND `runFullTrust`,
which defeats AppContainer entirely — the app runs with full user privileges and
none of the store sandbox protections apply. Microsoft Store policy strongly prefers
sandboxed apps; full-trust packages require a `runFullTrust` restricted-capability
review and a higher bar for approval.
The split lets us keep full-trust behavior for users who download us directly
(local-agent operation requires it) while shipping a genuinely sandboxed flavor to
the Store.
## Capabilities chosen for the store build
`AppxManifest.store.xml` declares only what's required for cloud-mode operation:
- **`internetClient`** — outbound HTTPS to Eliza Cloud and model providers.
- **`internetClientServer`** — the renderer talks to the bundled local API process
on a loopback port; loopback bind requires this capability under AppContainer.
Deliberately excluded:
- **`runFullTrust`** (restricted) — would void the sandbox; whole point of the
store flavor is to live inside AppContainer.
- **`broadFileSystemAccess`** (restricted) — store users grant file access via the
file-picker contract per file. No app-wide `%USERPROFILE%` access.
- **`privateNetworkClientServer`** — only add if a future store-build feature
needs to talk to a LAN device (e.g. local Ollama on a different host). Not
needed for cloud-only operation.
We do not declare `runFullTrust` even with the `rescap` namespace. If we ever
need it, the discussion is "do we ship a different product on the Store" — not
"add the capability and hope the review board misses it."
## Store-build runtime constraints
Code paths that require full-trust will fail under AppContainer. The store build
forces cloud hosting mode and gates these paths off via the sandbox-runtime layer
(parallel work in the Foundation agent track). Specific paths to verify on a real
Windows AppContainer host before Partner Center submission:
- **`plugins/plugin-agent-orchestrator/src/services/pty-init.ts`** —
`resolveNodeWorkerPath()` walks `/opt/homebrew/bin/node`, `/usr/local/bin/node`,
and `~/.nvm/versions/node/*/bin/node` looking for a Node binary. Under
AppContainer, none of those paths are reachable, and `PATH`-lookup spawns of
arbitrary binaries (`node`, `bun`, `codex`, `claude`) are blocked. The store
build must never reach this code; gating belongs in the runtime-mode resolver.
- **`packages/app-core/scripts/desktop-build.mjs`** — uses `child_process.spawn`
for build-time tooling (rcedit, vite, electrobun). This runs at build time on
the developer/CI machine, not at runtime inside the package, so it is not an
AppContainer concern.
- **Bun-runtime subprocess shell** — anything that spawns user-supplied commands
(`runShell` chokepoint, `EXECUTE_CODE` action, coding-agent PTY adapters) must
be disabled in store builds. The runtime-mode flag must hard-error these
surfaces before they reach `Bun.spawn` / `child_process.spawn`.
- **Filesystem writes outside the package storage** — AppContainer redirects
writes under `%USERPROFILE%` to per-app virtualized locations. Code that writes
to `~/.eliza/...` outside the runtime workspace must use either the package
storage API or ask via file picker. The default state-dir resolution
(`ELIZA_STATE_DIR`) already points at the per-user app data path, so this
largely works; verify `~/.eliza/optimized-prompts` and
`~/.eliza/audit/app-loads.jsonl` writes succeed inside the package container.
This list is verification-only; the actual gating lives in the sandbox-runtime
agent's work. If you hit a failing path here, file it against that agent rather
than punching a hole in the manifest.
## Wire-up with desktop-build
`packages/app-core/scripts/desktop-build.mjs` produces the Electrobun bundle (the
input to MSIX) but does NOT build MSIX itself. MSIX is a separate Windows-only
step driven by `build-msix.ps1`. To produce a store MSIX:
```powershell
$env:ELIZA_BUILD_VARIANT = "store"
pwsh -File packaging/msix/build-msix.ps1 `
-BuildDir ./apps/app/electrobun/build `
-OutputDir ./apps/app/electrobun/artifacts `
-Version "2.0.0-beta.0"
```
The `ELIZA_BUILD_VARIANT` env var is the same flag used by the runtime to gate
local-agent execution and force cloud hosting mode. Setting it for the build also
sets it for the packaged app's runtime via Electrobun's env propagation.
## Prerequisites
1. **Code signing**:
- For `direct` builds: `WINDOWS_SIGN_CERT_BASE64` + `WINDOWS_SIGN_CERT_PASSWORD`,
or Azure Trusted Signing.
- For `store` builds: `ELIZA_MSIX_STORE_CERT_PATH` (path to the `.pfx` issued
by Partner Center for the registered Identity Name) +
`ELIZA_MSIX_STORE_CERT_PASSWORD`. If absent, the MSIX is delivered unsigned
and Partner Center re-signs server-side on upload.
2. **Windows SDK** — installed on CI runner (available on `windows-latest`).
3. **Microsoft Partner Center account** — for Store submission ($19 one-time).
## Building locally
```powershell
# 1. Sign the executables first (direct flavor)
pwsh -File apps/app/electrobun/scripts/sign-windows.ps1 `
-ArtifactsDir ./apps/app/electrobun/artifacts `
-BuildDir ./apps/app/electrobun/build
# 2. Build MSIX (default: direct)
pwsh -File packaging/msix/build-msix.ps1 `
-BuildDir ./apps/app/electrobun/build `
-OutputDir ./apps/app/electrobun/artifacts `
-Version "2.0.0-beta.0"
# 3. Build store MSIX
$env:ELIZA_BUILD_VARIANT = "store"
pwsh -File packaging/msix/build-msix.ps1 `
-BuildDir ./apps/app/electrobun/build `
-OutputDir ./apps/app/electrobun/artifacts `
-Version "2.0.0-beta.0"
```
Output filenames:
- `direct`: `ElizaOSApp-<version>-x64.msix`
- `store`: `ElizaOSApp-<version>-x64-store.msix`
## CI pipeline
`release-electrobun.yml` runs the direct flavor automatically when
`WINDOWS_SIGN_CERT_BASE64` is configured. The store flavor is opt-in: set
`ELIZA_BUILD_VARIANT=store` on the CI step that targets Partner Center upload.
## Store submission
1. Create a Microsoft Partner Center account at https://partner.microsoft.com.
2. Register the app identity (`ElizaOS.App`).
3. **Set Identity env vars** on the CI step that produces the store MSIX
(preferred over editing the manifest file in-tree):
- `ELIZA_MSIX_IDENTITY_NAME` — Partner Center-registered app name, e.g.
`ElizaOS.App` (often the same as the placeholder, but Partner Center may
issue a namespace-scoped name).
- `ELIZA_MSIX_PUBLISHER_ID` — full `Publisher` attribute, e.g.
`CN=XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX`. MUST match the publisher ID
issued for your Partner Center account.
- `ELIZA_MSIX_PUBLISHER_DISPLAY_NAME` — human-readable publisher, e.g.
`elizaOS Labs`. Defaults to the placeholder `elizaOS` if unset.
`build-msix.ps1` substitutes these into the staged manifest before
`makeappx pack`. When unset, the script prints a `::warning::` and ships
the placeholder values (Partner Center will reject the upload).
4. Replace placeholder assets in `assets/` with final artwork.
5. Add screenshots to `store/screenshots/`.
6. Build the store MSIX (`ELIZA_BUILD_VARIANT=store` plus the three Identity
env vars) and upload via Partner Center. Submit for certification review.
Example CI snippet:
```yaml
env:
ELIZA_BUILD_VARIANT: store
ELIZA_MSIX_IDENTITY_NAME: ElizaOS.App
ELIZA_MSIX_PUBLISHER_ID: CN=12345678-90AB-CDEF-1234-567890ABCDEF
ELIZA_MSIX_PUBLISHER_DISPLAY_NAME: elizaOS Labs
ELIZA_MSIX_STORE_CERT_PATH: ${{ secrets.ELIZA_MSIX_STORE_CERT_PATH }}
ELIZA_MSIX_STORE_CERT_PASSWORD: ${{ secrets.ELIZA_MSIX_STORE_CERT_PASSWORD }}
```
## Updating the publisher identity
After registering in Partner Center, update the `Publisher=` attribute in:
- `AppxManifest.xml`
- `AppxManifest.store.xml`
- `store/listing.json``identity.publisher`
The values must match exactly. A mismatch between the manifest's `Publisher` and
the certificate subject (or the Partner Center identity) is the most common
cause of a rejected submission.
## What to verify on a Windows host before submission
These cannot be verified from a non-Windows worktree; they are a checklist for
the Windows submission engineer:
- [ ] `xmllint --noout AppxManifest.store.xml` (or any XML validator) succeeds.
- [ ] `makeappx pack /d <staging> /p out.msix /o` succeeds against the store
manifest.
- [ ] `signtool verify /pa /v out-store.msix` returns success when signed with
`ELIZA_MSIX_STORE_CERT_PATH`.
- [ ] App launches inside AppContainer (Task Manager → Details column "AppContainer
= Yes" on the launcher process).
- [ ] Renderer reaches the local API on its loopback port without firewall prompt.
- [ ] Outbound HTTPS to Eliza Cloud succeeds.
- [ ] Cloud hosting mode is active; Settings → Hosting shows "Cloud (sandboxed)";
attempting any local-agent action surfaces the gating error rather than
silently failing.
- [ ] No write attempts hit `%USERPROFILE%` outside the per-app virtualized
locations (use Process Monitor with the launcher process to confirm).
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -0,0 +1,236 @@
# build-msix.ps1 — Build an MSIX package from a signed Electrobun Windows build.
# Intended to run in CI after sign-windows.ps1.
#
# Usage:
# pwsh -File build-msix.ps1 -BuildDir ./build -OutputDir ./artifacts -Version 2.0.0
#
# Variant selection (env var ELIZA_BUILD_VARIANT):
# - "store" — Microsoft Store flavor. Uses AppxManifest.store.xml (AppContainer-
# sandboxed, no runFullTrust). Local-agent execution is gated off at
# runtime; cloud hosting only. Sign with the Microsoft Store certificate
# (env var ELIZA_MSIX_STORE_CERT_PATH) — see store certificate note below.
# - "direct" — Default. Uses AppxManifest.xml (full-trust desktop). Distributed via
# NSIS/MSI; supports local agents.
#
# Prerequisites:
# - Windows SDK installed (for makeappx.exe and signtool.exe)
# - Executables already code-signed (sign-windows.ps1 or Azure Trusted Signing)
# - Either WINDOWS_SIGN_CERT_BASE64 + WINDOWS_SIGN_CERT_PASSWORD, or AZURE_TENANT_ID,
# or SKIP_MSIX_SIGN (build unsigned MSIX for SKIP_WINDOWS_SIGNING / Azure path)
param(
[Parameter(Mandatory)][string]$BuildDir,
[Parameter(Mandatory)][string]$OutputDir,
[Parameter(Mandatory)][string]$Version
)
$ErrorActionPreference = "Stop"
# Build variant — "store" (AppContainer / Microsoft Store) or "direct" (full-trust NSIS/MSI flavor).
$buildVariant = if ($env:ELIZA_BUILD_VARIANT) { $env:ELIZA_BUILD_VARIANT.ToLower() } else { "direct" }
if ($buildVariant -ne "store" -and $buildVariant -ne "direct") {
Write-Error "Invalid ELIZA_BUILD_VARIANT='$buildVariant' (expected 'store' or 'direct')"
exit 1
}
Write-Host "Build variant: $buildVariant"
$certBase64 = $env:WINDOWS_SIGN_CERT_BASE64
$certPassword = $env:WINDOWS_SIGN_CERT_PASSWORD
$timestampUrl = if ($env:WINDOWS_SIGN_TIMESTAMP_URL) { $env:WINDOWS_SIGN_TIMESTAMP_URL } else { "http://timestamp.digicert.com" }
$azureSigning = $env:AZURE_TENANT_ID -or $env:AZURE_CLIENT_ID -or $env:SKIP_MSIX_SIGN -or $env:SKIP_WINDOWS_SIGNING
if ($buildVariant -eq "store") {
# Store certificate note: When Microsoft Partner Center registration is finalized, set
# ELIZA_MSIX_STORE_CERT_PATH (path to the .pfx issued for the registered Identity Name)
# and ELIZA_MSIX_STORE_CERT_PASSWORD. Until then, store builds run unsigned and the
# Partner Center upload pipeline re-signs server-side. The Identity Publisher in
# AppxManifest.store.xml MUST match the publisher ID issued by Partner Center.
$storeCertPath = $env:ELIZA_MSIX_STORE_CERT_PATH
$storeCertPassword = $env:ELIZA_MSIX_STORE_CERT_PASSWORD
if ($storeCertPath -and (Test-Path $storeCertPath)) {
Write-Host "Store cert: $storeCertPath"
} else {
Write-Host "ELIZA_MSIX_STORE_CERT_PATH not set or missing — store MSIX will be built unsigned for Partner Center re-sign."
}
}
if (-not $certBase64 -and -not $azureSigning) {
Write-Host "::warning::WINDOWS_SIGN_CERT_BASE64 not set and no Azure Trusted Signing - skipping MSIX generation"
exit 0
}
# Find SDK tools
$sdkBin = Get-ChildItem "C:\Program Files (x86)\Windows Kits\10\bin" -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -match '^\d+\.\d+\.\d+\.\d+$' } |
Sort-Object { [version]$_.Name } -Descending |
Select-Object -First 1
if (-not $sdkBin) {
Write-Error "Windows SDK not found"
exit 1
}
$makeappx = Join-Path $sdkBin.FullName "x64\makeappx.exe"
$signtool = Join-Path $sdkBin.FullName "x64\signtool.exe"
if (-not (Test-Path $makeappx)) {
Write-Error "makeappx.exe not found at: $makeappx"
exit 1
}
Write-Host "Using makeappx: $makeappx"
Write-Host "Using signtool: $signtool"
# Prepare MSIX staging directory
$msixStaging = Join-Path $env:RUNNER_TEMP "msix-staging"
Remove-Item $msixStaging -Recurse -Force -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Force -Path $msixStaging | Out-Null
# Find the Electrobun build output (launcher.exe and its directory)
$launcher = Get-ChildItem -Path $BuildDir -Recurse -Filter "launcher.exe" -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $launcher) {
Write-Error "launcher.exe not found under $BuildDir"
exit 1
}
$launcherParent = Split-Path -Parent $launcher.FullName
# launcher.exe lives under bin/ in the Electrobun app bundle; the app root is one level up
$appDir = if ((Split-Path -Leaf $launcherParent) -eq "bin") {
Split-Path -Parent $launcherParent
} else {
$launcherParent
}
Write-Host "App directory: $appDir"
# Copy app contents to staging
Copy-Item -Path "$appDir\*" -Destination $msixStaging -Recurse -Force
# Copy MSIX assets
$msixDir = $PSScriptRoot
$assetsSource = Join-Path $msixDir "assets"
$assetsDest = Join-Path $msixStaging "assets"
New-Item -ItemType Directory -Force -Path $assetsDest | Out-Null
Copy-Item -Path "$assetsSource\*" -Destination $assetsDest -Recurse -Force
# Process AppxManifest — inject version. Pick variant-specific manifest.
$manifestFileName = if ($buildVariant -eq "store") { "AppxManifest.store.xml" } else { "AppxManifest.xml" }
$manifestSource = Join-Path $msixDir $manifestFileName
$manifestDest = Join-Path $msixStaging "AppxManifest.xml"
Write-Host "Manifest source: $manifestSource"
if (-not (Test-Path $manifestSource)) {
Write-Error "Manifest not found: $manifestSource"
exit 1
}
# Convert semver (2.0.0-beta.0) to Windows quad version (2.0.0.0)
$parts = $Version -split '[-.]'
$major = $parts[0]
$minor = $parts[1]
$patch = $parts[2]
$build = if ($parts.Count -ge 5) { $parts[4] } else { "0" }
$winVersion = "$major.$minor.$patch.$build"
$manifestContent = Get-Content $manifestSource -Raw
$manifestContent = $manifestContent -replace 'Version="0\.0\.0\.0"', "Version=`"$winVersion`""
# Identity parameterization for Partner Center submissions.
#
# When uploading to the Microsoft Store, the Identity.Name and Identity.Publisher
# attributes MUST match the values issued by Partner Center for the registered app.
# Until those IDs are provisioned the manifest ships with placeholders, which would
# be rejected on upload. Allow env-var override so CI can substitute real values
# without forking the manifest.
if ($buildVariant -eq "store") {
$identityName = $env:ELIZA_MSIX_IDENTITY_NAME
$publisherId = $env:ELIZA_MSIX_PUBLISHER_ID
$publisherDisplayName = $env:ELIZA_MSIX_PUBLISHER_DISPLAY_NAME
if ($identityName) {
$manifestContent = $manifestContent -replace 'Name="ElizaOS\.App"', "Name=`"$identityName`""
Write-Host "Identity.Name set to: $identityName"
} else {
Write-Host "::warning::ELIZA_MSIX_IDENTITY_NAME not set — store MSIX will use placeholder 'ElizaOS.App' (Partner Center upload will reject)."
}
if ($publisherId) {
$manifestContent = $manifestContent -replace 'Publisher="CN=elizaOS"', "Publisher=`"$publisherId`""
Write-Host "Identity.Publisher set to: $publisherId"
} else {
Write-Host "::warning::ELIZA_MSIX_PUBLISHER_ID not set — store MSIX will use placeholder 'CN=elizaOS' (Partner Center upload will reject)."
}
if ($publisherDisplayName) {
$manifestContent = $manifestContent -replace '<PublisherDisplayName>elizaOS</PublisherDisplayName>', "<PublisherDisplayName>$publisherDisplayName</PublisherDisplayName>"
Write-Host "PublisherDisplayName set to: $publisherDisplayName"
}
}
Set-Content -Path $manifestDest -Value $manifestContent
Write-Host "Manifest version set to: $winVersion"
# Build MSIX package
$variantSuffix = if ($buildVariant -eq "store") { "-store" } else { "" }
$msixOutput = Join-Path $OutputDir "ElizaOSApp-$Version-x64$variantSuffix.msix"
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
& $makeappx pack /d $msixStaging /p $msixOutput /o
if ($LASTEXITCODE -ne 0) {
Write-Error "makeappx pack failed"
exit 1
}
Write-Host "MSIX package created: $msixOutput"
# Pick signing identity. Store builds prefer ELIZA_MSIX_STORE_CERT_PATH; if absent, fall
# through to the standard signing path (Azure Trusted Signing or WINDOWS_SIGN_CERT_BASE64).
# If neither is configured, the MSIX is delivered unsigned for Partner Center server-side re-sign.
$useStoreCert = $false
$storeCertPath = $env:ELIZA_MSIX_STORE_CERT_PATH
$storeCertPassword = $env:ELIZA_MSIX_STORE_CERT_PASSWORD
if ($buildVariant -eq "store" -and $storeCertPath -and (Test-Path $storeCertPath)) {
$useStoreCert = $true
}
if ($env:SKIP_MSIX_SIGN -or ($azureSigning -and -not $certBase64 -and -not $useStoreCert)) {
if ($env:SKIP_WINDOWS_SIGNING) {
Write-Host "SKIP_WINDOWS_SIGNING - delivering unsigned MSIX"
} else {
Write-Host "Azure Trusted Signing path - skipping PFX signing. Azure will sign the MSIX next."
}
exit 0
}
if ($useStoreCert) {
Write-Host "Signing store MSIX with ELIZA_MSIX_STORE_CERT_PATH"
& $signtool sign /f $storeCertPath /p $storeCertPassword /fd sha256 /tr $timestampUrl /td sha256 /v $msixOutput
if ($LASTEXITCODE -ne 0) {
Write-Error "Failed to sign MSIX package with store cert"
exit 1
}
} else {
if (-not $certBase64) {
Write-Host "No signing cert available — delivering unsigned MSIX"
exit 0
}
# Sign the MSIX package with the dev/CI cert (WINDOWS_SIGN_CERT_BASE64)
$pfxPath = Join-Path $env:RUNNER_TEMP "code-signing-cert.pfx"
[System.IO.File]::WriteAllBytes($pfxPath, [System.Convert]::FromBase64String($certBase64))
& $signtool sign /f $pfxPath /p $certPassword /fd sha256 /tr $timestampUrl /td sha256 /v $msixOutput
if ($LASTEXITCODE -ne 0) {
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
Write-Error "Failed to sign MSIX package"
exit 1
}
Remove-Item $pfxPath -Force -ErrorAction SilentlyContinue
}
# Verify
& $signtool verify /pa /v $msixOutput
if ($LASTEXITCODE -ne 0) {
Write-Error "MSIX signature verification failed"
exit 1
}
Write-Host "MSIX package signed and verified: $msixOutput"
@@ -0,0 +1,49 @@
# generate-placeholder-assets.ps1 — Generate placeholder MSIX visual assets.
# Run manually once, then replace with final artwork before Store submission.
#
# Usage: pwsh -File generate-placeholder-assets.ps1
param(
[string]$OutputDir = (Join-Path $PSScriptRoot "assets")
)
$ErrorActionPreference = "Stop"
New-Item -ItemType Directory -Force -Path $OutputDir | Out-Null
Add-Type -AssemblyName System.Drawing
$sizes = @{
"StoreLogo.png" = @(50, 50)
"Square44x44Logo.png" = @(44, 44)
"Square150x150Logo.png" = @(150, 150)
"Wide310x150Logo.png" = @(310, 150)
"LargeTile.png" = @(310, 310)
}
foreach ($entry in $sizes.GetEnumerator()) {
$width = $entry.Value[0]
$height = $entry.Value[1]
$bitmap = New-Object System.Drawing.Bitmap($width, $height)
$graphics = [System.Drawing.Graphics]::FromImage($bitmap)
$graphics.Clear([System.Drawing.Color]::FromArgb(255, 26, 26, 46))
$fontSize = [math]::Min($width, $height) * 0.4
$font = New-Object System.Drawing.Font("Arial", $fontSize, [System.Drawing.FontStyle]::Bold)
$brush = New-Object System.Drawing.SolidBrush([System.Drawing.Color]::White)
$sf = New-Object System.Drawing.StringFormat
$sf.Alignment = [System.Drawing.StringAlignment]::Center
$sf.LineAlignment = [System.Drawing.StringAlignment]::Center
$rect = New-Object System.Drawing.RectangleF(0, 0, $width, $height)
$graphics.DrawString("M", $font, $brush, $rect, $sf)
$outputPath = Join-Path $OutputDir $entry.Key
$bitmap.Save($outputPath, [System.Drawing.Imaging.ImageFormat]::Png)
$graphics.Dispose()
$bitmap.Dispose()
Write-Host "Created: $($entry.Key) (${width}x${height})"
}
Write-Host "Placeholder assets generated in $OutputDir"
Write-Host "Replace these with final artwork before Store submission."
@@ -0,0 +1,31 @@
# elizaOS App — Microsoft Store Listing
## Short Description (max 100 chars)
Personal AI assistant that runs on your machine. Private, fast, extensible.
## Description (max 10,000 chars)
elizaOS App is a personal AI assistant that runs entirely on YOUR machine. Not in the cloud. Not on someone else's server. Your computer.
Built on elizaOS, it manages your sessions, tools, and workflows through a Gateway control plane. Connect to Telegram, Discord, or use the built-in WebChat UI.
**Features:**
- Local-first AI — your data stays on your machine
- Multiple AI providers (Anthropic Claude, OpenAI GPT, Ollama for fully local)
- Plugin system for extensibility
- Built-in web dashboard at localhost:2138
- Gateway for multi-client connections
- Customizable agent personality
**Requirements:**
- Windows 10 version 1809 or later
- 8 GB RAM minimum (16 GB recommended)
- Internet connection for cloud AI providers (optional with Ollama)
## Search Keywords
AI, assistant, local AI, privacy, agent, chatbot, ollama, claude, GPT
## Category
Productivity
## Age Rating
12+
@@ -0,0 +1,27 @@
{
"$schema": "https://partner.microsoft.com/dashboard",
"_comment": "Microsoft Partner Center listing metadata template. Fill in before submission.",
"identity": {
"name": "ElizaOS.App",
"publisher": "CN=PLACEHOLDER_PUBLISHER_ID",
"publisherDisplayName": "elizaOS"
},
"listing": {
"language": "en-us",
"displayName": "elizaOS App",
"shortDescription": "Personal AI assistant that runs on your machine. Private, fast, extensible.",
"category": "Productivity",
"subcategory": "Personal Assistant",
"ageRating": "12+",
"website": "https://app.elizacloud.ai",
"supportUrl": "https://github.com/elizaos/elizaos-app/issues",
"privacyUrl": "https://app.elizacloud.ai/privacy"
},
"screenshots": {
"_comment": "Required: 1-10 screenshots, 1366x768 or 2560x1440 recommended",
"files": []
},
"pricing": {
"model": "free"
}
}
@@ -0,0 +1,14 @@
# elizaos-app
Python launcher for the elizaOS App.
This package provides the `elizaos-app` command for environments that install
applications through PyPI. The launcher validates that Node.js is available,
then delegates to the version-matched npm `elizaos` command.
```bash
pip install elizaos-app
elizaos-app --help
```
Node.js 22 or newer must be available on `PATH`.
@@ -0,0 +1,7 @@
"""Python launcher for the elizaOS App."""
from .loader import ensure_runtime, get_version, run
__version__ = "2.0.0b0"
__all__ = ["__version__", "ensure_runtime", "get_version", "run"]
@@ -0,0 +1,6 @@
"""Module entry point for `python -m elizaos_app`."""
from .cli import main
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,21 @@
"""Console entry point for the elizaOS App launcher."""
from __future__ import annotations
import sys
from collections.abc import Sequence
from .loader import ElizaOSAppError, run
def main(argv: Sequence[str] | None = None) -> int:
"""Run the npm-backed elizaOS App command."""
try:
return run(argv)
except ElizaOSAppError as error:
print(f"elizaos-app: {error}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,127 @@
"""Runtime checks and delegation for the elizaOS App PyPI package."""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
from collections.abc import Sequence
from importlib import metadata
MIN_NODE_VERSION = (22, 0, 0)
DEFAULT_NPM_PACKAGE = "elizaos"
class ElizaOSAppError(RuntimeError):
"""Base error for elizaOS App launcher failures."""
class NodeNotFoundError(ElizaOSAppError):
"""Raised when Node.js cannot be found."""
class RuntimeInstallError(ElizaOSAppError):
"""Raised when the npm runtime cannot be launched."""
def _parse_version(value: str) -> tuple[int, int, int] | None:
match = re.search(r"v?(\d+)\.(\d+)\.(\d+)", value.strip())
if not match:
return None
return tuple(int(part) for part in match.groups())
def _find_node() -> str | None:
return shutil.which("node")
def _get_node_version(node: str) -> tuple[int, int, int] | None:
try:
result = subprocess.run(
[node, "--version"],
check=False,
capture_output=True,
text=True,
timeout=10,
)
except OSError:
return None
if result.returncode != 0:
return None
return _parse_version(result.stdout.strip())
def _check_node(min_version: tuple[int, int, int] = MIN_NODE_VERSION) -> str:
node = _find_node()
if not node:
raise NodeNotFoundError("Node.js 22 or newer is required on PATH")
version = _get_node_version(node)
if version is None:
raise RuntimeInstallError("could not determine Node.js version")
if version < min_version:
required = ".".join(str(part) for part in min_version)
found = ".".join(str(part) for part in version)
raise RuntimeInstallError(
f"Node.js {required} or newer is required; found {found}"
)
return node
def _pep440_to_npm_version(version: str) -> str:
match = re.fullmatch(r"(\d+\.\d+\.\d+)b(\d+)", version)
if match:
return f"{match.group(1)}-beta.{match.group(2)}"
return version
def get_version() -> str:
try:
return metadata.version("elizaos-app")
except metadata.PackageNotFoundError:
from . import __version__
return __version__
def ensure_runtime() -> str:
"""Validate runtime prerequisites and return the Node.js executable path."""
return _check_node()
def _npm_exec_command(argv: Sequence[str]) -> list[str]:
npm = shutil.which("npm")
if not npm:
raise RuntimeInstallError("npm is required to launch elizaOS App")
package_name = os.environ.get("ELIZAOS_APP_NPM_PACKAGE", DEFAULT_NPM_PACKAGE)
package_version = os.environ.get(
"ELIZAOS_APP_NPM_VERSION",
_pep440_to_npm_version(get_version()),
)
package_spec = (
f"{package_name}@{package_version}" if package_version else package_name
)
return [
npm,
"exec",
"--yes",
"--package",
package_spec,
"--",
"elizaos",
*argv,
]
def run(argv: Sequence[str] | None = None) -> int:
"""Run the version-matched npm elizaOS command."""
ensure_runtime()
args = list(sys.argv[1:] if argv is None else argv)
command = _npm_exec_command(args)
try:
return subprocess.call(command)
except OSError as error:
raise RuntimeInstallError(str(error)) from error
@@ -0,0 +1 @@
@@ -0,0 +1,38 @@
[build-system]
requires = ["setuptools>=69", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "elizaos-app"
version = "2.0.0b0"
description = "Python launcher for the elizaOS App"
readme = "README.md"
requires-python = ">=3.9"
license = "MIT"
authors = [{ name = "elizaOS" }]
keywords = ["elizaos", "agent", "ai", "launcher"]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.9",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
]
[project.urls]
Homepage = "https://github.com/elizaOS/eliza"
Issues = "https://github.com/elizaOS/eliza/issues"
[project.scripts]
elizaos-app = "elizaos_app.cli:main"
[tool.setuptools.packages.find]
include = ["elizaos_app*"]
[tool.setuptools.package-data]
elizaos_app = ["py.typed"]
@@ -0,0 +1,565 @@
name: elizaos-app
version: "2.0.0-beta.0"
summary: Personal AI assistant built on elizaOS
description: |
elizaOS App is a personal AI assistant you run on your own devices, built on
elizaOS. It provides zero-config onboarding, multi-provider AI support,
a web dashboard, and a plugin system for extensibility.
Features:
- Interactive setup on first run — pick a name, personality, and AI provider
- Support for Anthropic (Claude), OpenAI, Google Gemini, Ollama, and more
- Web dashboard at http://localhost:2138
- Gateway API at http://localhost:18789
- Plugin system for extensibility
- Web3 wallet integration (EVM + Solana)
- Desktop apps for macOS, Windows, and Linux
Quick start: sudo snap install elizaos-app && elizaos-app setup
grade: devel
confinement: strict
base: core22
license: MIT
website: https://app.elizacloud.ai
source-code: https://github.com/elizaOS/eliza
issues: https://github.com/elizaOS/eliza/issues
architectures:
- build-on: [amd64]
build-for: [amd64]
- build-on: [arm64]
build-for: [arm64]
layout:
/usr/lib/x86_64-linux-gnu/libatomic.so.1:
symlink: $SNAP/usr/lib/x86_64-linux-gnu/libatomic.so.1
apps:
elizaos-app:
command: bin/elizaos-app-wrapper
plugs:
- network
- network-bind
- home
- desktop
- browser-support
- password-manager-service
environment:
PATH: $SNAP/node/bin:$SNAP/usr/bin:$SNAP/bin:$PATH
NODE_PATH: $SNAP/lib/elizaos-app/node_modules
HOME: $SNAP_USER_DATA
ELIZAOS_APP_DATA_DIR: $SNAP_USER_COMMON/.elizaos-app
# Ensure Node.js can resolve DNS and make HTTPS requests
NODE_EXTRA_CA_CERTS: /etc/ssl/certs/ca-certificates.crt
# Snap-specific: tell elizaOS App where config lives
XDG_CONFIG_HOME: $SNAP_USER_COMMON
XDG_DATA_HOME: $SNAP_USER_COMMON
parts:
node:
plugin: nil
override-build: |
set -eu
NODE_VERSION="22.12.0"
ARCH=$(dpkg --print-architecture)
case "$ARCH" in
amd64) NODE_ARCH="x64" ;;
arm64) NODE_ARCH="arm64" ;;
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
esac
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" \
| tar xJ --strip-components=1 -C "$CRAFT_PART_INSTALL"
export PATH="$CRAFT_PART_INSTALL/bin:$PATH"
"$CRAFT_PART_INSTALL/bin/node" --version
"$CRAFT_PART_INSTALL/bin/npm" --version
organize:
"*": node/
bun:
plugin: nil
override-build: |
set -eu
ARCH=$(dpkg --print-architecture)
case "$ARCH" in
amd64) BUN_ARCH="x64" ;;
arm64) BUN_ARCH="aarch64" ;;
*) echo "Unsupported arch: $ARCH"; exit 1 ;;
esac
curl -fsSL "https://github.com/oven-sh/bun/releases/latest/download/bun-linux-${BUN_ARCH}.zip" -o /tmp/bun.zip
unzip -o /tmp/bun.zip -d /tmp/bun-extract
mkdir -p "$CRAFT_PART_INSTALL/bun/bin"
cp /tmp/bun-extract/bun-linux-${BUN_ARCH}/bun "$CRAFT_PART_INSTALL/bun/bin/bun"
chmod +x "$CRAFT_PART_INSTALL/bun/bin/bun"
ln -sf bun "$CRAFT_PART_INSTALL/bun/bin/bunx"
"$CRAFT_PART_INSTALL/bun/bin/bun" --version
build-packages:
- unzip
- curl
elizaos-app:
after: [node, bun]
plugin: nil
source: .
override-build: |
set -eu
export PATH="$CRAFT_STAGE/bun/bin:$CRAFT_STAGE/node/bin:$PATH"
APP_CORE_DIR="packages/app-core"
PACKAGES_DIR="packages"
APP_CORE_SCRIPTS_DIR="$APP_CORE_DIR/scripts"
RM_PATH_RECURSIVE="packages/scripts/rm-path-recursive.mjs"
# Skip building local upstreams and native Capacitor plugins in the snap build.
export ELIZAOS_APP_SKIP_LOCAL_UPSTREAMS=1
export SKIP_NATIVE_PLUGINS=1
# Remove plugin source directories outside the snap runtime closure so
# tsc/build does not attempt to compile unrelated app/plugin workspaces.
# Keep the local beta runtime workspaces so the snap ships the same code
# as the npm tarballs instead of resolving stale registry prereleases.
node -e '
const fs = require("fs");
const path = require("path");
if (!fs.existsSync("plugins")) process.exit(0);
const keep = new Set([
"plugin-agent-skills",
"plugin-anthropic",
"plugin-browser",
"plugin-capacitor-bridge",
"plugin-commands",
"plugin-elizacloud",
"plugin-imessage",
"plugin-personal-assistant",
"plugin-local-inference",
"plugin-ollama",
"plugin-pdf",
"plugin-signal",
"plugin-sql",
"plugin-streaming",
"plugin-task-coordinator",
"plugin-telegram",
"plugin-training",
"plugin-video",
"plugin-wallet",
"plugin-wallet-ui",
"plugin-wechat",
"plugin-whatsapp",
]);
for (const dir of fs.readdirSync("plugins").filter(d =>
fs.statSync(path.join("plugins", d)).isDirectory()
)) {
if (keep.has(dir)) continue;
fs.rmSync(path.join("plugins", dir), { recursive: true, force: true });
}
'
# Also drop any remaining `workspace:*` entries that point at
# plugin workspaces we just removed. This has to scan every
# surviving package.json, not just the root + packages/agent,
# because package-level devDependencies such as app-core's
# plugin-agent-skills workspace refs also participate
# in Bun's workspace resolution.
node -e '
const fs = require("fs");
const path = require("path");
const repoRoot = process.cwd();
const packageJsonPaths = [path.join(repoRoot, "package.json")];
const rootPkg = JSON.parse(
fs.readFileSync(path.join(repoRoot, "package.json"), "utf8")
);
const availableWorkspaceNames = new Set();
const dependencySections = [
"dependencies",
"devDependencies",
"peerDependencies",
"optionalDependencies",
];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name.startsWith(".git")) {
continue;
}
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
continue;
}
if (entry.isFile() && entry.name === "package.json") {
packageJsonPaths.push(entryPath);
}
}
}
const workspaceRoots = new Set();
for (const entry of Array.isArray(rootPkg.workspaces) ? rootPkg.workspaces : []) {
let scanRoot = path.join(repoRoot, String(entry).split("*")[0]);
while (scanRoot !== repoRoot && !fs.existsSync(scanRoot)) {
scanRoot = path.dirname(scanRoot);
}
if (fs.existsSync(scanRoot)) {
workspaceRoots.add(scanRoot);
}
}
for (const dir of workspaceRoots) {
walk(dir);
}
for (const pkgPath of packageJsonPaths) {
try {
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (typeof pkg.name === "string" && pkg.name.length > 0) {
availableWorkspaceNames.add(pkg.name);
}
} catch {}
}
function stripWorkspaceDeps(pkgPath) {
if (!fs.existsSync(pkgPath)) return;
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
let mutated = false;
for (const section of dependencySections) {
if (!pkg[section]) continue;
for (const [k, v] of Object.entries(pkg[section])) {
if (
typeof v === "string" &&
v.startsWith("workspace:") &&
!availableWorkspaceNames.has(k)
) {
delete pkg[section][k];
mutated = true;
}
}
}
if (pkg.overrides && typeof pkg.overrides === "object") {
for (const [k, v] of Object.entries(pkg.overrides)) {
if (
typeof v === "string" &&
v.startsWith("workspace:") &&
!availableWorkspaceNames.has(k)
) {
delete pkg.overrides[k];
mutated = true;
}
}
if (Object.keys(pkg.overrides).length === 0) {
delete pkg.overrides;
}
}
if (Array.isArray(pkg.workspaces)) {
const next = pkg.workspaces.filter((entry) => {
if (typeof entry !== "string") return false;
const prefix = entry.split("*")[0];
return prefix.length === 0 || fs.existsSync(path.join(repoRoot, prefix));
});
if (next.length !== pkg.workspaces.length) {
pkg.workspaces = next;
mutated = true;
}
}
if (Array.isArray(pkg.bundleDependencies)) {
pkg.bundleDependencies = pkg.bundleDependencies.filter(
d => availableWorkspaceNames.has(d)
);
mutated = true;
}
if (mutated) {
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
}
}
for (const pkgPath of packageJsonPaths) {
stripWorkspaceDeps(pkgPath);
}
'
# The cloud-agent template is a release helper workspace, not a
# snap runtime dependency. Keeping it in the workspace graph
# pulls the published @elizaos/plugin-sql package, whose current
# older prerelease manifest self-pins an unavailable build.
node -e '
const fs = require("fs");
const pkgPath = "package.json";
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (Array.isArray(pkg.workspaces)) {
const next = pkg.workspaces.filter(
(entry) => entry !== "packages/app-core/deploy/cloud-agent-template"
);
if (next.length !== pkg.workspaces.length) {
pkg.workspaces = next;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
}
}
'
# The committed lockfile still references repo-local workspace
# paths that no longer exist after the destructive rewrite
# above. Force Bun to resolve against the patched manifests.
rm -f bun.lock
# Install dependencies — skip postinstall to avoid ensure-bundled-workspaces
# trying to build the (now-removed) plugin-agent-orchestrator from source.
bun install --ignore-scripts
# app-core still carries compatibility re-exports for wallet route
# helpers that live in plugin-wallet. The snap workspace rewrite keeps
# plugin-wallet on disk, but Bun may not link workspace packages unless
# a surviving manifest declares them. Link the local workspace explicitly
# so app-core's declaration build can resolve those compatibility imports.
mkdir -p node_modules/@elizaos "$APP_CORE_DIR/node_modules/@elizaos"
node "$RM_PATH_RECURSIVE" \
node_modules/@elizaos/plugin-wallet \
"$APP_CORE_DIR/node_modules/@elizaos/plugin-wallet"
ln -s ../../plugins/plugin-wallet node_modules/@elizaos/plugin-wallet
ln -s ../../../../plugins/plugin-wallet "$APP_CORE_DIR/node_modules/@elizaos/plugin-wallet"
# `bun install --ignore-scripts` skips the npm bun package postinstall,
# leaving node_modules/bun/bin/bun as a non-executable JS shim. Turbo
# package builds can then fail with "Exec format error" when they spawn
# that package binary instead of the staged Bun runtime.
install_js=""
if [ -f node_modules/bun/install.js ]; then
install_js="node_modules/bun/install.js"
elif [ -d node_modules/.bun ]; then
install_js="$(find node_modules/.bun -maxdepth 4 -path '*/bun@*/node_modules/bun/install.js' -print -quit 2>/dev/null)"
fi
if [ -n "$install_js" ] && [ -f "$install_js" ]; then
echo "Running bun postinstall: $install_js"
(cd "$(dirname "$install_js")" && node install.js)
else
echo "No bun/install.js found; continuing with staged Bun runtime"
fi
# Rehydrate published-only workspace deps that Vite and app-core imports
# still need after the destructive workspace rewrite above.
if [ -f scripts/install-published-workspace-fallback-deps.sh ]; then
bash scripts/install-published-workspace-fallback-deps.sh
fi
# Inject tailwindcss into packages/app-core/node_modules so the
# Vite CSS pipeline can resolve @import "tailwindcss" from the app-core
# source tree during the snap production build.
mkdir -p "$APP_CORE_DIR/node_modules"
if [ ! -d "$APP_CORE_DIR/node_modules/tailwindcss" ]; then
TW_EXTRACT_DIR="/tmp/tw_extract"
TAILWIND_URL=$(npm view tailwindcss dist.tarball 2>/dev/null || true)
if [ -z "$TAILWIND_URL" ]; then
echo "Failed to resolve tailwindcss tarball URL" >&2
exit 1
fi
node "$RM_PATH_RECURSIVE" "$TW_EXTRACT_DIR"
mkdir -p "$TW_EXTRACT_DIR"
curl -fsSL "$TAILWIND_URL" | tar xz -C "$TW_EXTRACT_DIR"
node "$RM_PATH_RECURSIVE" "$APP_CORE_DIR/node_modules/tailwindcss"
mv "$TW_EXTRACT_DIR/package" "$APP_CORE_DIR/node_modules/tailwindcss"
fi
# Fresh snap builds also need generated i18n keyword data; the
# production build imports the generated TypeScript module directly.
node "$APP_CORE_SCRIPTS_DIR/ensure-shared-i18n-data.mjs"
# patch-deps removes @types packages from node_modules and Bun's cache.
# Preserve the packages needed by declaration builds outside node_modules
# before patch-deps runs.
SNAP_BUILD_TYPES_DIR=".snap-build-types"
SNAP_BUILD_TYPE_PACKAGES="
node bun fs-extra fast-redact markdown-it jsonfile linkify-it mdurl
react react-dom jsdom pg qrcode three ws
stats.js tough-cookie webxr
"
node "$RM_PATH_RECURSIVE" "$SNAP_BUILD_TYPES_DIR"
mkdir -p "$SNAP_BUILD_TYPES_DIR/@types"
for type_pkg in $(printf '%s\n' "$SNAP_BUILD_TYPE_PACKAGES"); do
type_target=""
if [ -d "node_modules/@types/$type_pkg" ]; then
type_target="node_modules/@types/$type_pkg"
elif [ -d "node_modules/.bun/node_modules/@types/$type_pkg" ]; then
type_target="node_modules/.bun/node_modules/@types/$type_pkg"
elif [ -d node_modules/.bun ]; then
type_target="$(find node_modules/.bun -maxdepth 5 -path "*/@types+$type_pkg@*/node_modules/@types/$type_pkg" -type d -print -quit 2>/dev/null)"
fi
if [ -z "$type_target" ] || [ ! -d "$type_target" ]; then
echo "Failed to locate @types/$type_pkg before patch-deps" >&2
exit 1
fi
cp -aL "$type_target" "$SNAP_BUILD_TYPES_DIR/@types/$type_pkg"
done
# Run only the safe postinstall steps (patch deps, link browser server)
node "$APP_CORE_SCRIPTS_DIR/patch-deps.mjs" || true
node "$APP_CORE_SCRIPTS_DIR/link-browser-server.mjs" || true
# patch-deps intentionally removes @types from the runtime install so Bun
# never tries to execute .d.ts files. Rehydrate just the type packages tsc
# needs for the build, then remove them again before copying node_modules
# into the snap runtime payload.
mkdir -p node_modules/@types
for type_pkg in $(printf '%s\n' "$SNAP_BUILD_TYPE_PACKAGES"); do
type_target="$SNAP_BUILD_TYPES_DIR/@types/$type_pkg"
if [ ! -d "$type_target" ]; then
echo "Failed to restore @types/$type_pkg after patch-deps" >&2
exit 1
fi
node "$RM_PATH_RECURSIVE" "node_modules/@types/$type_pkg"
cp -aL "$type_target" "node_modules/@types/$type_pkg"
done
mkdir -p "$APP_CORE_DIR/node_modules/@types"
for type_pkg in react react-dom three ws; do
node "$RM_PATH_RECURSIVE" "$APP_CORE_DIR/node_modules/@types/$type_pkg"
cp -aL "$SNAP_BUILD_TYPES_DIR/@types/$type_pkg" "$APP_CORE_DIR/node_modules/@types/$type_pkg"
done
# The snap rewrite removes non-runtime plugin/native workspaces from disk. Turbo
# validates explicit package-task dependencies before filtering, so drop
# dependency edges that point at packages no longer present in the
# rewritten workspace graph.
node -e '
const fs = require("fs");
const path = require("path");
const repoRoot = process.cwd();
const availableWorkspaceNames = new Set();
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (entry.name === "node_modules" || entry.name.startsWith(".git")) {
continue;
}
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(entryPath);
continue;
}
if (entry.isFile() && entry.name === "package.json") {
try {
const pkg = JSON.parse(fs.readFileSync(entryPath, "utf8"));
if (typeof pkg.name === "string" && pkg.name.length > 0) {
availableWorkspaceNames.add(pkg.name);
}
} catch {}
}
}
}
walk(repoRoot);
const turboPath = path.join(repoRoot, "turbo.json");
const turbo = JSON.parse(fs.readFileSync(turboPath, "utf8"));
let mutated = false;
for (const task of Object.values(turbo.tasks ?? {})) {
if (!Array.isArray(task.dependsOn)) continue;
const next = task.dependsOn.filter((dependency) => {
if (typeof dependency !== "string" || dependency.startsWith("^")) {
return true;
}
const hashIndex = dependency.indexOf("#");
if (hashIndex <= 0) return true;
return availableWorkspaceNames.has(dependency.slice(0, hashIndex));
});
if (next.length !== task.dependsOn.length) {
task.dependsOn = next;
mutated = true;
}
}
if (mutated) {
fs.writeFileSync(turboPath, JSON.stringify(turbo, null, 2) + "\n");
}
'
# Build only the runtime workspace closure needed by the snap. The root
# `bun run build` also builds examples and release helpers, which are not
# part of the snap payload and depend on workspaces intentionally removed
# above.
# Build this first because @elizaos/core's declaration pass imports the
# package via its generated dist types, while Turbo does not currently
# schedule @elizaos/cloud-routing before @elizaos/core in this filtered
# Snap closure.
bun run --cwd packages/cloud/routing build
bunx turbo run build \
--filter=@elizaos/cloud-routing \
--filter=@elizaos/core \
--filter=@elizaos/shared \
--filter=@elizaos/ui \
--filter=@elizaos/vault \
--filter=@elizaos/skills \
--filter=@elizaos/app-core \
--filter=@elizaos/agent \
--filter=@elizaos/plugin-agent-skills \
--filter=@elizaos/plugin-browser \
--filter=@elizaos/plugin-commands \
--filter=@elizaos/plugin-imessage \
--filter=@elizaos/plugin-personal-assistant \
--filter=@elizaos/plugin-local-inference \
--filter=@elizaos/plugin-ollama \
--filter=@elizaos/plugin-pdf \
--filter=@elizaos/plugin-signal \
--filter=@elizaos/plugin-sql \
--filter=@elizaos/plugin-streaming \
--filter=@elizaos/plugin-task-coordinator \
--filter=@elizaos/plugin-telegram \
--filter=@elizaos/plugin-training \
--filter=@elizaos/plugin-video \
--filter=@elizaos/plugin-wallet \
--filter=@elizaos/plugin-wallet-ui \
--filter=@elizaos/plugin-wechat \
--filter=@elizaos/plugin-whatsapp \
--filter=@elizaos/scenario-runner
node "$RM_PATH_RECURSIVE" node_modules/@types
node "$RM_PATH_RECURSIVE" "$SNAP_BUILD_TYPES_DIR"
# Install into snap
mkdir -p "$CRAFT_PART_INSTALL/lib/elizaos-app"
mkdir -p "$CRAFT_PART_INSTALL/lib/elizaos-app/packages/agent"
# Copy built artifacts
cp -r dist/ "$CRAFT_PART_INSTALL/lib/elizaos-app/" 2>/dev/null || true
cp elizaos-app.mjs "$CRAFT_PART_INSTALL/lib/elizaos-app/" 2>/dev/null || true
cp -r packages/agent/dist "$CRAFT_PART_INSTALL/lib/elizaos-app/packages/agent/"
cp packages/agent/package.json "$CRAFT_PART_INSTALL/lib/elizaos-app/packages/agent/"
cp package.json "$CRAFT_PART_INSTALL/lib/elizaos-app/"
cp plugins.json "$CRAFT_PART_INSTALL/lib/elizaos-app/" 2>/dev/null || true
# Copy node_modules for runtime
cp -r node_modules "$CRAFT_PART_INSTALL/lib/elizaos-app/" 2>/dev/null || true
# Copy additional runtime files
for dir in src scripts skills patches; do
if [ -d "$dir" ]; then
cp -r "$dir" "$CRAFT_PART_INSTALL/lib/elizaos-app/" 2>/dev/null || true
fi
done
# Create wrapper script
mkdir -p "$CRAFT_PART_INSTALL/bin"
cat > "$CRAFT_PART_INSTALL/bin/elizaos-app-wrapper" << 'WRAPPER'
#!/bin/sh
set -e
# Ensure elizaOS App data directory exists
ELIZAOS_APP_HOME="${ELIZAOS_APP_DATA_DIR:-$SNAP_USER_COMMON/.elizaos-app}"
mkdir -p "$ELIZAOS_APP_HOME"
if [ -f "$SNAP/lib/elizaos-app/elizaos-app.mjs" ]; then
exec "$SNAP/node/bin/node" "$SNAP/lib/elizaos-app/elizaos-app.mjs" "$@"
fi
exec "$SNAP/node/bin/node" \
--import "$SNAP/lib/elizaos-app/node_modules/tsx/dist/loader.mjs" \
"$SNAP/lib/elizaos-app/packages/agent/dist/bin.js" \
"$@"
WRAPPER
chmod +x "$CRAFT_PART_INSTALL/bin/elizaos-app-wrapper"
build-packages:
- curl
- git
- build-essential
stage-packages:
- libatomic1
- ca-certificates
+309
View File
@@ -0,0 +1,309 @@
#!/usr/bin/env bash
# ╔════════════════════════════════════════════════════════════════════════════╗
# ║ Packaging validation script — tests all packaging configs locally ║
# ║ Run: bash packaging/test-packaging.sh ║
# ╚════════════════════════════════════════════════════════════════════════════╝
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
find_repo_root() {
local dir="$1"
while [[ "$dir" != "/" ]]; do
if [[ -f "$dir/.github/workflows/publish-packages.yml" ]]; then
printf '%s\n' "$dir"
return 0
fi
dir="$(dirname "$dir")"
done
return 1
}
REPO_ROOT="$(
find_repo_root "$SCRIPT_DIR" ||
git -C "$SCRIPT_DIR" rev-parse --show-toplevel 2>/dev/null ||
(
cd "$SCRIPT_DIR/../../../.." &&
pwd
)
)"
PASS=0
FAIL=0
SKIP=0
green() { printf '\033[0;32m%s\033[0m\n' "$*"; }
red() { printf '\033[0;31m%s\033[0m\n' "$*"; }
yellow(){ printf '\033[0;33m%s\033[0m\n' "$*"; }
bold() { printf '\033[1m%s\033[0m\n' "$*"; }
check() {
local name="$1"
shift
if "$@" >/dev/null 2>&1; then
green "$name"
PASS=$((PASS + 1))
else
red "$name"
FAIL=$((FAIL + 1))
fi
}
check_file() {
local name="$1" path="$2"
if [[ -f "$path" ]]; then
green "$name exists"
PASS=$((PASS + 1))
else
red "$name missing: $path"
FAIL=$((FAIL + 1))
fi
}
skip() {
local name="$1" reason="$2"
yellow "$name (skipped: $reason)"
SKIP=$((SKIP + 1))
}
python_has_module() {
local module="$1"
if ! command -v python3 &>/dev/null; then
return 1
fi
python3 -c "import ${module}" >/dev/null 2>&1
}
# ── Header ───────────────────────────────────────────────────────────────────
echo ""
bold "╔══════════════════════════════════════╗"
bold "║ Packaging Validation Suite ║"
bold "╚══════════════════════════════════════╝"
echo ""
# ── 1. PyPI Package ──────────────────────────────────────────────────────────
bold "1. PyPI Package (elizaos-app)"
if [[ ! -d "$SCRIPT_DIR/pypi" ]]; then
skip "PyPI Package" "packaging/pypi/ directory not present in this branch"
else
check_file "pyproject.toml" "$SCRIPT_DIR/pypi/pyproject.toml"
check_file "elizaos_app/__init__.py" "$SCRIPT_DIR/pypi/elizaos_app/__init__.py"
check_file "elizaos_app/__main__.py" "$SCRIPT_DIR/pypi/elizaos_app/__main__.py"
check_file "elizaos_app/cli.py" "$SCRIPT_DIR/pypi/elizaos_app/cli.py"
check_file "elizaos_app/loader.py" "$SCRIPT_DIR/pypi/elizaos_app/loader.py"
check_file "elizaos_app/py.typed" "$SCRIPT_DIR/pypi/elizaos_app/py.typed"
check_file "README.md" "$SCRIPT_DIR/pypi/README.md"
# Validate Python syntax
if command -v python3 &>/dev/null; then
check "Python syntax valid" python3 -c "
import ast, sys, pathlib
for f in pathlib.Path('$SCRIPT_DIR/pypi/elizaos_app').glob('*.py'):
ast.parse(f.read_text())
"
# Validate pyproject.toml is parseable
check "pyproject.toml parseable" python3 -c "
import tomllib, pathlib
tomllib.loads(pathlib.Path('$SCRIPT_DIR/pypi/pyproject.toml').read_text())
"
# Build test
if python3 -c "import build" 2>/dev/null; then
check "Package builds" bash -c "cd '$SCRIPT_DIR/pypi' && python3 -m build --no-isolation 2>/dev/null || python3 -m build"
else
skip "Package build" "python-build not installed"
fi
# Import test (in subprocess to avoid polluting this env)
check "elizaos_app module importable" python3 -c "
import sys; sys.path.insert(0, '$SCRIPT_DIR/pypi')
import elizaos_app
assert elizaos_app.__version__, 'No version'
assert hasattr(elizaos_app, 'run'), 'Missing run'
assert hasattr(elizaos_app, 'ensure_runtime'), 'Missing ensure_runtime'
assert hasattr(elizaos_app, 'get_version'), 'Missing get_version'
"
# Loader unit tests
check "Version parser" python3 -c "
import sys; sys.path.insert(0, '$SCRIPT_DIR/pypi')
from elizaos_app.loader import _parse_version
assert _parse_version('v22.12.0') == (22, 12, 0)
assert _parse_version('v18.0.0') == (18, 0, 0)
assert _parse_version('v1.2.3-nightly') == (1, 2, 3)
assert _parse_version('not-a-version') is None
"
check "Node detection" python3 -c "
import sys; sys.path.insert(0, '$SCRIPT_DIR/pypi')
from elizaos_app.loader import _find_node, _get_node_version
node = _find_node()
assert node, 'Node not found'
ver = _get_node_version(node)
assert ver and ver >= (22, 0, 0), f'Bad node version: {ver}'
"
else
skip "Python tests" "python3 not available"
fi
fi # close: if [[ -d "$SCRIPT_DIR/pypi" ]]
echo ""
# ── 2. Homebrew Formula & Cask ─────────────────────────────────────────────────
bold "2. Homebrew Formula & Cask"
check_file "elizaos-app.rb" "$SCRIPT_DIR/homebrew/elizaos-app.rb"
check_file "elizaos-app.cask.rb" "$SCRIPT_DIR/homebrew/elizaos-app.cask.rb"
# Validate Ruby syntax
if command -v ruby &>/dev/null; then
check "Formula Ruby syntax valid" ruby -c "$SCRIPT_DIR/homebrew/elizaos-app.rb"
check "Cask Ruby syntax valid" ruby -c "$SCRIPT_DIR/homebrew/elizaos-app.cask.rb"
else
skip "Ruby syntax check" "ruby not available"
fi
# Check formula has real SHA256 (not placeholder)
check "Formula SHA256 is not placeholder" bash -c "! grep -q PLACEHOLDER '$SCRIPT_DIR/homebrew/elizaos-app.rb'"
check "Has url field" grep -q 'url "https://' "$SCRIPT_DIR/homebrew/elizaos-app.rb"
check "Has sha256 field" grep -q 'sha256 "' "$SCRIPT_DIR/homebrew/elizaos-app.rb"
check "Depends on node" grep -q 'depends_on "node' "$SCRIPT_DIR/homebrew/elizaos-app.rb"
check "Has test block" grep -q 'test do' "$SCRIPT_DIR/homebrew/elizaos-app.rb"
echo ""
# ── 3. Debian Packaging ─────────────────────────────────────────────────────
bold "3. Debian/apt Packaging"
check_file "debian/control" "$SCRIPT_DIR/debian/control"
check_file "debian/rules" "$SCRIPT_DIR/debian/rules"
check_file "debian/changelog" "$SCRIPT_DIR/debian/changelog"
check_file "debian/copyright" "$SCRIPT_DIR/debian/copyright"
check_file "debian/install" "$SCRIPT_DIR/debian/install"
check_file "debian/postinst" "$SCRIPT_DIR/debian/postinst"
check_file "debian/source/format" "$SCRIPT_DIR/debian/source/format"
check "rules is executable" test -x "$SCRIPT_DIR/debian/rules"
check "postinst is executable" test -x "$SCRIPT_DIR/debian/postinst"
check "Control has Package field" grep -q "^Package: elizaos-app" "$SCRIPT_DIR/debian/control"
check "Control has Depends" grep -q "Depends:" "$SCRIPT_DIR/debian/control"
check "Changelog has version" grep -q "elizaos-app (" "$SCRIPT_DIR/debian/changelog"
check "Compat level 13" grep -q "debhelper-compat (= 13)" "$SCRIPT_DIR/debian/control"
check "Source format 3.0 quilt" grep -q "3.0 (quilt)" "$SCRIPT_DIR/debian/source/format"
echo ""
# ── 4. Snap Package ─────────────────────────────────────────────────────────
bold "4. Snap Package"
check_file "snapcraft.yaml" "$SCRIPT_DIR/snap/snapcraft.yaml"
# Validate YAML syntax
if python_has_module yaml; then
check "YAML syntax valid" python3 -c "
import yaml, pathlib
yaml.safe_load(pathlib.Path('$SCRIPT_DIR/snap/snapcraft.yaml').read_text())
"
elif command -v python3 &>/dev/null; then
skip "YAML syntax valid" "pyyaml not installed"
fi
check "Has name field" grep -q "^name: elizaos-app" "$SCRIPT_DIR/snap/snapcraft.yaml"
check "Has version field" grep -q "^version:" "$SCRIPT_DIR/snap/snapcraft.yaml"
check "Has confinement set" grep -q "^confinement:" "$SCRIPT_DIR/snap/snapcraft.yaml"
check "Has base" grep -q "^base: core22" "$SCRIPT_DIR/snap/snapcraft.yaml"
check "Has apps section" grep -q "^apps:" "$SCRIPT_DIR/snap/snapcraft.yaml"
check "Has node part" grep -q "^ node:" "$SCRIPT_DIR/snap/snapcraft.yaml"
check "Has elizaos-app part" grep -q "^ elizaos-app:" "$SCRIPT_DIR/snap/snapcraft.yaml"
echo ""
# ── 5. Flatpak Package ──────────────────────────────────────────────────────
bold "5. Flatpak Package"
check_file "Flatpak manifest (direct)" "$SCRIPT_DIR/flatpak/ai.elizaos.App.yml"
check_file "Flatpak manifest (store)" "$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml"
check_file "Flatpak README" "$SCRIPT_DIR/flatpak/README.md"
check_file "Desktop entry" "$SCRIPT_DIR/flatpak/ai.elizaos.App.desktop"
check_file "Metainfo XML" "$SCRIPT_DIR/flatpak/ai.elizaos.App.metainfo.xml"
check_file "Direct wrapper" "$SCRIPT_DIR/flatpak/elizaos-app-wrapper.sh"
check_file "Store wrapper" "$SCRIPT_DIR/flatpak/elizaos-app-wrapper.store.sh"
# Validate YAML
if python_has_module yaml; then
check "Direct manifest YAML valid" python3 -c "
import yaml, pathlib
yaml.safe_load(pathlib.Path('$SCRIPT_DIR/flatpak/ai.elizaos.App.yml').read_text())
"
check "Store manifest YAML valid" python3 -c "
import yaml, pathlib
yaml.safe_load(pathlib.Path('$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml').read_text())
"
elif command -v python3 &>/dev/null; then
skip "Manifest YAML valid" "pyyaml not installed"
fi
check "SHA256 not placeholder (x64)" bash -c "! grep -q PLACEHOLDER_SHA256_X64 '$SCRIPT_DIR/flatpak/ai.elizaos.App.yml'"
check "SHA256 not placeholder (arm64)" bash -c "! grep -q PLACEHOLDER_SHA256_ARM64 '$SCRIPT_DIR/flatpak/ai.elizaos.App.yml'"
check "Has app-id" grep -q "^app-id: ai.elizaos.App" "$SCRIPT_DIR/flatpak/ai.elizaos.App.yml"
check "Has runtime" grep -q "runtime: org.freedesktop" "$SCRIPT_DIR/flatpak/ai.elizaos.App.yml"
check "Desktop entry has Exec" grep -q "^Exec=" "$SCRIPT_DIR/flatpak/ai.elizaos.App.desktop"
check "Metainfo has app-id" grep -q "ai.elizaos.App" "$SCRIPT_DIR/flatpak/ai.elizaos.App.metainfo.xml"
# Store manifest sandbox lockdown — these grants would defeat the
# Flathub posture, so the manifest must NOT contain them.
check "Store: no --filesystem=home" bash -c "! grep -E -q -- '^[[:space:]]*-[[:space:]]+--filesystem=home' '$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml'"
check "Store: no --filesystem=host" bash -c "! grep -E -q -- '^[[:space:]]*-[[:space:]]+--filesystem=host' '$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml'"
check "Store: no host-spawn portal" bash -c "! grep -E -q -- '^[[:space:]]*-[[:space:]]+--talk-name=org\.freedesktop\.Flatpak' '$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml'"
check "Store: no --device=all" bash -c "! grep -E -q -- '^[[:space:]]*-[[:space:]]+--device=all' '$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml'"
check "Store: no session/system bus socket" bash -c "! grep -E -q -- '^[[:space:]]*-[[:space:]]+--socket=(session|system)-bus' '$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml'"
check "Store: has --share=network" grep -E -q -- "--share=network" "$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml"
check "Store: has wayland socket" grep -E -q -- "--socket=wayland" "$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml"
check "Store: has fallback-x11 socket" grep -E -q -- "--socket=fallback-x11" "$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml"
check "Store: persists state dir" grep -E -q -- "--persist=\.eliza" "$SCRIPT_DIR/flatpak/ai.elizaos.App.store.yml"
check "Store wrapper sets ELIZA_BUILD_VARIANT=store" grep -q 'ELIZA_BUILD_VARIANT=store' "$SCRIPT_DIR/flatpak/elizaos-app-wrapper.store.sh"
echo ""
# ── 6. CI/CD Workflow ────────────────────────────────────────────────────────
bold "6. CI/CD Workflow"
WORKFLOW="$REPO_ROOT/.github/workflows/publish-packages.yml"
check_file "publish-packages.yml" "$WORKFLOW"
if python_has_module yaml; then
check "Workflow YAML valid" python3 -c "
import yaml, pathlib
yaml.safe_load(pathlib.Path('$WORKFLOW').read_text())
"
elif command -v python3 &>/dev/null; then
skip "Workflow YAML valid" "pyyaml not installed"
fi
check "Has release trigger" grep -q "release:" "$WORKFLOW"
check "Has workflow_dispatch" grep -q "workflow_dispatch:" "$WORKFLOW"
check "Has PyPI job" grep -q "publish-pypi:" "$WORKFLOW"
# Homebrew is handled by the standalone update-homebrew.yml workflow
check "Has Homebrew job" test -f "$REPO_ROOT/.github/workflows/update-homebrew.yml"
check "Has Snap job" grep -q "publish-snap:" "$WORKFLOW"
check "Has Debian job" grep -q "build-deb:" "$WORKFLOW"
check "Has Flatpak job" grep -q "build-flatpak:" "$WORKFLOW"
check "Has summary job" grep -q "publish-summary:" "$WORKFLOW"
check "Uses trusted publishing" grep -q "id-token: write" "$WORKFLOW"
echo ""
# ── 7. Publishing Guide ─────────────────────────────────────────────────────
bold "7. Publishing Guide"
check_file "PUBLISHING_GUIDE.md" "$SCRIPT_DIR/PUBLISHING_GUIDE.md"
check "Covers PyPI" grep -q "PyPI" "$SCRIPT_DIR/PUBLISHING_GUIDE.md"
check "Covers Homebrew" grep -q "Homebrew" "$SCRIPT_DIR/PUBLISHING_GUIDE.md"
check "Covers apt" grep -q "apt" "$SCRIPT_DIR/PUBLISHING_GUIDE.md"
check "Covers Snap" grep -q "Snap" "$SCRIPT_DIR/PUBLISHING_GUIDE.md"
check "Covers Flatpak" grep -q "Flatpak" "$SCRIPT_DIR/PUBLISHING_GUIDE.md"
check "Has version checklist" grep -q "Version Bumping" "$SCRIPT_DIR/PUBLISHING_GUIDE.md"
echo ""
# ── Summary ──────────────────────────────────────────────────────────────────
bold "════════════════════════════════════════"
bold " Results: $(green "$PASS passed"), $(red "$FAIL failed"), $(yellow "$SKIP skipped")"
bold "════════════════════════════════════════"
if [[ $FAIL -gt 0 ]]; then
exit 1
fi
@@ -0,0 +1,33 @@
diff --git a/package.json b/package.json
index 5b706051d126d61fd5c8a8d8deaa138bb13fbd81..b1d86fe6e84a20234ed4b332fe4c95ce23133abe 100644
--- a/package.json
+++ b/package.json
@@ -54,7 +54,27 @@
"./nist.js": "./nist.js",
"./secp256k1.js": "./secp256k1.js",
"./utils.js": "./utils.js",
- "./webcrypto.js": "./webcrypto.js"
+ "./webcrypto.js": "./webcrypto.js",
+ "./abstract/bls": "./abstract/bls.js",
+ "./abstract/curve": "./abstract/curve.js",
+ "./abstract/edwards": "./abstract/edwards.js",
+ "./abstract/fft": "./abstract/fft.js",
+ "./abstract/hash-to-curve": "./abstract/hash-to-curve.js",
+ "./abstract/modular": "./abstract/modular.js",
+ "./abstract/montgomery": "./abstract/montgomery.js",
+ "./abstract/oprf": "./abstract/oprf.js",
+ "./abstract/poseidon": "./abstract/poseidon.js",
+ "./abstract/tower": "./abstract/tower.js",
+ "./abstract/weierstrass": "./abstract/weierstrass.js",
+ "./bls12-381": "./bls12-381.js",
+ "./bn254": "./bn254.js",
+ "./ed448": "./ed448.js",
+ "./ed25519": "./ed25519.js",
+ "./misc": "./misc.js",
+ "./nist": "./nist.js",
+ "./secp256k1": "./secp256k1.js",
+ "./utils": "./utils.js",
+ "./webcrypto": "./webcrypto.js"
},
"engines": {
"node": ">= 20.19.0"
@@ -0,0 +1,26 @@
diff --git a/lib/nodes/index.cjs b/lib/nodes/index.cjs
index f1fcfe33e37f0139700b51990e1b9c787203456a..74257dea925d65c043b944d535164fabbd4720c7 100644
--- a/lib/nodes/index.cjs
+++ b/lib/nodes/index.cjs
@@ -125,7 +125,7 @@ var FnCompat = (jsFunc) => {
if (threeRevision2 >= 168) {
return THREE_TSL.Fn(jsFunc);
} else {
- return THREE_WEBGPU.tslFn(jsFunc);
+ return THREE_TSL.Fn(jsFunc);
}
};
var linearstep = FnCompat(
diff --git a/lib/nodes/index.module.js b/lib/nodes/index.module.js
index e50f617f3b5555d8049f1578b356717f72bccc5c..bf731ccdc57f219270c4cf80b41cb09b57a2a4c8 100644
--- a/lib/nodes/index.module.js
+++ b/lib/nodes/index.module.js
@@ -103,7 +103,7 @@ var FnCompat = (jsFunc) => {
if (threeRevision2 >= 168) {
return THREE_TSL.Fn(jsFunc);
} else {
- return THREE_WEBGPU.tslFn(jsFunc);
+ return THREE_TSL.Fn(jsFunc);
}
};
var linearstep = FnCompat(
@@ -0,0 +1,44 @@
diff --git a/dist/index.cjs b/dist/index.cjs
index 091bfe5a59bfe6c366a0f7b0f9f8934b7abccf40..2dacd9dda83a4d8ad8bde205092a760f96ade27b 100644
--- a/dist/index.cjs
+++ b/dist/index.cjs
@@ -1987,7 +1987,7 @@ var CodexAdapter = class extends BaseCodingAdapter {
safe: true
},
{
- pattern: /do.?you.?trust.?the.?contents|trust.?this.?directory|yes,?.?continue|prompt.?injection/i,
+ pattern: /do.?you.?trust.?the.?contents|trust.?this.?directory|allow.?codex.?to.?work.?in.?this.?folder|without.?asking.?for.?approval|yes,?.?continue|prompt.?injection/i,
type: "permission",
response: "",
responseType: "keys",
@@ -2222,7 +2222,7 @@ ${credentials.extraConfigToml}`;
*/
detectBlockingPrompt(output) {
const stripped = this.stripAnsi(output);
- if (/would.?you.?like.?to.?run.?the.?following.?command/i.test(stripped) || /do.?you.?want.?to.?approve.?access/i.test(stripped) || /would.?you.?like.?to.?make.?the.?following.?edits/i.test(stripped) || /press.?enter.?to.?confirm/i.test(stripped) && /esc.?to.?cancel/i.test(stripped)) {
+ if (/would.?you.?like.?to.?run.?the.?following.?command/i.test(stripped) || /do.?you.?want.?to.?approve.?access/i.test(stripped) || /would.?you.?like.?to.?make.?the.?following.?edits/i.test(stripped) || /allow.?codex.?to.?work.?in.?this.?folder/i.test(stripped) || /without.?asking.?for.?approval/i.test(stripped) || /press.?enter.?to.?confirm/i.test(stripped) && /esc.?to.?cancel/i.test(stripped)) {
return {
detected: true,
type: "permission",
diff --git a/dist/index.js b/dist/index.js
index f17f3f346a2b0368c239a2367ac4494c2219bf8d..510be83f6624f63bed1d196dd7e7ab193b6d48c7 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1981,7 +1981,7 @@ var CodexAdapter = class extends BaseCodingAdapter {
safe: true
},
{
- pattern: /do.?you.?trust.?the.?contents|trust.?this.?directory|yes,?.?continue|prompt.?injection/i,
+ pattern: /do.?you.?trust.?the.?contents|trust.?this.?directory|allow.?codex.?to.?work.?in.?this.?folder|without.?asking.?for.?approval|yes,?.?continue|prompt.?injection/i,
type: "permission",
response: "",
responseType: "keys",
@@ -2216,7 +2216,7 @@ ${credentials.extraConfigToml}`;
*/
detectBlockingPrompt(output) {
const stripped = this.stripAnsi(output);
- if (/would.?you.?like.?to.?run.?the.?following.?command/i.test(stripped) || /do.?you.?want.?to.?approve.?access/i.test(stripped) || /would.?you.?like.?to.?make.?the.?following.?edits/i.test(stripped) || /press.?enter.?to.?confirm/i.test(stripped) && /esc.?to.?cancel/i.test(stripped)) {
+ if (/would.?you.?like.?to.?run.?the.?following.?command/i.test(stripped) || /do.?you.?want.?to.?approve.?access/i.test(stripped) || /would.?you.?like.?to.?make.?the.?following.?edits/i.test(stripped) || /allow.?codex.?to.?work.?in.?this.?folder/i.test(stripped) || /without.?asking.?for.?approval/i.test(stripped) || /press.?enter.?to.?confirm/i.test(stripped) && /esc.?to.?cancel/i.test(stripped)) {
return {
detected: true,
type: "permission",
@@ -0,0 +1,13 @@
diff --git a/bin/electrobun.cjs b/bin/electrobun.cjs
index 09e3088948af39eeb338aa492ef50abb1a94d445..575ef1548d8b4c97b83b54f219cfe4fb4d457872 100755
--- a/bin/electrobun.cjs
+++ b/bin/electrobun.cjs
@@ -98,7 +98,7 @@ async function ensureCliBinary() {
await downloadFile(tarballUrl, tarballPath);
// Extract using system tar (available on macOS, Linux, and Windows 10+)
- execSync(`tar -xzf "${tarballPath}"`, { cwd: cacheDir, stdio: 'pipe' });
+ execSync(`tar -xzf electrobun-${platform}-${arch}.tar.gz`, { cwd: cacheDir, stdio: 'pipe' });
// Clean up tarball
unlinkSync(tarballPath);
@@ -0,0 +1,14 @@
diff --git a/lib/lockfile.js b/lib/lockfile.js
index 97b66373d5c5057de38884bb870ee68134e5a7ee..bcc1879ee1c2ddcd75f8fd768664ad6c06807cb9 100644
--- a/lib/lockfile.js
+++ b/lib/lockfile.js
@@ -3,7 +3,8 @@
const path = require('path');
const fs = require('graceful-fs');
const retry = require('retry');
-const onExit = require('signal-exit');
+const signalExit = require('signal-exit');
+const onExit = typeof signalExit === 'function' ? signalExit : signalExit.onExit;
const mtimePrecision = require('./mtime-precision');
const locks = {};
@@ -0,0 +1,194 @@
diff --git a/dist/index.js b/dist/index.js
index 3b4521d74c1dbf3e4a98926a5447b345cf52fb82..f79477f4aac60903db1c42becbc62c713f47500c 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -1734,14 +1734,17 @@ var PTYSession = class _PTYSession extends import_node_events2.EventEmitter {
if (!this.ptyProcess) return;
this.ptyProcess.onData((data) => {
this._lastActivityAt = /* @__PURE__ */ new Date();
- this.outputBuffer += data;
+ const sanitizedData = this.respondToCursorPositionRequests(data);
+ this.outputBuffer += sanitizedData;
if (this.outputBuffer.length > _PTYSession.MAX_OUTPUT_BUFFER) {
this.outputBuffer = this.outputBuffer.slice(
-_PTYSession.MAX_OUTPUT_BUFFER
);
}
- this.emit("output", data);
- if (!this._processScheduled) {
+ if (sanitizedData.length > 0) {
+ this.emit("output", sanitizedData);
+ }
+ if (sanitizedData.length > 0 && !this._processScheduled) {
this._processScheduled = true;
setImmediate(() => {
this._processScheduled = false;
@@ -1759,9 +1762,29 @@ var PTYSession = class _PTYSession extends import_node_events2.EventEmitter {
this.emit("exit", exitCode);
});
}
+ respondToCursorPositionRequests(data) {
+ if (!this.ptyProcess || !data.includes("\x1B[6n")) {
+ return data;
+ }
+ let requestCount = 0;
+ const sanitizedData = data.replace(/\x1B\[6n/g, () => {
+ requestCount += 1;
+ return "";
+ });
+ for (let i = 0; i < requestCount; i += 1) {
+ this.ptyProcess.write("\x1B[1;1R");
+ }
+ if (requestCount > 0) {
+ this.logger.debug(
+ { sessionId: this.id, requestCount },
+ "Responded to cursor position request",
+ );
+ }
+ return sanitizedData;
+ }
+
/**
- * Process the accumulated output buffer.
- * Called via setImmediate() from the onData handler so that heavy regex
+ * Process the accumulated output buffer. * Called via setImmediate() from the onData handler so that heavy regex
* work runs in its own event-loop tick, not inside node-pty's native callback.
*/
processOutputBuffer() {
diff --git a/dist/index.mjs b/dist/index.mjs
index 5a51b3d81fb090b08754b7be95808ed4da4156b2..3a5f9ac93618e482ef3f895f94e0dee56d26a2ac 100644
--- a/dist/index.mjs
+++ b/dist/index.mjs
@@ -1,9 +1,5 @@
-var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
- get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
-}) : x)(function(x) {
- if (typeof require !== "undefined") return require.apply(this, arguments);
- throw Error('Dynamic require of "' + x + '" is not supported');
-});
+import { createRequire } from "module";
+const __require = createRequire(import.meta.url);
// src/adapters/adapter-factory.ts
import { createAdapter } from "adapter-types";
@@ -681,8 +677,10 @@ function createPTYManager(options) {
// src/ensure-pty.ts
import { execSync } from "child_process";
+const __dirname = dirname(fileURLToPath(import.meta.url));
import { chmodSync, existsSync, readdirSync, statSync } from "fs";
import { dirname, join as join2, relative } from "path";
+import { fileURLToPath } from "url";
var TAG = "[pty-preflight]";
var platformArch = `${process.platform}-${process.arch}`;
var checked = false;
@@ -1693,14 +1691,17 @@ var PTYSession = class _PTYSession extends EventEmitter2 {
if (!this.ptyProcess) return;
this.ptyProcess.onData((data) => {
this._lastActivityAt = /* @__PURE__ */ new Date();
- this.outputBuffer += data;
+ const sanitizedData = this.respondToCursorPositionRequests(data);
+ this.outputBuffer += sanitizedData;
if (this.outputBuffer.length > _PTYSession.MAX_OUTPUT_BUFFER) {
this.outputBuffer = this.outputBuffer.slice(
-_PTYSession.MAX_OUTPUT_BUFFER
);
}
- this.emit("output", data);
- if (!this._processScheduled) {
+ if (sanitizedData.length > 0) {
+ this.emit("output", sanitizedData);
+ }
+ if (sanitizedData.length > 0 && !this._processScheduled) {
this._processScheduled = true;
setImmediate(() => {
this._processScheduled = false;
@@ -1718,9 +1719,29 @@ var PTYSession = class _PTYSession extends EventEmitter2 {
this.emit("exit", exitCode);
});
}
+ respondToCursorPositionRequests(data) {
+ if (!this.ptyProcess || !data.includes("\x1B[6n")) {
+ return data;
+ }
+ let requestCount = 0;
+ const sanitizedData = data.replace(/\x1B\[6n/g, () => {
+ requestCount += 1;
+ return "";
+ });
+ for (let i = 0; i < requestCount; i += 1) {
+ this.ptyProcess.write("\x1B[1;1R");
+ }
+ if (requestCount > 0) {
+ this.logger.debug(
+ { sessionId: this.id, requestCount },
+ "Responded to cursor position request",
+ );
+ }
+ return sanitizedData;
+ }
+
/**
- * Process the accumulated output buffer.
- * Called via setImmediate() from the onData handler so that heavy regex
+ * Process the accumulated output buffer. * Called via setImmediate() from the onData handler so that heavy regex
* work runs in its own event-loop tick, not inside node-pty's native callback.
*/
processOutputBuffer() {
diff --git a/dist/pty-worker.js b/dist/pty-worker.js
index 08d47643da79e543182e736e69232acb9412076c..3f8605e6b3e1b94ac0df999e56d3cb7ede93eced 100755
--- a/dist/pty-worker.js
+++ b/dist/pty-worker.js
@@ -1127,14 +1127,17 @@ var PTYSession = class _PTYSession extends import_node_events.EventEmitter {
if (!this.ptyProcess) return;
this.ptyProcess.onData((data) => {
this._lastActivityAt = /* @__PURE__ */ new Date();
- this.outputBuffer += data;
+ const sanitizedData = this.respondToCursorPositionRequests(data);
+ this.outputBuffer += sanitizedData;
if (this.outputBuffer.length > _PTYSession.MAX_OUTPUT_BUFFER) {
this.outputBuffer = this.outputBuffer.slice(
-_PTYSession.MAX_OUTPUT_BUFFER
);
}
- this.emit("output", data);
- if (!this._processScheduled) {
+ if (sanitizedData.length > 0) {
+ this.emit("output", sanitizedData);
+ }
+ if (sanitizedData.length > 0 && !this._processScheduled) {
this._processScheduled = true;
setImmediate(() => {
this._processScheduled = false;
@@ -1152,9 +1155,29 @@ var PTYSession = class _PTYSession extends import_node_events.EventEmitter {
this.emit("exit", exitCode);
});
}
+ respondToCursorPositionRequests(data) {
+ if (!this.ptyProcess || !data.includes("\x1B[6n")) {
+ return data;
+ }
+ let requestCount = 0;
+ const sanitizedData = data.replace(/\x1B\[6n/g, () => {
+ requestCount += 1;
+ return "";
+ });
+ for (let i = 0; i < requestCount; i += 1) {
+ this.ptyProcess.write("\x1B[1;1R");
+ }
+ if (requestCount > 0) {
+ this.logger.debug(
+ { sessionId: this.id, requestCount },
+ "Responded to cursor position request",
+ );
+ }
+ return sanitizedData;
+ }
+
/**
- * Process the accumulated output buffer.
- * Called via setImmediate() from the onData handler so that heavy regex
+ * Process the accumulated output buffer. * Called via setImmediate() from the onData handler so that heavy regex
* work runs in its own event-loop tick, not inside node-pty's native callback.
*/
processOutputBuffer() {
@@ -0,0 +1,102 @@
# Using Android gitignore template: https://github.com/github/gitignore/blob/HEAD/Android.gitignore
# Built application files
*.apk
*.aar
*.ap_
*.aab
# Files for the ART/Dalvik VM
*.dex
# Java class files
*.class
# Generated files
bin/
gen/
out/
# Uncomment the following line in case you need and you don't have the release build type files in your app
# release/
# Gradle files
.gradle/
build/
# Local configuration file (sdk path, etc)
local.properties
# Proguard folder generated by Eclipse
proguard/
# Log Files
*.log
# Android Studio Navigation editor temp files
.navigation/
# Android Studio captures folder
captures/
# IntelliJ
*.iml
.idea/workspace.xml
.idea/tasks.xml
.idea/gradle.xml
.idea/assetWizardSettings.xml
.idea/dictionaries
.idea/libraries
# Android Studio 3 in .gitignore file.
.idea/caches
.idea/modules.xml
# Comment next line if keeping position of elements in Navigation Editor is relevant for you
.idea/navEditor.xml
# Keystore files — never commit signing keys
*.jks
*.keystore
play-store-key.json
google-play-key.json
# External native build folder generated in Android Studio 2.2 and later
.externalNativeBuild
.cxx/
# Google Services (e.g. APIs or Firebase)
# google-services.json
# Freeline
freeline.py
freeline/
freeline_project_description.json
# fastlane
fastlane/report.xml
fastlane/Preview.html
fastlane/screenshots
fastlane/test_output
fastlane/readme.md
# Version control
vcs.xml
# lint
lint/intermediates/
lint/generated/
lint/outputs/
lint/tmp/
# lint/reports/
# Android Profiling
*.hprof
# Copied web assets
app/src/main/assets/public
# Generated Config files
app/src/main/assets/capacitor.config.json
app/src/main/assets/capacitor.plugins.json
app/src/main/res/xml/config.xml
# Fastlane vendor
vendor/
@@ -0,0 +1,6 @@
source "https://rubygems.org"
gem "fastlane", "~> 2.225"
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)
@@ -0,0 +1,153 @@
# elizaOS Android build targets
The build orchestrator at
[`packages/app-core/scripts/run-mobile-build.mjs`](../../scripts/run-mobile-build.mjs)
ships three Android targets. They are deliberately separate because their
manifests, embedded native artifacts, and signing models differ in ways
that make a single APK unviable.
## `build:android:cloud` — Play-Store thin client
```bash
bun run build:android:cloud
```
A Play-Store-compliant Capacitor APK backed by Eliza Cloud as the only
hosting target. Produces a release AAB at
`packages/app-core/platforms/android/app/build/outputs/bundle/release/`.
For local Pixel smoke tests, `android-cloud-debug` produces a debug APK
under `packages/app-core/platforms/android/app/build/outputs/apk/debug/`.
What this target deliberately does **not** ship:
- No on-device agent runtime — `assets/agent/` is not staged, and no
`libeliza_*.so` is copied into `jniLibs/`.
- No `ElizaAgentService` declaration.
- No default-role activities (`ElizaDialActivity`, `ElizaSmsReceiver`,
`ElizaBrowserActivity`, `ElizaContactsActivity`, `ElizaCameraActivity`,
`ElizaCalendarActivity`, `ElizaClockActivity`, `ElizaAssistActivity`,
`ElizaInCallService`, `ElizaMmsReceiver`,
`ElizaRespondViaMessageService`, `ElizaSmsComposeActivity`).
- No `ElizaBootReceiver`.
- No screen-capture native plugin or MediaProjection foreground-service
declaration.
- No system-only or Play-Store-restricted permissions:
`MANAGE_APP_OPS_MODES`, `PACKAGE_USAGE_STATS`, `BIND_DEVICE_ADMIN`,
`READ_FRAME_BUFFER`, `INJECT_EVENTS`, `REAL_GET_TASKS`,
`READ_SMS` / `SEND_SMS` / `RECEIVE_SMS` / `RECEIVE_MMS` /
`RECEIVE_WAP_PUSH`, `CALL_PHONE` / `READ_PHONE_STATE` /
`ANSWER_PHONE_CALLS` / `MANAGE_OWN_CALLS` / `READ_CALL_LOG` /
`WRITE_CALL_LOG`, `READ_CONTACTS` / `WRITE_CONTACTS`,
`ACCESS_BACKGROUND_LOCATION`, `RECEIVE_BOOT_COMPLETED`,
`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`, `SYSTEM_ALERT_WINDOW`,
`FOREGROUND_SERVICE_MEDIA_PROJECTION`, `FOREGROUND_SERVICE_SPECIAL_USE`.
What this target still ships for Pixel/Google Android entry points:
- `res/xml/shortcuts.xml` registered from `MainActivity` with
`android.app.shortcuts`.
- App Actions capabilities for the supported BIIs `OPEN_APP_FEATURE`,
`CREATE_MESSAGE`, and `GET_THING`. These cover chat/ask, voice,
LifeOps daily brief, LifeOps task creation, and LifeOps task lists by
opening source-tagged deep links in the app.
- `OPEN_APP_FEATURE` uses inline inventory for known features and keeps a
no-parameter fallback fulfillment, as required by the App Actions
`shortcuts.xml` schema.
- Static launcher/Assistant shortcuts for chat, voice, daily brief, new
task, and tasks.
Entry-point mapping:
| User flow | Android surface | Fulfillment |
|---|---|---|
| Ask or chat with Eliza | `CREATE_MESSAGE` for message text, `GET_THING` for search-style ask text, plus the chat static shortcut | `elizaos://chat?...` source-tagged deep links |
| Start voice chat | `OPEN_APP_FEATURE` inline inventory plus the voice static shortcut | `elizaos://voice?source=android-static-shortcut` |
| Open LifeOps daily brief | `OPEN_APP_FEATURE` inline inventory plus the daily-brief static shortcut | `elizaos://lifeops/daily-brief?source=android-static-shortcut` |
| Create a LifeOps task | `OPEN_APP_FEATURE` inline inventory plus the new-task static shortcut | `elizaos://lifeops/task/new?source=android-static-shortcut` deep link, then runtime confirmation/planning |
| View LifeOps tasks | `OPEN_APP_FEATURE` inline inventory plus the tasks static shortcut | `elizaos://lifeops/tasks?source=android-static-shortcut` |
There is no general third-party "be Gemini/default assistant" API for the
Play build. Current Android docs route normal app voice entry through
App Actions capabilities in `shortcuts.xml` and Android shortcuts; custom
Gemini/Assistant intent formats documented for navigation apps are
navigation-specific, not a general assistant surface for this app.
The Play build intentionally does not request default-assistant or
system-only powers. It has no `ACTION_ASSIST`, `VOICE_COMMAND`,
`ROLE_ASSISTANT`, `BIND_VOICE_INTERACTION`, usage-stats appop, SMS/call
default-role components, boot receiver, MediaProjection foreground
service, or special-use foreground service. Gemini/Assistant
interoperability for this target is through Google App Actions and
Android shortcuts, not by trying to become the device's default
assistant. Do not add unsupported BIIs such as `actions.intent.CREATE_THING`;
task creation is modeled as opening the LifeOps task feature.
Build-time flag set: `VITE_ELIZA_ANDROID_RUNTIME_MODE=cloud`. The
renderer reads this via
[`packages/ui/src/platform/android-runtime.ts`](../../../../ui/src/platform/android-runtime.ts)
and the `RuntimeSettingsSection` hides the Local picker option so users
cannot try to provision an on-device agent that physically isn't there.
## `build:android` — sideload-only debug
```bash
bun run build:android
```
> **WARNING** — this target embeds the Bun-based on-device agent runtime
> as `libeliza_bun.so` (≈9596 MB per ABI) inside `jniLibs/`, declares
> `FOREGROUND_SERVICE_SPECIAL_USE local-agent-runtime`, and requests
> system-only permissions (`MANAGE_APP_OPS_MODES`, `PACKAGE_USAGE_STATS`,
> `BIND_DEVICE_ADMIN`). It will be **rejected by the Play Store**. Use
> only for sideload installs and developer iteration, or migrate to
> `build:android:cloud` for distribution.
What it does ship: full default-role activities, BootReceiver, the
on-device agent staged via
[`stage-android-agent.mjs`](../../scripts/lib/stage-android-agent.mjs),
the AOSP-aimed permission set, and the same App Actions/static shortcuts
metadata used by the Play build. `ElizaAssistActivity` handles
`android.intent.action.ASSIST` for sideload/AOSP assistant-role testing;
the Play build strips that activity.
For the retail digital-assistant integration the sideload/AOSP builds also
ship a `VoiceInteractionService` trio — `ElizaVoiceInteractionService`
(the assistant), `ElizaVoiceInteractionSessionService` +
`ElizaVoiceInteractionSession` (the ChatGPT-style overlay voice bar that
hands off via `elizaos://voice?source=android-assistant-session`), and
`ElizaRecognitionService` (required by the VIS contract) — declared with
`BIND_VOICE_INTERACTION` and wired through
[`res/xml/eliza_voice_interaction_service.xml`](app/src/main/res/xml/eliza_voice_interaction_service.xml).
This is what surfaces Eliza under Settings → Apps → Default apps → Digital
assistant app and lets the assist gesture / long-press-power invoke it.
Users request the role at runtime through the `@elizaos/capacitor-system`
bridge (`System.requestRole({ role: "assistant" })`, surfaced in the
Device Settings overlay). The Play build strips all four components. The
matching AOSP ROM glue that pre-grants the role for the VIS is follow-up
sub-issue 6 of #12185.
## `build:android:system` — AOSP privileged platform-signed APK
```bash
bun run build:android:system
```
Release APK signed by Soong's platform key for Eliza OS / ElizaOS
device builds. The privileged `MANAGE_APP_OPS_MODES`,
`PACKAGE_USAGE_STATS`, `READ_FRAME_BUFFER`, `INJECT_EVENTS`, and
`REAL_GET_TASKS` permissions are granted via the
`privapp-permissions-ai.elizaos.app.xml` whitelist baked into the vendor
tree, so this APK is intended for `priv-app/` placement on
Eliza-flavored AOSP devices, **not** for Play Store distribution.
The matching system image also copies
`/product/etc/eliza/aosp-assistant-full-control.json`, which records the
AOSP-only assistant/full-control contract: `ROLE_ASSISTANT`,
`ACTION_ASSIST`, `VOICE_COMMAND`, boot/direct-boot, foreground services,
usage stats, MediaProjection/SurfaceControl screen capture, Accessibility
input control, and the AOSP-only accessibility and notification-listener
service declarations.
The release APK is staged at
`<repoRoot>/packages/os/android/vendor/eliza/apps/Eliza/Eliza.apk` (or
the brand-aware vendor dir resolved from `app.config.ts > aosp:`).
@@ -0,0 +1,2 @@
/build/*
!/build/.npmkeep
@@ -0,0 +1,462 @@
apply plugin: 'com.android.application'
// Single repo-root resolution shared by the elizavoice header gate and the
// CMake include-dir argument. patchAndroidGradle() in run-mobile-build.mjs
// rewrites the elizaRepoRoot definition below to the absolute checkout root
// for white-label builds (ELIZA_ANDROID_USE_APP_DIR=1), where the android
// project lives outside the eliza tree and the relative walk overshoots.
// Keep the variable name and single definition, or that pin silently misses.
def elizaRepoRoot = rootProject.projectDir.toPath().resolve('../../../..').normalize().toFile()
def resolveElizaVoiceArm64SourceDir = {
def fromProp = project.findProperty("eliza.mtp.android.libdir")
if (fromProp) return fromProp.toString()
def fromEnv = System.getenv("ELIZA_MTP_ANDROID_LIBDIR")
if (fromEnv) return fromEnv
def stateDir = System.getenv('ELIZA_STATE_DIR') ?: "${System.getProperty('user.home')}/.eliza"
def candidates = ['vulkan', 'cpu'].collect { backend ->
"${stateDir}/local-inference/bin/mtp/android-arm64-${backend}"
}
return candidates.find { new File(it).isDirectory() }
}
def resolveElizaVoiceProbePrebuilt = {
def sourceDir = resolveElizaVoiceArm64SourceDir()
if (sourceDir) {
def sourcePrebuilt = new File(sourceDir.toString(), 'libelizainference.so')
if (sourcePrebuilt.isFile()) return sourcePrebuilt
}
return file("src/main/jniLibs/arm64-v8a/libelizainference.so")
}
android {
namespace "ai.elizaos.app"
compileSdk rootProject.ext.compileSdkVersion
// BuildConfig is needed for AOSP_BUILD gating in ElizaAgentService.java —
// see eliza/packages/agent/src/runtime/aosp-llama-adapter.ts and
// scripts/elizaos/build-aosp.mjs. AGP 8+ defaults this to false.
buildFeatures {
buildConfig true
}
defaultConfig {
buildConfigField "boolean", "ELIZA_ANDROID_SMS_GATEWAY_ENABLED", "${['1', 'true', 'yes'].contains((System.getenv('ELIZA_ANDROID_SMS_GATEWAY_ENABLED') ?: 'false').toLowerCase())}"
buildConfigField "String", "ELIZA_ANDROID_SMS_GATEWAY_SECRET", "\"\""
buildConfigField "String", "ELIZA_ANDROID_SMS_GATEWAY_WEBHOOK_URL", "\"https://api.elizacloud.ai/api/webhooks/blooio/local?bridge=bluebubbles\""
buildConfigField "String", "ELIZA_ANDROID_SMS_GATEWAY_PHONE_NUMBER", "\"+14159611510\""
buildConfigField "String", "ELIZA_ANDROID_SMS_GATEWAY_PHONE_LABEL", "\"Eliza Cloud Gateway (+14159611510)\""
applicationId "ai.elizaos.app"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode System.getenv("ELIZAOS_VERSION_CODE")?.toInteger() ?: 1
versionName System.getenv("ELIZAOS_VERSION_NAME") ?: "1.0.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
// AOSP-only build gate. The ElizaOS AOSP product build sets
// -PelizaAospBuild=true via scripts/elizaos/build-aosp.mjs; the
// Capacitor APK build path (`bun run build:android`) leaves it
// unset and the field stays false. ElizaAgentService reads this
// to decide whether to export ELIZA_LOCAL_LLAMA=1 to the bun
// process so the AOSP-only fused libelizainference.so loader takes
// over inference.
buildConfigField "boolean", "AOSP_BUILD", "${project.findProperty('elizaAospBuild') ?: 'false'}"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
// Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61
ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
// Phase 3a fused-voice JNI bridge (libelizavoicejni.so). Built by the
// NDK (bionic) from src/main/elizavoice-jni; links the prebuilt,
// self-contained libelizainference.so staged into jniLibs/arm64-v8a by
// stage-elizavoice-lib.mjs. Pass the fork FFI header dir to the shim's
// CMake. arm64-v8a only in Phase 3a (other ABIs build a no-op stub).
externalNativeBuild {
cmake {
arguments "-DELIZAVOICE_FFI_INCLUDE_DIR=${new File(elizaRepoRoot, 'plugins/plugin-local-inference/native/llama.cpp/tools/omnivoice/include').absolutePath}"
}
}
}
// Only wire the Phase 3a elizavoice-jni native build when the configured
// fused lib source exports the fused-voice FFI entrypoints. This probe must
// not read only src/main/jniLibs: copyForkLlamaLib stages that directory at
// task execution time, after Gradle configuration. Clean builds therefore
// decide from -Peliza.mtp.android.libdir / ELIZA_MTP_ANDROID_LIBDIR (or the
// state-dir default), then the externalNativeBuild tasks depend on
// copyForkLlamaLib below so CMake sees the staged .so before linking.
def elizavoiceHeader = new File(elizaRepoRoot, 'plugins/plugin-local-inference/native/llama.cpp/tools/omnivoice/include/eliza-inference-ffi.h')
def elizavoicePrebuilt = resolveElizaVoiceProbePrebuilt()
def elizavoicePrebuiltText = elizavoicePrebuilt.exists()
? new String(elizavoicePrebuilt.readBytes(), "ISO-8859-1")
: ""
def elizavoiceRequiredBinaryTokens = [
"eliza_inference_wakeword_supported",
"eliza_inference_speaker_supported",
"eliza_inference_diariz_supported"
]
def elizavoiceBridgeSkipped = project.findProperty('elizaCloudBuild') == 'true' ||
System.getenv('ELIZA_ANDROID_SKIP_FORK_LLAMA_LIB') == '1'
def elizavoiceReady = !elizavoiceBridgeSkipped &&
elizavoicePrebuilt.exists() &&
elizavoiceRequiredBinaryTokens.every { elizavoicePrebuiltText.contains(it) }
if (elizavoiceReady && !elizavoiceHeader.exists()) {
throw new GradleException("[elizavoice-jni] ${elizavoicePrebuilt} exports the fused-voice ABI, but ${elizavoiceHeader} is missing. Run git submodule update --init --recursive so the llama.cpp omnivoice headers are checked out.")
}
if (elizavoiceReady) {
externalNativeBuild {
cmake {
path "src/main/elizavoice-jni/CMakeLists.txt"
version "3.22.1"
}
}
} else if (elizavoiceBridgeSkipped) {
logger.lifecycle("[elizavoice-jni] skipped for cloud/smoke build")
} else {
logger.warn("[elizavoice-jni] prebuilt libelizainference.so lacks fused-voice symbols (wakeword/speaker/diariz); skipping Phase 3a JNI bridge so the APK still builds. Rebuild via stage-elizavoice-lib.mjs to re-enable.")
}
ndkVersion "28.2.13676358"
signingConfigs {
release {
// For Google Play App Signing: use an upload keystore.
// CI provides these via environment variables; local builds fall back to debug signing.
if (System.getenv("ELIZAOS_KEYSTORE_PATH")) {
storeFile file(System.getenv("ELIZAOS_KEYSTORE_PATH"))
storePassword System.getenv("ELIZAOS_KEYSTORE_PASSWORD")
keyAlias System.getenv("ELIZAOS_KEY_ALIAS")
keyPassword System.getenv("ELIZAOS_KEY_PASSWORD")
}
}
}
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
if (signingConfigs.release.storeFile) {
signingConfig signingConfigs.release
}
}
}
bundle {
language {
enableSplit = true
}
density {
enableSplit = true
}
abi {
enableSplit = true
}
}
// Preserve .gguf / .so as-is in the packaged APK.
//
// .gguf is added so bundled chat + embedding models stay byte-identical
// for memory-mapped loading; .so already ships uncompressed but
// declaring it here matches AGP's recommended noCompress list and
// protects against future packaging-default changes.
//
// `tar.gz` / `tar` are NOT listed because aapt2 pre-processes
// `*.tar.gz` by stripping the `.gz` suffix at packaging time
// regardless of `noCompress` (which only controls ZIP-level
// compression of the entry, not the doubly-compressed-extension
// rewrite). ElizaAgentService.extractAssetsIfNeeded() compensates
// by reading the assets under the aapt2-rewritten `.tar` name and
// writing them to disk as `.tar.gz` so PGlite's runtime loader
// (`new URL("../vector.tar.gz", import.meta.url)`) finds them.
androidResources {
noCompress += ['gguf', 'so']
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
packaging {
jniLibs {
useLegacyPackaging = true
keepDebugSymbols += ["**/libimage_processing_util_jni.so"]
// The retired llama-cpp-capacitor plugin is dropped from the manifest
// by default (stubLlamaCpp), so its separate libllama-cpp-arm64.so —
// a second, unused llama.cpp — is never loaded. Keep that dead 6.6 MB
// out of the APK too, so libelizainference is the ONLY inference lib
// shipped. Opt back in (plugin + its lib) with
// ELIZA_ANDROID_INCLUDE_LLAMA_CPP_CAPACITOR=1.
if (System.getenv('ELIZA_ANDROID_INCLUDE_LLAMA_CPP_CAPACITOR') != '1') {
excludes += ["**/libllama-cpp-arm64.so"]
}
}
}
// Preserve .tar.gz / .tar / .gguf / .so as-is in the packaged APK.
// aapt2 otherwise rewrites .tar.gz to .tar and PGlite's runtime
// extension loader fails to find vector.tar.gz / fuzzystrmatch.tar.gz.
androidResources {
noCompress += ['gguf', 'tar.gz', 'so', 'tar']
}
}
// Bundle the MTP Android llama.cpp stack into the APK so mobile
// gets Eliza-1/Gemma 4 support across every supported Android ABI
// (arm64-v8a, x86_64, riscv64). The arm64-v8a slice is mandatory for
// local-agent capable builds; x86_64 and riscv64 ship when their
// per-ABI artifacts exist (Wave 2 cross-compiles land them
// incrementally). Cloud builds and explicitly opted-out CI smoke
// builds skip this task.
ext.elizaForkLlamaAbis = ['arm64-v8a', 'x86_64', 'riscv64']
ext.forkLlamaAbiTokens = [
'arm64-v8a': 'android-arm64',
'x86_64': 'android-x86_64',
'riscv64': 'android-riscv64'
]
ext.forkLlamaLibompAbiTokens = [
'arm64-v8a': 'aarch64',
'x86_64': 'x86_64',
'riscv64': 'riscv64'
]
def resolveForkLlamaLibDir = { String abi ->
// arm64-v8a keeps the legacy un-suffixed property/env names for
// backwards compatibility; other ABIs use the suffixed forms.
def propSuffix = abi == 'arm64-v8a' ? '' : ".${abi}"
def envSuffix = abi == 'arm64-v8a' ? '' : "_${abi.replace('-', '_').toUpperCase()}"
def fromProp = project.findProperty("eliza.mtp.android.libdir${propSuffix}")
if (fromProp) return fromProp.toString()
def fromEnv = System.getenv("ELIZA_MTP_ANDROID_LIBDIR${envSuffix}")
if (fromEnv) return fromEnv
def stateDir = System.getenv('ELIZA_STATE_DIR') ?: "${System.getProperty('user.home')}/.eliza"
def abiToken = project.ext.forkLlamaAbiTokens[abi]
def candidates = ['vulkan', 'cpu'].collect { backend ->
"${stateDir}/local-inference/bin/mtp/${abiToken}-${backend}"
}
return candidates.find { new File(it).isDirectory() }
}
def resolveAndroidLibompForAbi = { String abi ->
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.isFile()) {
localPropertiesFile.withInputStream { localProperties.load(it) }
}
def sdkPath = localProperties.getProperty('sdk.dir') ?:
System.getenv('ANDROID_HOME') ?:
System.getenv('ANDROID_SDK_ROOT') ?:
"${System.getProperty('user.home')}/Library/Android/sdk"
def androidSdk = new File(sdkPath)
def ndkRoots = []
def declaredNdkVersion = android.ndkVersion?.toString()
if (declaredNdkVersion) ndkRoots << new File(androidSdk, "ndk/${declaredNdkVersion}")
ndkRoots << new File(androidSdk, 'ndk/29.0.13113456')
def ndkParent = new File(androidSdk, 'ndk')
if (ndkParent.isDirectory()) {
(ndkParent.listFiles() ?: [] as File[]).each { ndkRoots << it }
}
def libompAbiToken = project.ext.forkLlamaLibompAbiTokens[abi]
for (def ndkDir : ndkRoots.unique { it.absolutePath }) {
def prebuiltDir = new File(ndkDir, 'toolchains/llvm/prebuilt')
if (!prebuiltDir.isDirectory()) continue
def hosts = prebuiltDir.listFiles() ?: [] as File[]
for (def hostDir : hosts) {
def clangDir = new File(hostDir, 'lib/clang')
def versions = clangDir.listFiles() ?: [] as File[]
for (def versionDir : versions) {
def libomp = new File(versionDir, "lib/linux/${libompAbiToken}/libomp.so")
if (libomp.isFile()) return libomp
}
}
}
return null
}
// NOTE: despite the legacy "ForkLlama" name, this stages the SINGLE canonical
// fused inference lib — libelizainference.so — together with its own DT_NEEDED
// runtime siblings (libggml{,-base,-cpu,-vulkan}.so, libllama.so, libllama-common.so,
// libmtmd.so, libomp.so). Those siblings ARE libelizainference's GPU/CPU backends
// (the Vulkan variant links them dynamically), NOT a separate "fork" of llama.cpp.
// There is no second inference library. Do NOT delete this task or its siblings —
// that breaks on-device inference. (libelizainference also folds in MTP, omnivoice
// TTS/Kokoro, ASR, VAD, vision; see verify-fused-symbols.mjs for the symbol gate.)
task copyForkLlamaLib {
doLast {
if (project.findProperty('elizaCloudBuild') == 'true' || System.getenv('ELIZA_ANDROID_SKIP_FORK_LLAMA_LIB') == '1') {
println "[copyForkLlamaLib] skipped for cloud/smoke build"
return
}
boolean stagedArm64 = false
int totalCopied = 0
boolean stagedKernels = false
project.ext.elizaForkLlamaAbis.each { abi ->
def libDir = resolveForkLlamaLibDir(abi)
if (!libDir) {
// No fresh source configured. If the fused lib set is already
// staged in jniLibs (a prior build, the common dev case), use it
// as-is — a plain `build:android` "just works" without any flag.
def alreadyStaged = new File(file("src/main/jniLibs/${abi}"), 'libelizainference.so')
if (alreadyStaged.isFile()) {
logger.lifecycle("[copyForkLlamaLib] no source dir for ${abi}; libelizainference.so already staged in jniLibs — using the pre-staged fused lib set")
if (abi == 'arm64-v8a') stagedArm64 = true
return
}
if (abi == 'arm64-v8a') {
// arm64-v8a is the mandatory baseline ABI; missing it (and no
// pre-staged lib) is a hard error.
throw new GradleException("[copyForkLlamaLib] no fused inference lib for arm64-v8a (not configured, not pre-staged). Run packages/app-core/scripts/aosp/compile-libllama.mjs --target android-arm64-vulkan-fused (the Android cross-compiler; build-llama-cpp-mtp.mjs has no Android targets) or set -Peliza.mtp.android.libdir / ELIZA_MTP_ANDROID_LIBDIR.")
}
logger.lifecycle("[copyForkLlamaLib] no fork lib dir for ABI ${abi}; skipping")
return
}
def srcDir = new File(libDir.toString())
if (!srcDir.isDirectory()) {
if (abi == 'arm64-v8a') {
throw new GradleException("[copyForkLlamaLib] MTP Android lib dir does not exist for arm64-v8a: ${libDir}")
}
logger.lifecycle("[copyForkLlamaLib] fork lib dir ${libDir} does not exist for ABI ${abi}; skipping")
return
}
def jniDir = file("src/main/jniLibs/${abi}")
jniDir.mkdirs()
def assetsDir = file('src/main/assets')
assetsDir.mkdirs()
int copied = 0
srcDir.eachFile { src ->
if (src.name.endsWith('.so')) {
def dst = new File(jniDir, src.name)
dst.bytes = src.bytes
copied++
}
// kernels.json is ABI-independent; stage once from the first ABI we see.
if (src.name == 'kernels.json' && !stagedKernels) {
def dst = new File(assetsDir, 'llama-cpp-kernels.json')
dst.bytes = src.bytes
println "[copyForkLlamaLib] staged kernels.json as assets/llama-cpp-kernels.json (from ${abi})"
stagedKernels = true
}
}
def libomp = resolveAndroidLibompForAbi(abi)
if (libomp != null) {
def dst = new File(jniDir, 'libomp.so')
dst.bytes = libomp.bytes
copied++
println "[copyForkLlamaLib] staged Android OpenMP runtime for ${abi} from ${libomp}"
} else if (abi == 'arm64-v8a') {
throw new GradleException("[copyForkLlamaLib] Android arm64 libomp.so not found in the configured NDK; MTP CPU backend cannot load without it.")
} else {
logger.lifecycle("[copyForkLlamaLib] no libomp.so found for ${abi}; the .so set may not link on-device")
}
println "[copyForkLlamaLib] copied ${copied} .so file(s) from ${libDir} to ${jniDir}"
totalCopied += copied
if (abi == 'arm64-v8a') stagedArm64 = true
}
if (!stagedArm64) {
throw new GradleException("[copyForkLlamaLib] arm64-v8a slice was not staged; aborting (this is the baseline ABI).")
}
}
}
afterEvaluate {
tasks.matching { it.name == 'preBuild' }.all { it.dependsOn copyForkLlamaLib }
tasks.matching {
it.name.startsWith('externalNativeBuild') ||
it.name.startsWith('configureCMake') ||
it.name.startsWith('buildCMake')
}.all { it.dependsOn copyForkLlamaLib }
}
// Optional app thinning: keep assets/agent/ by default so stock
// Capacitor APKs can run the bundled local agent. Set
// -PelizaStripAgentAssets=true only for an explicitly cloud-only slim APK.
afterEvaluate {
tasks.matching { it.name.startsWith('merge') && it.name.endsWith('Assets') }.all { mergeTask ->
mergeTask.inputs.property('elizaAospBuild', project.findProperty('elizaAospBuild') ?: 'false')
mergeTask.inputs.property('elizaStripAgentAssets', project.findProperty('elizaStripAgentAssets') ?: 'false')
mergeTask.inputs.property('elizaCloudBuild', project.findProperty('elizaCloudBuild') ?: 'false')
mergeTask.doLast {
if (project.findProperty('elizaAospBuild') != 'true' && (project.findProperty('elizaStripAgentAssets') == 'true' || project.findProperty('elizaCloudBuild') == 'true')) {
def assetsDir = mergeTask.outputDir.get().asFile
def agentDir = new File(assetsDir, 'agent')
if (agentDir.exists()) {
println "[app-thinning] removing assets/agent/ from ${mergeTask.name} (cloud/slim Capacitor build)"
agentDir.deleteDir()
}
} else {
println "[app-thinning] keeping assets/agent/ in ${mergeTask.name} (local-agent capable build)"
}
}
}
}
repositories {
flatDir {
dirs 'libs',
'../../node_modules/@capacitor/background-runner/android/src/main/libs',
'../../node_modules/@capacitor-community/background-runner/android/src/main/libs',
'../../../../app/node_modules/@capacitor/background-runner/android/src/main/libs',
'../../../../app/node_modules/@capacitor-community/background-runner/android/src/main/libs',
'../../../../node_modules/@capacitor/background-runner/android/src/main/libs',
'../../../../node_modules/@capacitor-community/background-runner/android/src/main/libs'
}
}
dependencies {
implementation "com.google.code.gson:gson:2.13.2"
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
// WorkManager for periodic background refresh (see ElizaTasksWorker + ElizaBootReceiver).
// The Capacitor background-runner already pulls in work-runtime-ktx transitively;
// this explicit dep makes the worker classes resolvable from Java without relying on
// transitive resolution and pins us to the same version (2.11.0).
implementation "androidx.work:work-runtime:2.11.0"
// androidx.work's Worker/ListenableWorker API returns Guava's
// com.google.common.util.concurrent.ListenableFuture. That class ships in
// full Guava; the transitively-resolved listenablefuture artifact is the
// empty 9999.0-empty-to-avoid-conflict-with-guava stub, so without a direct
// Guava dep javac fails to compile ElizaTasksWorker (extends Worker) with
// "cannot access ListenableFuture". The empty-stub mechanism prevents any
// duplicate-class conflict once full Guava is on the classpath.
implementation "com.google.guava:guava:31.1-android"
implementation project(':capacitor-android')
implementation "com.google.code.gson:gson:2.14.0"
implementation "com.google.firebase:firebase-common-ktx:21.0.0"
testImplementation "junit:junit:$junitVersion"
androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion"
androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion"
implementation project(':capacitor-cordova-android-plugins')
}
apply from: 'capacitor.build.gradle'
try {
def servicesJSON = file('google-services.json')
if (servicesJSON.text) {
apply plugin: 'com.google.gms.google-services'
}
} catch(Exception e) {
logger.info("google-services.json not found, google-services plugin not applied. Push Notifications won't work")
}
// [cloud-app-thinning] Cloud builds must never package the local agent payload.
// This second hook patches older generated projects whose existing
// [app-thinning] block only honored -PelizaStripAndroidAgentAssets.
afterEvaluate {
tasks.matching { it.name.startsWith('merge') && it.name.endsWith('Assets') }.all { mergeTask ->
mergeTask.inputs.property('elizaCloudBuild', project.findProperty('elizaCloudBuild') ?: 'false')
mergeTask.doLast {
if (project.findProperty('elizaCloudBuild') == 'true') {
def assetsDir = mergeTask.outputDir.get().asFile
def agentDir = new File(assetsDir, 'agent')
if (agentDir.exists()) {
println "[cloud-app-thinning] removing assets/agent/ from ${mergeTask.name}"
agentDir.deleteDir()
}
}
}
}
}
@@ -0,0 +1,52 @@
// DO NOT EDIT THIS FILE! IT IS GENERATED EACH TIME "capacitor update" IS RUN
android {
compileOptions {
sourceCompatibility JavaVersion.VERSION_21
targetCompatibility JavaVersion.VERSION_21
}
}
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':elizaos-capacitor-bun-runtime')
implementation project(':capacitor-app')
implementation project(':capacitor-background-runner')
implementation project(':capacitor-barcode-scanner')
implementation project(':capacitor-browser')
implementation project(':capacitor-device')
implementation project(':capacitor-filesystem')
implementation project(':capacitor-haptics')
implementation project(':capacitor-keyboard')
implementation project(':capacitor-local-notifications')
implementation project(':capacitor-network')
implementation project(':capacitor-preferences')
implementation project(':capacitor-push-notifications')
implementation project(':capacitor-share')
implementation project(':capacitor-status-bar')
implementation project(':elizaos-capacitor-agent')
implementation project(':elizaos-capacitor-appblocker')
implementation project(':elizaos-capacitor-browser-surface')
implementation project(':elizaos-capacitor-camera')
implementation project(':elizaos-capacitor-canvas')
implementation project(':elizaos-capacitor-contacts')
implementation project(':elizaos-capacitor-gateway')
implementation project(':elizaos-capacitor-location')
implementation project(':elizaos-capacitor-messages')
implementation project(':elizaos-capacitor-mlkit-text')
implementation project(':elizaos-capacitor-mobile-agent-bridge')
implementation project(':elizaos-capacitor-mobile-signals')
implementation project(':elizaos-capacitor-phone')
implementation project(':elizaos-capacitor-screencapture')
implementation project(':elizaos-capacitor-swabble')
implementation project(':elizaos-capacitor-system')
implementation project(':elizaos-capacitor-talkmode')
implementation project(':elizaos-capacitor-websiteblocker')
implementation project(':elizaos-capacitor-wifi')
}
if (hasProperty('postBuildExtras')) {
postBuildExtras()
}
@@ -0,0 +1,62 @@
# elizaOS ProGuard/R8 Rules
# =========================
# Capacitor keep the bridge and plugin classes
-keep class com.getcapacitor.** { *; }
-keep @com.getcapacitor.annotation.CapacitorPlugin class * { *; }
-keep class com.getcapacitor.community.** { *; }
# elizaOS custom Capacitor plugins.
#
# Two package roots are in play and BOTH must be kept:
# - ai.elizaos.plugins.* (only plugin-native-bun-runtime uses this)
# - ai.eliza.plugins.* (every other @elizaos/capacitor-* native plugin:
# websiteblocker, appblocker, camera, gateway,
# location, screencapture, swabble, talkmode, )
#
# The ai.eliza.plugins.* root in particular contains manifest-declared
# components that Android instantiates by name most critically
# ai.eliza.plugins.websiteblocker.WebsiteBlockerBootReceiver (BOOT_COMPLETED)
# and WebsiteBlockerVpnService. Those have no @CapacitorPlugin annotation and
# no other code reference, so without an explicit keep R8 strips them from the
# release dex and the merged-manifest receiver crash-loops the app at boot with
# ClassNotFoundException. Keep the whole native-plugin namespace.
-keep class ai.elizaos.plugins.** { *; }
-keep class ai.eliza.plugins.** { *; }
-keep class app.eliza.** { *; }
# WebView JavaScript interface
-keepclassmembers class * {
@android.webkit.JavascriptInterface <methods>;
}
# OkHttp (used by gateway plugin)
-dontwarn okhttp3.**
-dontwarn okio.**
-keep class okhttp3.** { *; }
-keep interface okhttp3.** { *; }
# Kotlin coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}
-keepclassmembers class kotlinx.coroutines.** {
volatile <fields>;
}
# AndroidX
-keep class androidx.** { *; }
-keep interface androidx.** { *; }
# Keep source file names and line numbers for crash reports
-keepattributes SourceFile,LineNumberTable
-renamesourcefileattribute SourceFile
# Preserve annotations
-keepattributes *Annotation*
-keepattributes Signature
-keepattributes Exceptions
# Firebase/GMS (if present)
-keep class com.google.firebase.** { *; }
-keep class com.google.android.gms.** { *; }
-dontwarn com.google.firebase.ktx.Firebase
@@ -0,0 +1,139 @@
package ai.elizaos.app;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import android.app.ForegroundServiceStartNotAllowedException;
import android.os.Build;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;
@RunWith(AndroidJUnit4.class)
public class ElizaAgentWatchdogPolicyInstrumentedTest {
@Test
public void readyHealthBodyAcceptsOnlyRunningRuntime() {
assertTrue(ElizaAgentWatchdogPolicy.isReadyHealthBody(
"{\"ready\":true,\"runtime\":\"ok\",\"agentState\":\"running\"}"
));
assertTrue(ElizaAgentWatchdogPolicy.isReadyHealthBody(
"{\"ready\":true}"
));
assertFalse(ElizaAgentWatchdogPolicy.isReadyHealthBody(
"{\"ready\":false,\"runtime\":\"ok\",\"agentState\":\"running\"}"
));
assertFalse(ElizaAgentWatchdogPolicy.isReadyHealthBody(
"{\"ready\":true,\"runtime\":\"degraded\",\"agentState\":\"running\"}"
));
assertFalse(ElizaAgentWatchdogPolicy.isReadyHealthBody(
"{\"ready\":true,\"runtime\":\"ok\",\"agentState\":\"starting\"}"
));
assertFalse(ElizaAgentWatchdogPolicy.isReadyHealthBody("not-json"));
}
@Test
public void busyProbePreservesExistingStrikeCounter() {
ElizaAgentWatchdogPolicy.HealthDecision decision =
ElizaAgentWatchdogPolicy.evaluateHealthProbe(
ElizaAgentWatchdogPolicy.ProbeResult.BUSY,
2,
3
);
assertEquals(2, decision.unhealthyTicks);
assertFalse(decision.restartRequired);
assertFalse(decision.resetRestartAttempts);
}
@Test
public void okProbeClearsStrikesAndResetsBackoff() {
ElizaAgentWatchdogPolicy.HealthDecision decision =
ElizaAgentWatchdogPolicy.evaluateHealthProbe(
ElizaAgentWatchdogPolicy.ProbeResult.OK,
2,
3
);
assertEquals(0, decision.unhealthyTicks);
assertFalse(decision.restartRequired);
assertTrue(decision.resetRestartAttempts);
}
@Test
public void deadProbeRestartsOnlyAfterConfiguredStrikeThreshold() {
ElizaAgentWatchdogPolicy.HealthDecision first =
ElizaAgentWatchdogPolicy.evaluateHealthProbe(
ElizaAgentWatchdogPolicy.ProbeResult.DEAD,
0,
3
);
ElizaAgentWatchdogPolicy.HealthDecision second =
ElizaAgentWatchdogPolicy.evaluateHealthProbe(
ElizaAgentWatchdogPolicy.ProbeResult.DEAD,
first.unhealthyTicks,
3
);
ElizaAgentWatchdogPolicy.HealthDecision third =
ElizaAgentWatchdogPolicy.evaluateHealthProbe(
ElizaAgentWatchdogPolicy.ProbeResult.DEAD,
second.unhealthyTicks,
3
);
assertEquals(1, first.unhealthyTicks);
assertFalse(first.restartRequired);
assertEquals(2, second.unhealthyTicks);
assertFalse(second.restartRequired);
assertEquals(0, third.unhealthyTicks);
assertTrue(third.restartRequired);
}
@Test
public void restartPolicyUsesBoundedExponentialBackoff() {
long[] expectedDelays = {1000L, 2000L, 4000L, 8000L, 16000L};
int attempts = 0;
for (long expectedDelay : expectedDelays) {
ElizaAgentWatchdogPolicy.RestartDecision decision =
ElizaAgentWatchdogPolicy.nextRestart(attempts, 5);
assertTrue(decision.allowed);
assertEquals(expectedDelay, decision.delayMs);
assertEquals(attempts + 1, decision.nextRestartAttempts);
attempts = decision.nextRestartAttempts;
}
ElizaAgentWatchdogPolicy.RestartDecision fatal =
ElizaAgentWatchdogPolicy.nextRestart(attempts, 5);
assertFalse(fatal.allowed);
assertEquals(5, fatal.nextRestartAttempts);
assertEquals(0L, fatal.delayMs);
}
@Test
public void foregroundStartDenialMatchesOnlyTheAndroid12Denial() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// The real framework exception (API 31+): the exact throwable
// Service.startForeground() raises on a denied background start.
assertTrue(ElizaAgentWatchdogPolicy.isForegroundStartDenial(
new ForegroundServiceStartNotAllowedException("denied")
));
}
// Plain IllegalStateException (the pre-31 startForeground failure
// shape, and any unrelated state bug) must NOT be swallowed.
assertFalse(ElizaAgentWatchdogPolicy.isForegroundStartDenial(
new IllegalStateException(
"Service.startForeground() not allowed due to mAllowStartForeground false"
)
));
assertFalse(ElizaAgentWatchdogPolicy.isForegroundStartDenial(
new RuntimeException("unrelated")
));
assertFalse(ElizaAgentWatchdogPolicy.isForegroundStartDenial(null));
}
}
@@ -0,0 +1,131 @@
package ai.elizaos.app;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.content.pm.ServiceInfo;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.test.ext.junit.runners.AndroidJUnit4;
import java.util.List;
/**
* Retail-path (non-{@code assumeSystemEliza}) assertions that Eliza's Android
* assistant surfaces survived install-time manifest parsing on ANY debug build.
*
* The pre-existing {@code ElizaOsInstrumentedTest} only asserts held default
* roles, and every one of its cases {@code assumeSystemEliza()}-gates to the
* {@code /system/priv-app/Eliza/} AOSP APK — so on a normal sideload/CI APK it
* vacuously assume-skips and the {@code :app:} androidTest run proves nothing.
* This class fixes that (#13581): its assertions run on the plain debug APK the
* emulator installs, so wiring {@code :app:connectedDebugAndroidTest} into CI
* is not green-by-skip.
*
* These are the checks that regress when a manifest edit or a missing paired
* service (e.g. a VoiceInteractionService whose recognitionService is absent —
* the framework then rejects it and it never appears as an assistant candidate)
* silently drops a surface. The lane's device-side adb scrape (dumpsys/cmd)
* covers the *runtime* role/IME selection; this covers the *declaration* that
* must exist before any of that can work.
*/
@RunWith(AndroidJUnit4.class)
public class ElizaAssistantSurfaceInstrumentedTest {
private static final String PACKAGE_NAME = "ai.elizaos.app";
private Context context() {
return InstrumentationRegistry.getInstrumentation().getTargetContext();
}
private ServiceInfo declaredService(String className) throws Exception {
ComponentName component = new ComponentName(PACKAGE_NAME, className);
// GET_META_DATA so the parse of the service's <meta-data> (voice
// interaction / input-method descriptors) is exercised too — a broken
// descriptor throws NameNotFoundException here.
return context().getPackageManager()
.getServiceInfo(component, PackageManager.GET_META_DATA);
}
@Test
public void voiceInteractionServiceIsDeclaredAndBindGuarded() throws Exception {
ServiceInfo info = declaredService("ai.elizaos.app.ElizaVoiceInteractionService");
assertNotNull("ElizaVoiceInteractionService must be declared", info);
assertTrue("VIS must be exported so the framework can bind it", info.exported);
assertTrue(
"VIS must be guarded by BIND_VOICE_INTERACTION",
"android.permission.BIND_VOICE_INTERACTION".equals(info.permission));
}
@Test
public void pairedSessionAndRecognitionServicesAreDeclared() throws Exception {
// The VoiceInteractionServiceInfo parser rejects the VIS unless BOTH the
// session and recognition services it references exist; asserting they
// are present is asserting the VIS could parse at all.
assertNotNull(declaredService("ai.elizaos.app.ElizaVoiceInteractionSessionService"));
assertNotNull(declaredService("ai.elizaos.app.ElizaRecognitionService"));
}
@Test
public void voiceImeIsDeclaredAndBindGuarded() throws Exception {
ServiceInfo info = declaredService("ai.elizaos.app.ElizaVoiceInputMethodService");
assertNotNull("ElizaVoiceInputMethodService must be declared", info);
assertTrue("IME must be exported", info.exported);
assertTrue(
"IME must be guarded by BIND_INPUT_METHOD",
"android.permission.BIND_INPUT_METHOD".equals(info.permission));
}
@Test
public void voiceImeResolvesForTheInputMethodAction() {
Intent imeIntent = new Intent("android.view.InputMethod");
List<ResolveInfo> resolved = context().getPackageManager()
.queryIntentServices(imeIntent, 0);
boolean elizaImePresent = false;
for (ResolveInfo candidate : resolved) {
if (candidate.serviceInfo != null
&& PACKAGE_NAME.equals(candidate.serviceInfo.packageName)
&& "ai.elizaos.app.ElizaVoiceInputMethodService"
.equals(candidate.serviceInfo.name)) {
elizaImePresent = true;
break;
}
}
assertTrue(
"Eliza voice IME must resolve for android.view.InputMethod",
elizaImePresent);
}
@Test
public void assistActivityResolvesTheAssistAction() {
// ACTION_ASSIST must resolve to Eliza's fallback assist activity (the OEM
// direct-intent path that stays valid whether or not the role is held).
Intent assist = new Intent(Intent.ACTION_ASSIST);
assist.setPackage(PACKAGE_NAME);
ResolveInfo resolved = context().getPackageManager()
.resolveActivity(assist, 0);
assertNotNull("ACTION_ASSIST must resolve within Eliza", resolved);
assertTrue(
"ACTION_ASSIST must route to ElizaAssistActivity",
resolved.activityInfo != null
&& "ai.elizaos.app.ElizaAssistActivity"
.equals(resolved.activityInfo.name));
}
@Test
public void voiceCommandActionResolvesWithinEliza() {
Intent voiceCommand = new Intent(Intent.ACTION_VOICE_COMMAND);
voiceCommand.setPackage(PACKAGE_NAME);
ResolveInfo resolved = context().getPackageManager()
.resolveActivity(voiceCommand, 0);
assertNotNull("ACTION_VOICE_COMMAND must resolve within Eliza", resolved);
}
}
@@ -0,0 +1,162 @@
package ai.elizaos.app;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertTrue;
import static org.junit.Assume.assumeTrue;
import android.Manifest;
import android.app.role.RoleManager;
import android.content.ComponentName;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.content.pm.ResolveInfo;
import android.os.Build;
import android.provider.Settings;
import android.provider.Telephony;
import android.telecom.TelecomManager;
import androidx.test.platform.app.InstrumentationRegistry;
import org.junit.Test;
import org.junit.runner.RunWith;
import androidx.test.ext.junit.runners.AndroidJUnit4;
@RunWith(AndroidJUnit4.class)
public class ElizaOsInstrumentedTest {
private static final String PACKAGE_NAME = "ai.elizaos.app";
private static final String[] REQUIRED_PERMISSIONS = {
Manifest.permission.READ_CONTACTS,
Manifest.permission.WRITE_CONTACTS,
Manifest.permission.CALL_PHONE,
Manifest.permission.READ_PHONE_STATE,
Manifest.permission.ANSWER_PHONE_CALLS,
Manifest.permission.READ_CALL_LOG,
Manifest.permission.WRITE_CALL_LOG,
Manifest.permission.READ_SMS,
Manifest.permission.SEND_SMS,
Manifest.permission.RECEIVE_SMS,
Manifest.permission.RECEIVE_MMS,
Manifest.permission.RECEIVE_WAP_PUSH,
Manifest.permission.POST_NOTIFICATIONS,
};
private static final String[] FORBIDDEN_PACKAGES = {
"com.android.contacts",
"com.android.dialer",
"com.android.launcher3",
"com.android.messaging",
"com.google.android.apps.messaging",
"com.google.android.apps.nexuslauncher",
"com.google.android.dialer",
"org.lineageos.trebuchet",
};
private Context context() {
return InstrumentationRegistry.getInstrumentation().getTargetContext();
}
private ApplicationInfo appInfo() throws PackageManager.NameNotFoundException {
Context context = context();
return context.getPackageManager().getApplicationInfo(context.getPackageName(), 0);
}
private String resolveHomePackage() {
Intent home = new Intent(Intent.ACTION_MAIN);
home.addCategory(Intent.CATEGORY_HOME);
ResolveInfo resolved = context().getPackageManager()
.resolveActivity(home, PackageManager.MATCH_DEFAULT_ONLY);
assertNotNull(resolved);
return resolved.activityInfo.packageName;
}
private String resolveDialerPackage() {
TelecomManager telecomManager = (TelecomManager) context().getSystemService(Context.TELECOM_SERVICE);
assertNotNull(telecomManager);
return telecomManager.getDefaultDialerPackage();
}
private String resolveSmsPackage() {
return Telephony.Sms.getDefaultSmsPackage(context());
}
private String resolveAssistantPackage() {
String assistant = Settings.Secure.getString(context().getContentResolver(), "assistant");
assertNotNull(assistant);
ComponentName componentName = ComponentName.unflattenFromString(assistant);
assertNotNull(componentName);
return componentName.getPackageName();
}
private void assumeSystemEliza() throws PackageManager.NameNotFoundException {
ApplicationInfo info = appInfo();
assumeTrue("ElizaOS tests only run for the system privileged Eliza APK",
info.sourceDir != null && info.sourceDir.startsWith("/system/priv-app/Eliza/"));
}
@Test
public void packageIsPrivilegedSystemEliza() throws Exception {
assumeSystemEliza();
ApplicationInfo info = appInfo();
assertEquals(PACKAGE_NAME, context().getPackageName());
assertTrue((info.flags & ApplicationInfo.FLAG_SYSTEM) != 0);
assertTrue(info.sourceDir.startsWith("/system/priv-app/Eliza/"));
}
@Test
public void homeIntentResolvesToEliza() throws Exception {
assumeSystemEliza();
Intent home = new Intent(Intent.ACTION_MAIN);
home.addCategory(Intent.CATEGORY_HOME);
assertNotNull(context().getPackageManager().resolveActivity(home, PackageManager.MATCH_DEFAULT_ONLY));
assertEquals(PACKAGE_NAME, resolveHomePackage());
}
@Test
public void elizaHoldsAndroidDefaultRoles() throws Exception {
assumeSystemEliza();
assumeTrue(Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q);
RoleManager roleManager = (RoleManager) context().getSystemService(Context.ROLE_SERVICE);
assertNotNull(roleManager);
assertTrue(RoleManager.ROLE_HOME + " must be available", roleManager.isRoleAvailable(RoleManager.ROLE_HOME));
assertTrue(RoleManager.ROLE_DIALER + " must be available", roleManager.isRoleAvailable(RoleManager.ROLE_DIALER));
assertTrue(RoleManager.ROLE_SMS + " must be available", roleManager.isRoleAvailable(RoleManager.ROLE_SMS));
assertTrue(RoleManager.ROLE_ASSISTANT + " must be available", roleManager.isRoleAvailable(RoleManager.ROLE_ASSISTANT));
assertEquals(PACKAGE_NAME, resolveHomePackage());
assertEquals(PACKAGE_NAME, resolveDialerPackage());
assertEquals(PACKAGE_NAME, resolveSmsPackage());
assertEquals(PACKAGE_NAME, resolveAssistantPackage());
}
@Test
public void defaultPermissionsAreGranted() throws Exception {
assumeSystemEliza();
for (String permission : REQUIRED_PERMISSIONS) {
assertEquals(
permission,
PackageManager.PERMISSION_GRANTED,
context().checkSelfPermission(permission)
);
}
}
@Test
public void stockPhoneAppsAreNotInstalled() throws Exception {
assumeSystemEliza();
PackageManager packageManager = context().getPackageManager();
for (String packageName : FORBIDDEN_PACKAGES) {
try {
packageManager.getPackageInfo(packageName, 0);
throw new AssertionError(packageName + " is still installed");
} catch (PackageManager.NameNotFoundException expected) {
assertTrue(true);
}
}
}
}
@@ -0,0 +1,506 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<queries>
<package android:name="com.google.android.apps.healthdata" />
<!-- Android 11+ package visibility: expose the system speech
RecognitionService so native voice capture (TalkMode SpeechRecognizer)
can resolve + bind to it for on-device STT. -->
<intent>
<action android:name="android.speech.RecognitionService" />
</intent>
</queries>
<uses-feature
android:name="android.hardware.telephony"
android:required="false" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:usesCleartextTraffic="true"
android:theme="@style/AppTheme">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:name=".MainActivity"
android:label="@string/title_activity_main"
android:theme="@style/AppTheme.NoActionBarLaunch"
android:launchMode="singleTask"
android:exported="true">
<meta-data
android:name="android.app.shortcuts"
android:resource="@xml/shortcuts" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!--
Declares MainActivity as a home-screen candidate so Eliza can be
selected as the device default launcher (ROLE_HOME). Required for
AOSP / sideload full-device takeovers (e.g. Light Phone 3) where
Eliza replaces the stock launcher; on Play/retail builds the user
still explicitly grants the Home role.
-->
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.HOME" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="@string/custom_url_scheme" />
</intent-filter>
</activity>
<!--
Continuous-chat foreground service (R10 §6.2). Owns the long-
lived microphone capture path so VAD-gated / always-on chat
survives screen lock + backgrounding. foregroundServiceType
"microphone" is required on API 34+ alongside the
FOREGROUND_SERVICE_MICROPHONE permission.
-->
<service
android:name=".ElizaVoiceCaptureService"
android:exported="false"
android:foregroundServiceType="microphone" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths"></meta-data>
</provider>
<!--
Digital-assistant (ROLE_ASSISTANT) integration. Declaring a
VoiceInteractionService is what surfaces Eliza under Settings ->
Apps -> Default apps -> Digital assistant app on retail devices and
lets the assist gesture / long-press-power invoke Eliza over the
current app; the session renders a native voice bar then routes into
the app via elizaos://voice?source=android-assistant-session. The
ElizaAssistActivity above stays as the ACTION_ASSIST fallback for OEM
flows that still deliver the assist intent directly (when the role is
held the framework prefers this VoiceInteractionService session). The
framework rejects the service unless BOTH the session and recognition
services in @xml/eliza_voice_interaction_service are declared, so all
three are declared together. Runtime role request lives in the
@elizaos/capacitor-system bridge (System.requestRole assistant); the
AOSP ROM glue that pre-grants the role for these components is
follow-up sub-issue 6.
-->
<service
android:name="ai.elizaos.app.ElizaVoiceInteractionService"
android:exported="true"
android:label="@string/app_name"
android:permission="android.permission.BIND_VOICE_INTERACTION">
<meta-data
android:name="android.voice_interaction"
android:resource="@xml/eliza_voice_interaction_service" />
<intent-filter>
<action android:name="android.service.voice.VoiceInteractionService" />
</intent-filter>
</service>
<service
android:name="ai.elizaos.app.ElizaVoiceInteractionSessionService"
android:exported="true"
android:permission="android.permission.BIND_VOICE_INTERACTION" />
<service
android:name="ai.elizaos.app.ElizaRecognitionService"
android:exported="true"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.speech.RecognitionService" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<meta-data
android:name="android.speech"
android:resource="@xml/eliza_recognition_service" />
</service>
<!--
Voice-input keyboard (IME, FUTO Voice Input pattern). Declaring an
InputMethodService with a voice-mode subtype (@xml/method) surfaces
Eliza under Settings -> System -> Languages & input -> On-screen
keyboard and lets other keyboards' mic long-press hand off to Eliza.
BIND_INPUT_METHOD is the system-held bind permission; the
android.view.im metadata declares the subtype + switch-back support.
Android IMEs may record audio (unlike iOS extensions), so this
keyboard records directly and transcribes via the on-device engine's
loopback ASR route, deep-linking into the app
(elizaos://voice?source=android-ime) when the engine isn't running.
-->
<service
android:name="ai.elizaos.app.ElizaVoiceInputMethodService"
android:exported="true"
android:label="@string/eliza_ime_label"
android:permission="android.permission.BIND_INPUT_METHOD">
<intent-filter>
<action android:name="android.view.InputMethod" />
</intent-filter>
<meta-data
android:name="android.view.im"
android:resource="@xml/method" />
</service>
<service
android:name="ai.elizaos.app.GatewayConnectionService"
android:exported="false"
android:foregroundServiceType="dataSync" />
<service
android:name="ai.elizaos.app.ElizaAgentService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="local-agent-runtime" />
</service>
<activity
android:name="ai.elizaos.app.ElizaDialActivity"
android:exported="true"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DIAL" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="tel" />
</intent-filter>
</activity>
<activity
android:name="ai.elizaos.app.ElizaAssistActivity"
android:exported="true"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.ASSIST" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VOICE_COMMAND" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="ai.elizaos.app.ElizaShareActivity"
android:exported="true"
android:label="@string/app_action_smart_reply_long"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.PROCESS_TEXT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<service
android:name="ai.elizaos.app.ElizaVoiceTileService"
android:exported="true"
android:icon="@mipmap/ic_launcher_monochrome"
android:label="@string/app_action_voice_long"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
<meta-data
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
android:value="false" />
</service>
<receiver
android:name="ai.elizaos.app.ElizaQuickActionsWidgetProvider"
android:exported="true">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/eliza_quick_actions_widget" />
</receiver>
<service
android:name="ai.elizaos.app.ElizaAccessibilityService"
android:exported="true"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService" />
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/eliza_accessibility_service" />
</service>
<service
android:name="ai.elizaos.app.ElizaNotificationListenerService"
android:exported="true"
android:permission="android.permission.BIND_NOTIFICATION_LISTENER_SERVICE">
<intent-filter>
<action android:name="android.service.notification.NotificationListenerService" />
</intent-filter>
</service>
<service
android:name="ai.elizaos.app.ElizaInCallService"
android:exported="true"
android:permission="android.permission.BIND_INCALL_SERVICE">
<meta-data
android:name="android.telecom.IN_CALL_SERVICE_UI"
android:value="true" />
<meta-data
android:name="android.telecom.IN_CALL_SERVICE_RINGING"
android:value="true" />
<intent-filter>
<action android:name="android.telecom.InCallService" />
</intent-filter>
</service>
<receiver
android:name="ai.elizaos.app.ElizaSmsReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_SMS">
<intent-filter>
<action android:name="android.provider.Telephony.SMS_DELIVER" />
</intent-filter>
</receiver>
<receiver
android:name="ai.elizaos.app.ElizaMmsReceiver"
android:exported="true"
android:permission="android.permission.BROADCAST_WAP_PUSH">
<intent-filter>
<action android:name="android.provider.Telephony.WAP_PUSH_DELIVER" />
<data android:mimeType="application/vnd.wap.mms-message" />
</intent-filter>
</receiver>
<service
android:name="ai.elizaos.app.ElizaSmsGatewayService"
android:exported="false" />
<service
android:name="ai.elizaos.app.ElizaRespondViaMessageService"
android:exported="true"
android:permission="android.permission.SEND_RESPOND_VIA_MESSAGE">
<intent-filter>
<action android:name="android.intent.action.RESPOND_VIA_MESSAGE" />
<data android:scheme="sms" />
<data android:scheme="smsto" />
<data android:scheme="mms" />
<data android:scheme="mmsto" />
</intent-filter>
</service>
<activity
android:name="ai.elizaos.app.ElizaSmsComposeActivity"
android:exported="true"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.SENDTO" />
<category android:name="android.intent.category.DEFAULT" />
<data android:scheme="sms" />
<data android:scheme="smsto" />
<data android:scheme="mms" />
<data android:scheme="mmsto" />
</intent-filter>
</activity>
<receiver
android:name="ai.elizaos.app.ElizaBootReceiver"
android:directBootAware="true"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
<activity
android:name="ai.elizaos.app.ElizaBrowserActivity"
android:exported="true"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.WEB_SEARCH" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="ai.elizaos.app.ElizaContactsActivity"
android:exported="true"
android:label="Contacts"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.dir/contact" />
<data android:mimeType="vnd.android.cursor.dir/person" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/contact" />
<data android:mimeType="vnd.android.cursor.item/person" />
</intent-filter>
</activity>
<activity
android:name="ai.elizaos.app.ElizaCameraActivity"
android:exported="true"
android:label="Camera"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.media.action.STILL_IMAGE_CAMERA" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.action.IMAGE_CAPTURE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.media.action.VIDEO_CAPTURE" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="ai.elizaos.app.ElizaClockActivity"
android:exported="true"
android:label="Clock"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.SET_ALARM" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SHOW_ALARMS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SET_TIMER" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SHOW_TIMERS" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.DISMISS_ALARM" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
</activity>
<activity
android:name="ai.elizaos.app.ElizaCalendarActivity"
android:exported="true"
android:label="Calendar"
android:theme="@style/AppTheme.NoActionBar">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.INSERT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.dir/event" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.EDIT" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="vnd.android.cursor.item/event" />
</intent-filter>
</activity>
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- MODIFY_AUDIO_SETTINGS: communication-mode routing + speakerphone for the
hands-free voice loop (engages the platform AEC). See TalkModePlugin. -->
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.RECEIVE_MMS" />
<uses-permission android:name="android.permission.RECEIVE_WAP_PUSH" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!-- BLE pendant (omi DevKit1) via @capacitor-community/bluetooth-le.
Android 12+ (API 31+) uses the runtime BLUETOOTH_SCAN / BLUETOOTH_CONNECT
permissions instead of legacy location. usesPermissionFlags="neverForLocation"
asserts the scan is NOT used to derive physical location, so the user is
NOT prompted for location on the Light Phone III (Android 13+). The legacy
BLUETOOTH / BLUETOOTH_ADMIN perms are gated to maxSdkVersion=30 for older
devices only. See NativeBlePendantTransport + PENDANT-ANDROID-REPORT.md. -->
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission
android:name="android.permission.BLUETOOTH"
android:maxSdkVersion="30" />
<uses-permission
android:name="android.permission.BLUETOOTH_ADMIN"
android:maxSdkVersion="30" />
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="false" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission
android:name="android.permission.READ_EXTERNAL_STORAGE"
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<uses-permission android:name="android.permission.CALL_PHONE" />
<uses-permission android:name="android.permission.READ_PHONE_STATE" />
<uses-permission android:name="android.permission.ANSWER_PHONE_CALLS" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS" />
<uses-permission android:name="android.permission.READ_CALL_LOG" />
<uses-permission android:name="android.permission.WRITE_CALL_LOG" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.PACKAGE_USAGE_STATS" />
<uses-permission android:name="android.permission.MANAGE_APP_OPS_MODES" />
<uses-permission android:name="android.permission.MANAGE_VIRTUAL_MACHINE" />
<uses-permission android:name="android.permission.READ_FRAME_BUFFER" />
<uses-permission android:name="android.permission.INJECT_EVENTS" />
<uses-permission android:name="android.permission.REAL_GET_TASKS" />
</manifest>
@@ -0,0 +1,177 @@
/*
* Eliza background runner — fired by Capacitor BackgroundRunner inside a
* separate JSContext (iOS QuickJS, Android V8) per wake event.
*
* Sandboxed env: no window, no localStorage, no IndexedDB, no cookied fetch.
* Available globals: setTimeout, Promise, console, fetch (no cookies),
* CapacitorBackgroundRunner.addEventListener.
*
* Contract:
* args: {
* kind: 'refresh' | 'processing',
* deadlineSec: number,
* deviceSecret: string,
* agentBase: string, // e.g. 'http://127.0.0.1:31337'
* }
*
* Resolves with { delivered: true, ...result } on success; rejects with
* { delivered: false, error } on failure or deadline.
*
* The host app's main webview (FGS on Android, foreground process on iOS)
* owns the long-lived AgentRuntime; this runner pokes it via loopback HTTP
* and returns. Every wake-up runs once and then the JSContext is suspended.
*/
addEventListener("wake", (resolve, reject, args) => {
handleWake(args).then(resolve, reject);
});
/**
* Entry point exposed for unit testing. The addEventListener handler above
* is the production binding; this function is the testable body.
*
* @param {{kind: 'refresh' | 'processing', deadlineSec: number, deviceSecret: string, agentBase: string}} args
* @returns {Promise<{delivered: boolean, ranTasks?: number, durationMs?: number, lastWakeFiredAt?: number}>}
*/
function handleWake(args) {
const kind = args?.kind;
const deadlineSec = args && Number(args.deadlineSec);
const deviceSecret = args?.deviceSecret;
const agentBase = args?.agentBase;
if (kind !== "refresh" && kind !== "processing") {
return Promise.reject({
delivered: false,
error: 'invalid args: kind must be "refresh" or "processing"',
});
}
if (!Number.isFinite(deadlineSec) || deadlineSec <= 0) {
return Promise.reject({
delivered: false,
error: "invalid args: deadlineSec must be a positive number",
});
}
if (typeof deviceSecret !== "string" || deviceSecret.length === 0) {
return Promise.reject({
delivered: false,
error: "invalid args: deviceSecret must be a non-empty string",
});
}
if (typeof agentBase !== "string" || agentBase.length === 0) {
return Promise.reject({
delivered: false,
error: "invalid args: agentBase must be a non-empty string",
});
}
// OS gives ~30s on iOS, more on Android. Leave 2-3s buffer so the POST
// body can flush and we can resolve before the OS kills the JSContext.
var BUFFER_MS = 2500;
var hardDeadlineMs = Math.max(1000, deadlineSec * 1000 - BUFFER_MS);
var startedAt = Date.now();
var wakeDeadlineAt = startedAt + hardDeadlineMs;
if (!isHttpUrlBase(agentBase)) {
if (isLocalAgentBase(agentBase)) {
return Promise.resolve({
delivered: true,
skipped: true,
reason: "android_agent_service_ipc_unavailable_in_background_jscontext",
durationMs: Date.now() - startedAt,
lastWakeFiredAt: Date.now(),
});
}
return Promise.reject({
delivered: false,
error: "invalid args: agentBase must be http(s) or local IPC",
});
}
var url = `${trimTrailingSlash(agentBase)}/api/internal/wake`;
// Wrap in Promise.resolve().then so that a synchronous throw inside the
// sandboxed fetch() (rare, but observed under hostile mocks and the iOS
// QuickJS network stack when no route is registered) becomes a rejection
// we can race against the deadline.
var workPromise = Promise.resolve()
.then(() =>
fetch(url, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${deviceSecret}`,
},
body: JSON.stringify({
kind: kind,
deadlineMs: wakeDeadlineAt,
}),
}),
)
.then((response) => {
if (!response?.ok) {
const status = response ? response.status : "no-response";
throw new Error(`wake POST failed: status=${status}`);
}
return response.json();
});
var deadlinePromise = new Promise((_resolve, reject) => {
setTimeout(() => {
reject(new Error(`wake deadline reached after ${hardDeadlineMs}ms`));
}, hardDeadlineMs);
});
return Promise.race([workPromise, deadlinePromise])
.then((result) => {
var safe = result && typeof result === "object" ? result : {};
return {
delivered: true,
ranTasks: typeof safe.ranTasks === "number" ? safe.ranTasks : 0,
durationMs:
typeof safe.durationMs === "number"
? safe.durationMs
: Date.now() - startedAt,
lastWakeFiredAt:
typeof safe.lastWakeFiredAt === "number"
? safe.lastWakeFiredAt
: Date.now(),
};
})
.catch((error) => {
var msg = error?.message
? error.message
: typeof error === "string"
? error
: "unknown error";
console.error(`[eliza-tasks] wake failed: ${msg}`);
return Promise.reject({ delivered: false, error: msg });
});
}
function trimTrailingSlash(value) {
if (typeof value !== "string") return value;
return value.charAt(value.length - 1) === "/"
? value.substring(0, value.length - 1)
: value;
}
function isHttpUrlBase(base) {
if (!base || typeof base !== "string") return false;
return /^https?:\/\//i.test(base.trim());
}
function isLocalAgentBase(base) {
if (!base || typeof base !== "string") return false;
var normalized = trimTrailingSlash(base.trim()).toLowerCase();
return (
normalized === "eliza-local-agent://ipc" ||
normalized === "http://127.0.0.1:31337" ||
normalized === "http://localhost:31337"
);
}
// Export `handleWake` to the global scope so unit tests can exercise it
// without depending on the Capacitor addEventListener binding. In the real
// runner this is harmless — the JSContext is recreated per wake.
if (typeof globalThis !== "undefined") {
globalThis.handleWake = handleWake;
}
@@ -0,0 +1,100 @@
cmake_minimum_required(VERSION 3.22)
project(elizavoice-jni)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# Phase 3a JNI host: a tiny bionic .so (built by the app module's NDK
# externalNativeBuild) that links the prebuilt, self-contained
# `libelizainference.so` (the fused fork omnivoice `elizainference` target,
# ABI v7, all four voice runtimes) and exposes a couple of native methods to
# the ai.elizaos.app Capacitor process. This proves the fused voice .so loads
# + runs IN-PROCESS in the normal APK (bionic), replacing the musl bun-agent
# path for the voice runtimes.
#
# Layout (mirrors llama-cpp-capacitor's IMPORTED-prebuilt pattern):
# - libelizainference.so is staged into src/main/jniLibs/${ANDROID_ABI}
# by stage-elizavoice-lib.mjs (cross-built via the NDK from the fork's
# tools/omnivoice, statically linking ggml/llama/mtmd so it has NO
# external NEEDED deps beyond bionic libc/libm/libdl).
# - the FFI header lives in the fork tree; we point at it via
# ELIZAVOICE_FFI_INCLUDE_DIR (passed from build.gradle) with a sane
# in-tree fallback.
if(NOT ANDROID_ABI STREQUAL "arm64-v8a")
# Phase 3a only validates arm64-v8a. Other ABIs get a no-op stub so the
# multi-ABI app build does not fail before the per-ABI .so is staged.
file(WRITE "${CMAKE_CURRENT_BINARY_DIR}/elizavoice-stub.cpp"
"extern \"C\" int elizavoice_jni_stub() { return 0; }\n")
add_library(elizavoicejni SHARED "${CMAKE_CURRENT_BINARY_DIR}/elizavoice-stub.cpp")
find_library(LOG_LIB log)
target_link_libraries(elizavoicejni PRIVATE ${LOG_LIB})
message(STATUS "[elizavoice-jni] building no-op stub for ${ANDROID_ABI} (Phase 3a: arm64-v8a only)")
return()
endif()
# Resolve the fused-fork FFI header. build.gradle passes the absolute repo
# path; fall back to the conventional in-repo submodule path so a standalone
# cmake invocation still configures.
if(NOT DEFINED ELIZAVOICE_FFI_INCLUDE_DIR OR ELIZAVOICE_FFI_INCLUDE_DIR STREQUAL "")
set(ELIZAVOICE_FFI_INCLUDE_DIR
"${CMAKE_CURRENT_SOURCE_DIR}/../../../../../../../plugins/plugin-local-inference/native/llama.cpp/tools/omnivoice/include")
endif()
if(NOT EXISTS "${ELIZAVOICE_FFI_INCLUDE_DIR}/eliza-inference-ffi.h")
message(FATAL_ERROR
"[elizavoice-jni] eliza-inference-ffi.h not found under "
"${ELIZAVOICE_FFI_INCLUDE_DIR}; pass -DELIZAVOICE_FFI_INCLUDE_DIR=<fork>/tools/omnivoice/include")
endif()
# The prebuilt fused voice library, staged into jniLibs by
# stage-elizavoice-lib.mjs before the native build runs.
set(ELIZAVOICE_PREBUILT_SO
"${CMAKE_CURRENT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libelizainference.so")
if(NOT EXISTS "${ELIZAVOICE_PREBUILT_SO}")
message(FATAL_ERROR
"[elizavoice-jni] prebuilt libelizainference.so missing at "
"${ELIZAVOICE_PREBUILT_SO}; run "
"packages/app-core/scripts/stage-elizavoice-lib.mjs first.")
endif()
add_library(elizainference SHARED IMPORTED)
set_target_properties(elizainference PROPERTIES
IMPORTED_LOCATION "${ELIZAVOICE_PREBUILT_SO}")
# The DYNAMIC-Vulkan variant of libelizainference.so links its ggml/llama/mtmd
# backends as separate .so (so the GPU backend libggml-vulkan.so can dlopen the
# device's libvulkan.so at runtime — only possible in the bionic app process,
# never the musl agent). Import each dep that is staged alongside so the
# elizavoicejni link resolves libelizainference's DT_NEEDED; the bionic loader
# pulls them in when System.loadLibrary("elizainference") runs. A CPU-only
# static-fused libelizainference.so has none of these siblings, so each import
# is guarded on the file existing — both variants build from this one list.
set(ELIZAVOICE_LIB_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}")
set(ELIZAVOICE_DEP_TARGETS "")
foreach(dep ggml ggml-base ggml-cpu ggml-vulkan llama llama-common mtmd)
if(EXISTS "${ELIZAVOICE_LIB_DIR}/lib${dep}.so")
add_library(eliza_dep_${dep} SHARED IMPORTED)
set_target_properties(eliza_dep_${dep} PROPERTIES
IMPORTED_LOCATION "${ELIZAVOICE_LIB_DIR}/lib${dep}.so")
list(APPEND ELIZAVOICE_DEP_TARGETS eliza_dep_${dep})
endif()
endforeach()
find_library(LOG_LIB log)
add_library(elizavoicejni SHARED elizavoice-jni.cpp)
target_include_directories(elizavoicejni PRIVATE "${ELIZAVOICE_FFI_INCLUDE_DIR}")
target_compile_options(elizavoicejni PRIVATE -O2 -DNDEBUG)
# --allow-shlib-undefined: libggml-vulkan.so NEEDs the device's libvulkan.so,
# which isn't a link input (it resolves from /system/lib64 at runtime in the
# app process). The shim never calls vulkan symbols directly, so leaving that
# transitive NEEDED unresolved at link time is correct.
target_link_libraries(elizavoicejni PRIVATE
elizainference ${ELIZAVOICE_DEP_TARGETS} ${LOG_LIB})
target_link_options(elizavoicejni PRIVATE "-Wl,--allow-shlib-undefined")
message(STATUS "[elizavoice-jni] building bionic JNI bridge for ${ANDROID_ABI}")
message(STATUS "[elizavoice-jni] FFI header: ${ELIZAVOICE_FFI_INCLUDE_DIR}")
message(STATUS "[elizavoice-jni] prebuilt voice .so: ${ELIZAVOICE_PREBUILT_SO}")
@@ -0,0 +1,181 @@
package ai.elizaos.app;
import com.getcapacitor.JSObject;
import com.getcapacitor.Plugin;
import com.getcapacitor.PluginCall;
import com.getcapacitor.PluginMethod;
import com.getcapacitor.annotation.CapacitorPlugin;
import org.json.JSONException;
import org.json.JSONObject;
import java.io.IOException;
import java.util.Iterator;
@CapacitorPlugin(name = "Agent")
public class AgentPlugin extends Plugin {
@PluginMethod
public void start(PluginCall call) {
try {
ElizaAgentService.start(getContext());
call.resolve(status("starting"));
} catch (RuntimeException e) {
call.reject("Failed to start local agent service", e);
}
}
@PluginMethod
public void stop(PluginCall call) {
try {
ElizaAgentService.stop(getContext());
JSObject result = new JSObject();
result.put("ok", true);
call.resolve(result);
} catch (RuntimeException e) {
call.reject("Failed to stop local agent service", e);
}
}
@PluginMethod
public void getStatus(PluginCall call) {
String token = ElizaAgentService.localAgentToken(getContext());
call.resolve(status(token == null || token.trim().isEmpty() ? "starting" : "running"));
}
@PluginMethod
public void getLocalAgentBootState(PluginCall call) {
try {
call.resolve(toJsObject(ElizaAgentService.getLocalAgentBootState(getContext())));
} catch (JSONException e) {
call.reject("Failed to read local agent boot state", e);
}
}
@PluginMethod
public void getLocalAgentToken(PluginCall call) {
JSObject result = new JSObject();
String token = ElizaAgentService.localAgentToken(getContext());
result.put("available", token != null && !token.trim().isEmpty());
result.put("token", token == null || token.trim().isEmpty() ? JSONObject.NULL : token.trim());
call.resolve(result);
}
@PluginMethod
public void request(PluginCall call) {
try {
// Prime the per-process token from the recovery file so requests
// routed by a WebView process that didn't start the agent still
// carry the bearer token.
ElizaAgentService.localAgentToken(getContext());
JSONObject request = new JSONObject();
putIfPresent(request, "method", call.getString("method"));
putIfPresent(request, "path", call.getString("path"));
putIfPresent(request, "body", call.getString("body"));
Integer timeoutMs = call.getInt("timeoutMs");
if (timeoutMs != null) {
request.put("timeoutMs", timeoutMs);
}
JSObject headers = call.getObject("headers");
if (headers != null) {
request.put("headers", headers);
}
call.resolve(toJsObject(new JSONObject(ElizaAgentService.requestLocalAgent(request.toString()))));
} catch (IllegalArgumentException e) {
call.reject(e.getMessage(), e);
} catch (IOException e) {
call.reject("Local agent request failed", e);
} catch (JSONException e) {
call.reject("Local agent returned an invalid response", e);
}
}
/**
* Streaming variant of {@link #request}. Resolves immediately with a
* {@code streamId}, then pushes the loopback response incrementally as
* {@code agentStream*} Capacitor events tagged with that id — so SSE token
* frames reach the WebView as they arrive instead of buffering the whole
* body (the chat reply finally streams on mobile). The WebView falls back to
* {@link #request} when this is unavailable or the head never arrives.
*/
@PluginMethod
public void requestStream(PluginCall call) {
final String streamId = java.util.UUID.randomUUID().toString();
try {
// Prime the per-process token (see request()).
ElizaAgentService.localAgentToken(getContext());
JSONObject request = new JSONObject();
putIfPresent(request, "method", call.getString("method"));
putIfPresent(request, "path", call.getString("path"));
putIfPresent(request, "body", call.getString("body"));
Integer timeoutMs = call.getInt("timeoutMs");
if (timeoutMs != null) {
request.put("timeoutMs", timeoutMs);
}
JSObject headers = call.getObject("headers");
if (headers != null) {
request.put("headers", headers);
}
final String requestJson = request.toString();
// Resolve first so the WebView attaches its agentStream* listeners
// for this streamId before the background thread starts emitting.
JSObject ack = new JSObject();
ack.put("streamId", streamId);
call.resolve(ack);
new Thread(() -> ElizaAgentService.requestLocalAgentStream(requestJson, (String eventJson) -> {
try {
JSONObject event = new JSONObject(eventJson);
String type = event.optString("type");
JSObject payload = new JSObject();
payload.put("streamId", streamId);
if ("response".equals(type)) {
payload.put("status", event.optInt("status"));
payload.put("statusText", event.optString("statusText"));
JSONObject h = event.optJSONObject("headers");
payload.put("headers", h != null ? h : new JSONObject());
notifyListeners("agentStreamResponse", payload);
} else if ("chunk".equals(type)) {
payload.put("dataBase64", event.optString("dataBase64"));
notifyListeners("agentStreamChunk", payload);
} else if ("complete".equals(type)) {
if (event.has("error")) {
payload.put("error", event.optString("error"));
}
notifyListeners("agentStreamComplete", payload);
}
} catch (JSONException ignored) {
// A malformed envelope must not kill the stream.
}
})).start();
} catch (JSONException e) {
call.reject("Local agent stream request was invalid", e);
}
}
private static JSObject status(String state) {
JSObject result = new JSObject();
String token = ElizaAgentService.localAgentToken();
result.put("state", state);
result.put("agentName", "eliza");
result.put("port", 31337);
result.put("tokenAvailable", token != null && !token.trim().isEmpty());
return result;
}
private static void putIfPresent(JSONObject target, String key, String value) throws JSONException {
if (value == null) return;
target.put(key, value);
}
private static JSObject toJsObject(JSONObject source) throws JSONException {
JSObject target = new JSObject();
Iterator<String> keys = source.keys();
while (keys.hasNext()) {
String key = keys.next();
target.put(key, source.get(key));
}
return target;
}
}

Some files were not shown because too many files have changed in this diff Show More