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
+191
View File
@@ -0,0 +1,191 @@
# @elizaos/agent
Standalone elizaOS agent + HTTP backend server. Wraps `@elizaos/core`'s `AgentRuntime`, resolves and boots the bundled `@elizaos/plugin-*` set, and serves a local dashboard/control API. This is the package the `eliza-autonomous` binary runs.
## Role
- Consumed by the desktop/mobile shells and CLI as the agent process and backend server. Many subpath exports (`@elizaos/agent/api`, `/runtime`, `/services/*`, `/config/*`, `/security/*`, `/auth/*`) are imported by sibling `@elizaos/plugin-*` packages and the app shell.
- Owns runtime boot, plugin resolution/lifecycle, the HTTP API + route dispatch, character/config loading, trajectory persistence, triggers/scheduling, permission brokering, and the TEE (dstack) boot/key-release path.
Repo-wide conventions (logger-only, ESM, naming, architecture rules, git workflow) live in the root [AGENTS.md](../../AGENTS.md) — not repeated here.
## Layout
```
src/
bin.ts #!/usr/bin/env node entry → cli/index.ts; mobile (android/ios) bootstrap shims
index.ts Public barrel — re-exports api/runtime/services/config/auth/security/triggers
version-resolver.ts Resolves package version (__ELIZA_VERSION__ / package.json / build-info.json)
cli/
index.ts runAutonomousCli() — command dispatch: serve|start|runtime|ios-bridge|android-bridge|benchmark
benchmark.ts Headless benchmark runner (runBenchmark)
runtime/
eliza.ts startEliza() / bootElizaRuntime() / startInCloudMode() — core boot orchestration
eliza-plugin.ts createElizaPlugin() — the "eliza" Plugin (workspace/session providers, lifecycle actions, services)
core-plugins.ts CORE_PLUGINS / BLOCKING_ / DEFERRED_ / OPTIONAL_ / MOBILE_ / ELIZAOS_ANDROID_ plugin name lists
plugin-resolver.ts resolvePlugins() — resolve plugin names → modules; getLastFailedPluginNames()
plugin-collector.ts collectPluginNames(), CHANNEL/OPTIONAL/PROVIDER_PLUGIN_MAP
plugin-lifecycle.ts Plugin install/eject/reinject lifecycle
plugin-role-gating.ts Role-based plugin access gating
roles.ts / roles/ Role definitions and role-resolution helpers
agent-wallets.ts Agent wallet bootstrap and TEE-gated wallet logic
model-resolution.ts Model name resolution helpers
prompt-optimization.ts / prompt-compaction.ts Prompt optimization and compaction strategies
tool-call-cache/ tool-call-cache-wrapper.ts Tool-call result caching layer
first-time-setup.ts First-run initialization logic
load-plugin-from-directory.ts / load-plugin-from-vfs.ts Plugin loading from local dirs and VFS
sandbox-registry.ts / sandbox-character.ts Sandbox plugin registry and character isolation
restart.ts Runtime restart helpers
release-plugin-policy.ts Plugin release-channel gating policy
boot-telemetry.ts / boot-timer.ts Boot timing and telemetry
view-action-affinity.ts View↔action routing affinity
web-search-tools.ts / vault-profile-resolver.ts Miscellaneous runtime helpers
trajectory-*.ts Trajectory persistence / query / internals
conversation-compactor*.ts Conversation summarization/compaction
operations/ vault-bridge.ts (Vault-backed config env resolution), classifier.ts,
cold-strategy.ts, manager.ts, health.ts, health-checks.ts,
reload-hot.ts, repository.ts, types.ts
api/
server.ts startApiServer() — HTTP stack, auth, CORS, WS upgrade, route dispatch
runtime-mode/ mode resolution (local/local-only/cloud/remote), route-visibility gate + remote-mode forwarder run pre-dispatch by every host
dispatch-route.ts dispatchRoute() — maps requests to handlers
*-routes.ts ~38 route modules (agent admin/lifecycle/status, auth, character, memory, models, permissions, registry, etc.)
server-helpers*.ts Auth/conversation/wallet helpers (trusted-local checks, tokens)
server-types.ts Conversation/server/plugin transport types
index.ts api barrel (@elizaos/agent/api)
config/
character-schema.ts CharacterSchema (zod)
config.ts loadElizaConfig() / saveElizaConfig()
plugin-auto-enable.ts Plugin auto-enable resolution
paths.ts resolveUserPath() and state/path helpers
env-vars.ts, schema.ts, model-metadata.ts, owner-contacts.ts
services/ Business-logic services (capability-broker, permissions-registry, config-plugin-manager, plugin-installer/-compiler, relationships-graph, agent-export, shell-execution-router, tee-*, dstack-tee-provider, cove-quote)
actions/ Eliza actions registered by createElizaPlugin (terminal, trigger, contact, settings, plugin, logs, runtime, database, memory, compact-conversation)
providers/ Providers for createElizaPlugin (workspace, admin-trust/-panel, session, rolodex, recent/relevant-conversations, pending-permissions, escalation-trigger, page-scoped-context, ...)
triggers/ runtime.ts (registerTriggerTaskWorker), scheduling.ts, types.ts
auth/ Credential storage + OAuth/Anthropic/OpenAI-Codex flows (account-storage, oauth-flow, refresh-mutex)
security/ access.ts, audit-log.ts, network-policy.ts, mcp-server-config.ts (validateMcpServerConfig)
awareness/ Re-exports AwarenessRegistry from @elizaos/shared
hooks/ loadHooks() / triggerHook() — workspace hook discovery + dispatch
contracts/awareness.ts Local-only awareness contract types
diagnostics/ integration-observability.ts
shared/ workspace-resolution.ts (resolveDefaultAgentWorkspaceDir)
scripts/ build-mobile-bundle.mjs, live-sandbox-smoke.ts, tee-*-smoke.ts, validate-tee-*.mjs
docs/ capability-router-remote-plugins.md, e2b-capability-routing.md, tee-agent-implementation-plan.md
```
## Key exports / surface
- **Binary:** `eliza-autonomous``src/bin.ts``runAutonomousCli()`. Commands: `serve`/`start`, `runtime`, `ios-bridge`, `android-bridge`, `benchmark`.
- **Boot:** `startEliza()`, `bootElizaRuntime()`, `startInCloudMode()` (`runtime/eliza.ts`); `createElizaPlugin()` (`runtime/eliza-plugin.ts`) — the `Plugin` named `"eliza"` registering services (`AgentEventService`, `ElizaCharacterPersistenceService`, `AgentMediaGenerationService`, `PermissionRegistry`), workspace/session/rolodex providers, and the terminal/trigger/contact/settings/plugin/logs/runtime/database/memory/compact actions.
- **HTTP:** `startApiServer()`, `dispatchRoute()`, route handlers (`@elizaos/agent/api`).
- **Plugin sets:** `CORE_PLUGINS`, `BLOCKING_CORE_PLUGINS`, `DEFERRED_CORE_PLUGINS`, `OPTIONAL_CORE_PLUGINS`, `MOBILE_CORE_PLUGINS` (`runtime/core-plugins.ts`); `resolvePlugins()`, `collectPluginNames()`.
- **Config:** `loadElizaConfig`/`saveElizaConfig`, `CharacterSchema`, `resolveUserPath`, `resolveDefaultAgentWorkspaceDir`.
- **Services (named subpaths):** `getCapabilityBroker`/`CapabilityBroker`, `PermissionRegistry`, `runShell` (`services/shell-execution-router.ts`), `resolveRelationshipsGraphService`, TEE helpers (`tee-*`, `dstack-tee-provider`, `cove-quote`).
- Cloud route handlers (`handleCloudRoute`, `handleCloudBillingRoute`, `validateCloudBaseUrl`) are lazy re-exports that dynamically import `@elizaos/plugin-elizacloud`.
## Commands
Run from repo root targeting this package:
```bash
bun run --cwd packages/agent start # bun run src/bin.ts (defaults to `serve`)
bun run --cwd packages/agent dev # bun --hot src/bin.ts
bun run --cwd packages/agent typecheck # tsgo --noEmit -p tsconfig.json
bun run --cwd packages/agent test # vitest run --config vitest.config.ts
bun run --cwd packages/agent lint # biome check --write (curated src subdirs)
bun run --cwd packages/agent lint:check # biome check read-only
bun run --cwd packages/agent format # biome format --write
bun run --cwd packages/agent format:check # biome format read-only
bun run --cwd packages/agent build # build:dist (tsc --noCheck → prepare-package-dist → rewrite imports)
bun run --cwd packages/agent build:mobile # bun scripts/build-mobile-bundle.mjs
bun run --cwd packages/agent build:ios-bun # mobile bundle, --target=ios
bun run --cwd packages/agent test:remote-capabilities
bun run --cwd packages/agent test:sandbox-live
```
`build:docker-dist`, `build:ios-jsc`, `clean`, `pack:dry-run`, `test:remote-capabilities:{docker,cloud-live,provider-live,source-build}` also exist in `package.json`.
## Config / env vars
State and platform:
- `ELIZA_STATE_DIR` — per-user state root (DB, config, logs). `PGLITE_DATA_DIR` / `POSTGRES_URL` for the SQL store.
- `ELIZA_PLATFORM` (`android`/`ios`/…), `ELIZA_BUILD_VARIANT`, `ELIZA_RUNTIME_MODE`, `ELIZA_MOBILE_LOCAL_AGENT`, `ELIZA_DEVICE_BRIDGE_ENABLED`, `ELIZA_LOCAL_LLAMA`.
Cloud + models:
- `ELIZAOS_CLOUD_ENABLED`, `ELIZAOS_CLOUD_API_KEY`, `ELIZAOS_CLOUD_BASE_URL`, `ELIZA_CLOUD_PROVISIONED`.
- Model overrides: `ELIZAOS_CLOUD_{NANO,SMALL,MEDIUM,LARGE,MEGA}_MODEL`, `ELIZAOS_CLOUD_{PLANNER,ACTION_PLANNER,SHOULD_RESPOND,RESPONSE_HANDLER}_MODEL`.
- Provider keys: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`.
Capability router (remote plugins — see `docs/capability-router-remote-plugins.md`):
- `ELIZA_CAPABILITY_ROUTER_ENABLED`, `ELIZA_CAPABILITY_ROUTER_URLS`, `ELIZA_CAPABILITY_ROUTER_ALLOWED_MODULES`, `ELIZA_CAPABILITY_ROUTER_TRUST_POLICY`, `ELIZA_CAPABILITY_ROUTER_TRUST_AUDIT`.
Wallet/chain: `EVM_PRIVATE_KEY`, `SOLANA_PRIVATE_KEY`, `ELIZA_WALLET_NETWORK`, `{BSC,QUICKNODE_BSC,NODEREAL_BSC}_RPC_URL`. Misc: `GITHUB_TOKEN`, `LOG_LEVEL`, `ELIZA_CONVERSATION_COMPACTOR`.
Stability (memory watchdog — `runtime/memory-watchdog.ts`, #10197): the boot
sampler (`runtime/boot-telemetry.ts`) only *records* RSS; the watchdog *acts* on
it by requesting a clean restart through the existing `requestRestart()` seam
(host exits `RESTART_EXIT_CODE=75`, the `app-core/scripts/run-node.mjs`
supervisor relaunches) — never a silent `process.exit`.
- `ELIZA_MEMORY_WATCHDOG``1`/`true` enables it (default **off**).
- `ELIZA_MEMORY_WATCHDOG_RSS_MB` — RSS restart threshold in MB (default `1536`, floor `128`).
- `ELIZA_MEMORY_WATCHDOG_INTERVAL_MS` — sample interval (default `30000`, floor `1000`).
- `ELIZA_MEMORY_WATCHDOG_SUSTAINED` — consecutive over-threshold samples before a restart, to debounce transient spikes (default `3`, floor `1`).
## How to extend
- **Add an Eliza action/provider to the agent plugin:** add the file under `src/actions/` or `src/providers/`, export it through the directory barrel (`actions/index.ts`), then wire it into the `actions`/`providers` arrays in `createElizaPlugin()` (`runtime/eliza-plugin.ts`). Parent actions with subactions are flattened via `promoteSubactionsToActions(...)`.
- **Add an HTTP route:** create `src/api/<name>-routes.ts` exporting a handler, register it in `api/dispatch-route.ts`, and export it from `api/index.ts`. Every route needs a real client caller (root AGENTS.md rule 10).
- **Add/enable a bundled plugin:** add the package name to the appropriate list in `runtime/core-plugins.ts` (`CORE_PLUGINS`, `BLOCKING_`/`DEFERRED_`, `MOBILE_`/`ELIZAOS_ANDROID_`) and add it as a `workspace:*` dependency in `package.json`.
- **Add a service:** put it under `src/services/`, register the class in the `services` array of `createElizaPlugin()`, and export from `services/index.ts`.
## Conventions / gotchas
- `bin.ts` statically imports `node:fs` and pins AOSP/mobile bootstrap symbols onto `globalThis` to defeat tree-shaking in the mobile bundle — do not remove those guards.
- `core-plugins.ts` splits plugins into blocking vs deferred boot phases; slow feature/provider plugins must stay in the deferred set or boot regresses.
- Several barrel re-exports avoid duplicate-symbol (`TS2308`) collisions and lazy-load heavy plugins (wallet, app-manager, elizacloud) — read the inline comments in `index.ts`/`api/index.ts`/`services/index.ts` before adding broad `export *` lines.
- `lint`/`lint:check` only cover a curated subset of `src/` directories (see the script in `package.json`); `format` covers all of `src`.
- TEE work (dstack) is gated behind `services/tee-boot-gate*` and validated by `scripts/validate-tee-*.mjs` + `scripts/tee-*-smoke.ts`; see `docs/tee-agent-implementation-plan.md`.
- **Files / media storage.** Attachment bytes live in one content-addressed store, `api/media-store.ts` (`${STATE_DIR}/media/<sha256>.<ext>`, served pre-auth at `/api/media/<sha256>` with `nosniff` + a download `Content-Disposition` for SVG/active types). `services/file-storage.ts` (`LocalFileStorageService`, fills `ServiceType.REMOTE_FILES`) is the contract the rest of the system resolves via `runtime.getService(ServiceType.REMOTE_FILES)``store`/`getUrl`/`list`/`delete`; the authenticated `api/files-routes.ts` (`GET`/`DELETE /api/files`) and the `actions/files.ts` `FILES` agent tool both go through it. `api/media-runtime.ts` rehosts inline `data:` and remote generated-media URLs into the store on the outgoing path (SSRF-guarded) and runs the reference-aware orphan GC (which also counts document `metadata.mediaUrl`). Do NOT add a second file store, a `files` DB table, or a refcount/GC engine — see issue #8876 and the root AGENTS.md anti-pattern clause.
<!-- 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 -->
+191
View File
@@ -0,0 +1,191 @@
# @elizaos/agent
Standalone elizaOS agent + HTTP backend server. Wraps `@elizaos/core`'s `AgentRuntime`, resolves and boots the bundled `@elizaos/plugin-*` set, and serves a local dashboard/control API. This is the package the `eliza-autonomous` binary runs.
## Role
- Consumed by the desktop/mobile shells and CLI as the agent process and backend server. Many subpath exports (`@elizaos/agent/api`, `/runtime`, `/services/*`, `/config/*`, `/security/*`, `/auth/*`) are imported by sibling `@elizaos/plugin-*` packages and the app shell.
- Owns runtime boot, plugin resolution/lifecycle, the HTTP API + route dispatch, character/config loading, trajectory persistence, triggers/scheduling, permission brokering, and the TEE (dstack) boot/key-release path.
Repo-wide conventions (logger-only, ESM, naming, architecture rules, git workflow) live in the root [AGENTS.md](../../AGENTS.md) — not repeated here.
## Layout
```
src/
bin.ts #!/usr/bin/env node entry → cli/index.ts; mobile (android/ios) bootstrap shims
index.ts Public barrel — re-exports api/runtime/services/config/auth/security/triggers
version-resolver.ts Resolves package version (__ELIZA_VERSION__ / package.json / build-info.json)
cli/
index.ts runAutonomousCli() — command dispatch: serve|start|runtime|ios-bridge|android-bridge|benchmark
benchmark.ts Headless benchmark runner (runBenchmark)
runtime/
eliza.ts startEliza() / bootElizaRuntime() / startInCloudMode() — core boot orchestration
eliza-plugin.ts createElizaPlugin() — the "eliza" Plugin (workspace/session providers, lifecycle actions, services)
core-plugins.ts CORE_PLUGINS / BLOCKING_ / DEFERRED_ / OPTIONAL_ / MOBILE_ / ELIZAOS_ANDROID_ plugin name lists
plugin-resolver.ts resolvePlugins() — resolve plugin names → modules; getLastFailedPluginNames()
plugin-collector.ts collectPluginNames(), CHANNEL/OPTIONAL/PROVIDER_PLUGIN_MAP
plugin-lifecycle.ts Plugin install/eject/reinject lifecycle
plugin-role-gating.ts Role-based plugin access gating
roles.ts / roles/ Role definitions and role-resolution helpers
agent-wallets.ts Agent wallet bootstrap and TEE-gated wallet logic
model-resolution.ts Model name resolution helpers
prompt-optimization.ts / prompt-compaction.ts Prompt optimization and compaction strategies
tool-call-cache/ tool-call-cache-wrapper.ts Tool-call result caching layer
first-time-setup.ts First-run initialization logic
load-plugin-from-directory.ts / load-plugin-from-vfs.ts Plugin loading from local dirs and VFS
sandbox-registry.ts / sandbox-character.ts Sandbox plugin registry and character isolation
restart.ts Runtime restart helpers
release-plugin-policy.ts Plugin release-channel gating policy
boot-telemetry.ts / boot-timer.ts Boot timing and telemetry
view-action-affinity.ts View↔action routing affinity
web-search-tools.ts / vault-profile-resolver.ts Miscellaneous runtime helpers
trajectory-*.ts Trajectory persistence / query / internals
conversation-compactor*.ts Conversation summarization/compaction
operations/ vault-bridge.ts (Vault-backed config env resolution), classifier.ts,
cold-strategy.ts, manager.ts, health.ts, health-checks.ts,
reload-hot.ts, repository.ts, types.ts
api/
server.ts startApiServer() — HTTP stack, auth, CORS, WS upgrade, route dispatch
runtime-mode/ mode resolution (local/local-only/cloud/remote), route-visibility gate + remote-mode forwarder run pre-dispatch by every host
dispatch-route.ts dispatchRoute() — maps requests to handlers
*-routes.ts ~38 route modules (agent admin/lifecycle/status, auth, character, memory, models, permissions, registry, etc.)
server-helpers*.ts Auth/conversation/wallet helpers (trusted-local checks, tokens)
server-types.ts Conversation/server/plugin transport types
index.ts api barrel (@elizaos/agent/api)
config/
character-schema.ts CharacterSchema (zod)
config.ts loadElizaConfig() / saveElizaConfig()
plugin-auto-enable.ts Plugin auto-enable resolution
paths.ts resolveUserPath() and state/path helpers
env-vars.ts, schema.ts, model-metadata.ts, owner-contacts.ts
services/ Business-logic services (capability-broker, permissions-registry, config-plugin-manager, plugin-installer/-compiler, relationships-graph, agent-export, shell-execution-router, tee-*, dstack-tee-provider, cove-quote)
actions/ Eliza actions registered by createElizaPlugin (terminal, trigger, contact, settings, plugin, logs, runtime, database, memory, compact-conversation)
providers/ Providers for createElizaPlugin (workspace, admin-trust/-panel, session, rolodex, recent/relevant-conversations, pending-permissions, escalation-trigger, page-scoped-context, ...)
triggers/ runtime.ts (registerTriggerTaskWorker), scheduling.ts, types.ts
auth/ Credential storage + OAuth/Anthropic/OpenAI-Codex flows (account-storage, oauth-flow, refresh-mutex)
security/ access.ts, audit-log.ts, network-policy.ts, mcp-server-config.ts (validateMcpServerConfig)
awareness/ Re-exports AwarenessRegistry from @elizaos/shared
hooks/ loadHooks() / triggerHook() — workspace hook discovery + dispatch
contracts/awareness.ts Local-only awareness contract types
diagnostics/ integration-observability.ts
shared/ workspace-resolution.ts (resolveDefaultAgentWorkspaceDir)
scripts/ build-mobile-bundle.mjs, live-sandbox-smoke.ts, tee-*-smoke.ts, validate-tee-*.mjs
docs/ capability-router-remote-plugins.md, e2b-capability-routing.md, tee-agent-implementation-plan.md
```
## Key exports / surface
- **Binary:** `eliza-autonomous``src/bin.ts``runAutonomousCli()`. Commands: `serve`/`start`, `runtime`, `ios-bridge`, `android-bridge`, `benchmark`.
- **Boot:** `startEliza()`, `bootElizaRuntime()`, `startInCloudMode()` (`runtime/eliza.ts`); `createElizaPlugin()` (`runtime/eliza-plugin.ts`) — the `Plugin` named `"eliza"` registering services (`AgentEventService`, `ElizaCharacterPersistenceService`, `AgentMediaGenerationService`, `PermissionRegistry`), workspace/session/rolodex providers, and the terminal/trigger/contact/settings/plugin/logs/runtime/database/memory/compact actions.
- **HTTP:** `startApiServer()`, `dispatchRoute()`, route handlers (`@elizaos/agent/api`).
- **Plugin sets:** `CORE_PLUGINS`, `BLOCKING_CORE_PLUGINS`, `DEFERRED_CORE_PLUGINS`, `OPTIONAL_CORE_PLUGINS`, `MOBILE_CORE_PLUGINS` (`runtime/core-plugins.ts`); `resolvePlugins()`, `collectPluginNames()`.
- **Config:** `loadElizaConfig`/`saveElizaConfig`, `CharacterSchema`, `resolveUserPath`, `resolveDefaultAgentWorkspaceDir`.
- **Services (named subpaths):** `getCapabilityBroker`/`CapabilityBroker`, `PermissionRegistry`, `runShell` (`services/shell-execution-router.ts`), `resolveRelationshipsGraphService`, TEE helpers (`tee-*`, `dstack-tee-provider`, `cove-quote`).
- Cloud route handlers (`handleCloudRoute`, `handleCloudBillingRoute`, `validateCloudBaseUrl`) are lazy re-exports that dynamically import `@elizaos/plugin-elizacloud`.
## Commands
Run from repo root targeting this package:
```bash
bun run --cwd packages/agent start # bun run src/bin.ts (defaults to `serve`)
bun run --cwd packages/agent dev # bun --hot src/bin.ts
bun run --cwd packages/agent typecheck # tsgo --noEmit -p tsconfig.json
bun run --cwd packages/agent test # vitest run --config vitest.config.ts
bun run --cwd packages/agent lint # biome check --write (curated src subdirs)
bun run --cwd packages/agent lint:check # biome check read-only
bun run --cwd packages/agent format # biome format --write
bun run --cwd packages/agent format:check # biome format read-only
bun run --cwd packages/agent build # build:dist (tsc --noCheck → prepare-package-dist → rewrite imports)
bun run --cwd packages/agent build:mobile # bun scripts/build-mobile-bundle.mjs
bun run --cwd packages/agent build:ios-bun # mobile bundle, --target=ios
bun run --cwd packages/agent test:remote-capabilities
bun run --cwd packages/agent test:sandbox-live
```
`build:docker-dist`, `build:ios-jsc`, `clean`, `pack:dry-run`, `test:remote-capabilities:{docker,cloud-live,provider-live,source-build}` also exist in `package.json`.
## Config / env vars
State and platform:
- `ELIZA_STATE_DIR` — per-user state root (DB, config, logs). `PGLITE_DATA_DIR` / `POSTGRES_URL` for the SQL store.
- `ELIZA_PLATFORM` (`android`/`ios`/…), `ELIZA_BUILD_VARIANT`, `ELIZA_RUNTIME_MODE`, `ELIZA_MOBILE_LOCAL_AGENT`, `ELIZA_DEVICE_BRIDGE_ENABLED`, `ELIZA_LOCAL_LLAMA`.
Cloud + models:
- `ELIZAOS_CLOUD_ENABLED`, `ELIZAOS_CLOUD_API_KEY`, `ELIZAOS_CLOUD_BASE_URL`, `ELIZA_CLOUD_PROVISIONED`.
- Model overrides: `ELIZAOS_CLOUD_{NANO,SMALL,MEDIUM,LARGE,MEGA}_MODEL`, `ELIZAOS_CLOUD_{PLANNER,ACTION_PLANNER,SHOULD_RESPOND,RESPONSE_HANDLER}_MODEL`.
- Provider keys: `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENAI_BASE_URL`.
Capability router (remote plugins — see `docs/capability-router-remote-plugins.md`):
- `ELIZA_CAPABILITY_ROUTER_ENABLED`, `ELIZA_CAPABILITY_ROUTER_URLS`, `ELIZA_CAPABILITY_ROUTER_ALLOWED_MODULES`, `ELIZA_CAPABILITY_ROUTER_TRUST_POLICY`, `ELIZA_CAPABILITY_ROUTER_TRUST_AUDIT`.
Wallet/chain: `EVM_PRIVATE_KEY`, `SOLANA_PRIVATE_KEY`, `ELIZA_WALLET_NETWORK`, `{BSC,QUICKNODE_BSC,NODEREAL_BSC}_RPC_URL`. Misc: `GITHUB_TOKEN`, `LOG_LEVEL`, `ELIZA_CONVERSATION_COMPACTOR`.
Stability (memory watchdog — `runtime/memory-watchdog.ts`, #10197): the boot
sampler (`runtime/boot-telemetry.ts`) only *records* RSS; the watchdog *acts* on
it by requesting a clean restart through the existing `requestRestart()` seam
(host exits `RESTART_EXIT_CODE=75`, the `app-core/scripts/run-node.mjs`
supervisor relaunches) — never a silent `process.exit`.
- `ELIZA_MEMORY_WATCHDOG``1`/`true` enables it (default **off**).
- `ELIZA_MEMORY_WATCHDOG_RSS_MB` — RSS restart threshold in MB (default `1536`, floor `128`).
- `ELIZA_MEMORY_WATCHDOG_INTERVAL_MS` — sample interval (default `30000`, floor `1000`).
- `ELIZA_MEMORY_WATCHDOG_SUSTAINED` — consecutive over-threshold samples before a restart, to debounce transient spikes (default `3`, floor `1`).
## How to extend
- **Add an Eliza action/provider to the agent plugin:** add the file under `src/actions/` or `src/providers/`, export it through the directory barrel (`actions/index.ts`), then wire it into the `actions`/`providers` arrays in `createElizaPlugin()` (`runtime/eliza-plugin.ts`). Parent actions with subactions are flattened via `promoteSubactionsToActions(...)`.
- **Add an HTTP route:** create `src/api/<name>-routes.ts` exporting a handler, register it in `api/dispatch-route.ts`, and export it from `api/index.ts`. Every route needs a real client caller (root AGENTS.md rule 10).
- **Add/enable a bundled plugin:** add the package name to the appropriate list in `runtime/core-plugins.ts` (`CORE_PLUGINS`, `BLOCKING_`/`DEFERRED_`, `MOBILE_`/`ELIZAOS_ANDROID_`) and add it as a `workspace:*` dependency in `package.json`.
- **Add a service:** put it under `src/services/`, register the class in the `services` array of `createElizaPlugin()`, and export from `services/index.ts`.
## Conventions / gotchas
- `bin.ts` statically imports `node:fs` and pins AOSP/mobile bootstrap symbols onto `globalThis` to defeat tree-shaking in the mobile bundle — do not remove those guards.
- `core-plugins.ts` splits plugins into blocking vs deferred boot phases; slow feature/provider plugins must stay in the deferred set or boot regresses.
- Several barrel re-exports avoid duplicate-symbol (`TS2308`) collisions and lazy-load heavy plugins (wallet, app-manager, elizacloud) — read the inline comments in `index.ts`/`api/index.ts`/`services/index.ts` before adding broad `export *` lines.
- `lint`/`lint:check` only cover a curated subset of `src/` directories (see the script in `package.json`); `format` covers all of `src`.
- TEE work (dstack) is gated behind `services/tee-boot-gate*` and validated by `scripts/validate-tee-*.mjs` + `scripts/tee-*-smoke.ts`; see `docs/tee-agent-implementation-plan.md`.
- **Files / media storage.** Attachment bytes live in one content-addressed store, `api/media-store.ts` (`${STATE_DIR}/media/<sha256>.<ext>`, served pre-auth at `/api/media/<sha256>` with `nosniff` + a download `Content-Disposition` for SVG/active types). `services/file-storage.ts` (`LocalFileStorageService`, fills `ServiceType.REMOTE_FILES`) is the contract the rest of the system resolves via `runtime.getService(ServiceType.REMOTE_FILES)``store`/`getUrl`/`list`/`delete`; the authenticated `api/files-routes.ts` (`GET`/`DELETE /api/files`) and the `actions/files.ts` `FILES` agent tool both go through it. `api/media-runtime.ts` rehosts inline `data:` and remote generated-media URLs into the store on the outgoing path (SSRF-guarded) and runs the reference-aware orphan GC (which also counts document `metadata.mediaUrl`). Do NOT add a second file store, a `files` DB table, or a refcount/GC engine — see issue #8876 and the root AGENTS.md anti-pattern clause.
<!-- 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 -->
+26
View File
@@ -0,0 +1,26 @@
# `@elizaos/agent`
Standalone elizaOS agent and HTTP backend. Plugin routes can be registered on `AgentRuntime` and are served by the agents HTTP stack.
## Documentation
- **Paid HTTP routes (webhooks, plugins):** see the docs site section on [webhooks and routes](https://docs.elizaos.ai/plugins/webhooks-and-routes).
- **x402 micropayments on plugin routes:** see [x402 paid plugin routes](https://docs.elizaos.ai/plugins/x402-paid-routes) for protocol alignment and env vars.
## Local development
From this package:
```bash
bun install
bun run typecheck
bun run test
```
See `package.json` for `build`, `lint`, and other scripts.
## x402 at a glance
Paid routes set `x402` on a `Route`. The middleware returns **402** with payment options and accepts on-chain proofs, facilitator payment IDs, or standard payment payloads (`PAYMENT-SIGNATURE` / `X-Payment`), then verifies and settles through a facilitator before running the handler.
For environment variables, events, replay protection, and buyer guidance, use the linked docs above.
Binary file not shown.

After

Width:  |  Height:  |  Size: 264 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 237 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

File diff suppressed because it is too large Load Diff
@@ -0,0 +1,313 @@
# Cloud Sandbox Capability Router
This router lets semantic plugins use filesystem, terminal, and local Git
capabilities without owning the execution substrate.
```text
plugin-coding-tools
-> capability-router runtime service
-> sandbox provider
-> fs / pty / git execution
```
Active providers:
| Provider | Runner | Use |
| --- | --- | --- |
| `e2b` | E2B SDK sandbox | Hosted coding sandbox when E2B credentials are configured. |
| `eliza-cloud` | Eliza Cloud remote runner HTTP runner | Managed cloud runner and coding-agent container path. |
| `home` | Home remote runner HTTP runner | User-owned machine reachable directly, through Eliza Cloud routing, or through SSH tunnel. |
Direct `vercel`, `cloudflare`, and `rivet` providers are intentionally disabled
until they are exposed through Eliza Cloud or another reviewed product option.
## Activation
Select the provider:
```text
ELIZA_CODING_REMOTE_RUNNER=e2b
ELIZA_CODING_REMOTE_RUNNER=eliza-cloud
ELIZA_CODING_REMOTE_RUNNER=home
```
`ELIZA_REMOTE_RUNNER` is also accepted. E2B additionally accepts the legacy
flag:
```text
ELIZA_E2B_REMOTE_RUNNER=1
```
If no provider is selected, the router auto-selects `eliza-cloud` when a direct
cloud runner URL is present, `home` when home runner settings are present, and
otherwise stays disabled unless E2B is explicitly enabled.
## E2B
```text
E2B_API_KEY
E2B_ACCESS_TOKEN
E2B_DOMAIN
E2B_SANDBOX_ID
E2B_TEMPLATE
ELIZA_E2B_WORKDIR
ELIZA_E2B_HOST_WORKSPACE_ROOT
ELIZA_E2B_BOOTSTRAP_GIT_URL
ELIZA_E2B_BOOTSTRAP_GIT_REF
ELIZA_E2B_KEEP_ALIVE=1
ELIZA_E2B_TIMEOUT_MS
ELIZA_E2B_REQUEST_TIMEOUT_MS
```
## Eliza Cloud
```text
ELIZA_CLOUD_SANDBOX_API_BASE_URL
ELIZA_CLOUD_SANDBOX_BASE_URL
ELIZA_CLOUD_REMOTE_RUNNER_URL
ELIZA_CLOUD_RUNNER_URL
ELIZA_CLOUD_SANDBOX_TOKEN
ELIZA_CLOUD_API_KEY
ELIZA_CLOUD_AUTH_TOKEN
ELIZAOS_CLOUD_API_KEY
ELIZACLOUD_API_KEY
ELIZA_CLOUD_SANDBOX_ACCESS_URL
ELIZA_CLOUD_SANDBOX_IMAGE
ELIZA_CLOUD_REMOTE_RUNNER_IMAGE
ELIZA_CLOUD_CODING_REMOTE_RUNNER_IMAGE
ELIZA_CLOUD_SANDBOX_WORKDIR
ELIZA_CLOUD_SANDBOX_HOST_WORKSPACE_ROOT
ELIZA_CLOUD_SANDBOX_BOOTSTRAP_GIT_URL
ELIZA_CLOUD_SANDBOX_BOOTSTRAP_GIT_REF
ELIZA_CLOUD_SANDBOX_TIMEOUT_MS
ELIZA_CLOUD_SANDBOX_REQUEST_TIMEOUT_MS
```
`ELIZA_CLOUD_SANDBOX_BASE_URL`, `ELIZA_CLOUD_REMOTE_RUNNER_URL`, and
`ELIZA_CLOUD_RUNNER_URL` are direct remote runner HTTP URLs and must expose
`/v1/health`, `/v1/fs/entries`, `/v1/fs/file`, and `/v1/processes/run`.
If no direct remote runner URL is set, `eliza-cloud` uses the Cloud API at
`ELIZA_CLOUD_SANDBOX_API_BASE_URL` or the default
`https://api.elizacloud.ai/api/v1`, then posts to
`/coding-containers` with `ELIZA_CLOUD_SANDBOX_TOKEN`, `ELIZA_CLOUD_API_KEY`,
`ELIZAOS_CLOUD_API_KEY`, or `ELIZACLOUD_API_KEY`. The returned container URL is
then treated as the remote runner HTTP runner URL.
The Cloud control plane should use the coding remote runner image from
`packages/cloud/services/coding-remote-runner`. Publish it and set:
```text
ELIZA_CLOUD_CODING_REMOTE_RUNNER_IMAGE=ghcr.io/elizaos/coding-remote-runner:<tag>
```
## Home
```text
ELIZA_HOME_REMOTE_RUNNER_URL
ELIZA_HOME_RUNNER_URL
ELIZA_HOME_REMOTE_RUNNER_TOKEN
ELIZA_HOME_REMOTE_RUNNER_ACCESS_URL
ELIZA_HOME_ACCESS_URL
ELIZA_HOME_REMOTE_RUNNER_WORKDIR
ELIZA_HOME_REMOTE_RUNNER_HOST_WORKSPACE_ROOT
ELIZA_HOME_REMOTE_RUNNER_BOOTSTRAP_GIT_URL
ELIZA_HOME_REMOTE_RUNNER_BOOTSTRAP_GIT_REF
ELIZA_HOME_REMOTE_RUNNER_TIMEOUT_MS
ELIZA_HOME_REMOTE_RUNNER_REQUEST_TIMEOUT_MS
```
Optional SSH tunnel metadata for Settings:
```text
ELIZA_HOME_REMOTE_RUNNER_SSH_TARGET=user@home.example
ELIZA_HOME_SSH_TARGET=user@home.example
ELIZA_HOME_REMOTE_RUNNER_SSH_IDENTITY=/path/to/key
ELIZA_HOME_SSH_IDENTITY=/path/to/key
ELIZA_HOME_REMOTE_RUNNER_SSH_LOCAL_PORT=32468
```
The app only renders a copyable SSH tunnel command. It does not spawn or manage
SSH.
## Agent Runners
Eliza Cloud and Home default to:
```text
codex,claude-code,opencode
```
Override with:
```text
ELIZA_SANDBOX_AGENT_RUNNERS=codex,claude-code,opencode
SANDBOX_AGENT_RUNNERS=codex,claude-code,opencode
```
`claude` is normalized to `claude-code`; `open-code` is normalized to
`opencode`.
These are coding-agent runners, not model providers. The sandbox provider
starts the runner in the workspace; Codex, Claude Code, and opencode each use
their own configured auth/model settings inside that runner.
### Codex server mode
For one-shot jobs, a sandbox can run:
```text
codex exec --cd /workspace "..."
```
For streamed, resumable, cross-device coding sessions, prefer Codex app-server
inside the same sandbox workspace:
```text
codex app-server --listen stdio://
codex app-server --listen ws://127.0.0.1:4500
```
The Codex app-server protocol is JSON-RPC 2.0 without the `jsonrpc` wire field.
It supports threads, turns, streamed item events, `command/exec`, model listing,
auth state, and filesystem methods. Loopback WebSocket listeners expose
`/readyz` and `/healthz` probes. If a WebSocket listener is forwarded outside
the sandbox, require WebSocket auth:
```text
codex app-server --listen ws://127.0.0.1:4500 --ws-auth capability-token --ws-token-file /run/secrets/codex-ws-token
```
Use `CODEX_BIN` when the binary is not on `PATH`. Use
`CODEX_APP_SERVER_LISTEN`, `CODEX_APP_SERVER_WS_TOKEN_FILE`, and
`CODEX_APP_SERVER_WS_SHARED_SECRET_FILE` for runner images that manage a
long-lived app-server process. `codex` remains the runner id; the runner mode is
`exec` or `app-server`.
### opencode server mode
An opencode-backed sandbox should run opencode as a headless server inside the
same sandbox workspace:
```text
opencode serve --hostname 127.0.0.1 --port 4096
```
Use `OPENCODE_SERVER_PASSWORD` to require HTTP Basic auth. The default username
is `opencode`; set `OPENCODE_SERVER_USERNAME` only when a runner needs a
different account name.
The opencode server provides:
| Path | Purpose |
| --- | --- |
| `/global/health` | Server health and version. |
| `/event` | Server-sent event stream. |
| `/doc` | OpenAPI 3.1 spec. |
| `/session` and `/session/:id/message` | Programmatic coding-agent sessions. |
| `/find`, `/find/file`, `/file`, `/file/content`, `/file/status` | Workspace search and file reads. |
| `/vcs` and `/session/:id/diff` | VCS status and session diff. |
The remote runner HTTP runner remains the outer capability boundary. opencode is an
agent runner inside E2B, Eliza Cloud, or Home, not a fourth sandbox provider.
## remote runner HTTP Contract
Eliza Cloud and Home use the same HTTP runner shape:
| Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/v1/health` | Runner readiness. |
| `GET` | `/v1/fs/entries?path=/workspace` | List files. |
| `GET` | `/v1/fs/file?path=/workspace/file.ts` | Read a file. |
| `PUT` | `/v1/fs/file?path=/workspace/file.ts` | Write a file. |
| `POST` | `/v1/processes/run` | Run a command. |
The command request body is:
```json
{
"command": "sh",
"args": ["-lc", "git status --short"],
"cwd": "/workspace",
"env": {},
"timeoutMs": 60000
}
```
The command response may use either:
```json
{ "exitCode": 0, "stdout": "ok", "stderr": "" }
```
or the terminal-style shape:
```json
{ "exitCode": 0, "output": "ok", "timedOut": false }
```
## Routed Capabilities
| Capability | Route |
| --- | --- |
| `fs.list` | Provider file listing. |
| `fs.readText` | Provider file read. |
| `fs.writeText` | Provider file write. |
| `pty.command.run` | Provider command execution. |
| `git.status` | `git status --porcelain=v1 --branch` in provider workspace. |
| `git.diff` | `git diff` in provider workspace. |
| `git.command.run` | `git ...args` in provider workspace. |
`model.status` remains unavailable because local model control belongs to model
providers and `eliza.local-model`, not the coding sandbox runner.
## Workspace Mapping
Absolute host paths under the configured host workspace root map into the
provider workdir.
Example:
```text
ELIZA_SANDBOX_HOST_WORKSPACE_ROOT=/Users/me/eliza
ELIZA_SANDBOX_WORKDIR=/workspace
/Users/me/eliza/packages/agent -> /workspace/packages/agent
```
Paths outside the mapped root fail with `CAPABILITY_UNAVAILABLE`.
## Mobile And Cross-Device
Mobile does not need Electrobun. It talks to the same Eliza agent runtime, and
the runtime routes coding capabilities to a reachable provider:
- E2B for hosted sandbox execution.
- Eliza Cloud for managed cloud remote runner execution.
- Home for a user-owned machine reachable by direct URL, cloud routing, or SSH tunnel.
Results return through normal chat, trace, and dynamic-view channels.
## Live Smoke Tests
Run the live smoke harness from the repo root:
```text
bun run --cwd packages/agent test:sandbox-live
```
Without provider credentials, E2B, Eliza Cloud, and Home are reported as
skipped. Codex app-server is always tested locally when `codex` is available.
To require a configured provider:
```text
bun run --cwd packages/agent test:sandbox-live -- --target=e2b --strict
bun run --cwd packages/agent test:sandbox-live -- --target=eliza-cloud --strict
bun run --cwd packages/agent test:sandbox-live -- --target=home --strict
```
The provider smoke writes a temporary file, reads it back, lists the workspace,
runs a shell command, and runs `git --version` through the configured sandbox
route.
@@ -0,0 +1,589 @@
# TEE-Native Agent Implementation Plan — Confidential AI End to End
Date: 2026-05-21
Lane: AGENT-RUNTIME (confidential agent + confidential inference)
Status: **Phase A landed (software-only).** The agent-side primitives, the
production boot-gate wiring, the production profile, the nonce/`report_data`
replay binding, the signed revocation manifest, and the confidential-inference
unseal *plumbing* (against the local mock KMS) are built and unit-tested — see
the Phase A table in §7 and the DONE markers in §1.2. **All real hardware quote
verification (real TDX/CoVE quote signature, RTMR, and `report_data` validation;
the dstack guest agent; RA-TLS KMS) is Phase B/C and stays BLOCKED on hardware**
— called out as such throughout. **The agent runs on a CPU-based Intel TDX CVM;
confidential-GPU (NVIDIA H100) is out of scope.** Phase A verifies a signed
evidence *document* and fails closed; it does not claim hardware-verified trust.
Scope discipline (repo `AGENTS.md`): this document is the plan only. It does
not refactor production code. It proposes strongly-typed additions (no `any`,
no silent fallbacks) and fail-closed gates. Nothing here invents product
behavior beyond the documented local-first + optional-Cloud topology.
---
## 0. What the device must guarantee
A TEE-native ultra-private personal AI device. The agent runtime, model
weights, KV-cache, user data, signing keys, and the local inference engine all
run inside a single-tenant whole-system confidential domain (a dstack CVM on
TDX today; a CoVE/TSM TVM on the E1 chip later). Cleartext weights, prompts,
embeddings, and KV-cache never exist outside that domain. Secrets are released
only after a fresh, nonce-bound attestation proves the measured agent + policy
+ container + NPU firmware match the release manifest, debug is disabled, and
the security version is above the rollback floor. Up to a 10% perf hit is
acceptable for this guarantee.
---
## 1. Critical assessment of the existing `tee-*` suite
### 1.1 What is real, strong, and load-bearing
The normalized evidence type and the policy verifier are the genuine core, and
they are good:
- **`tee-evidence.ts`** — `TeeEvidence` is a clean, provider-neutral normalized
shape (`kind`, `provider`, `securityVersion`, `measurements`, `freshness`,
`claims`, `quote`, `certificatePem`, `reportData`, `raw`). `normalizeTeeEvidence`
is strict and fail-closed: it throws on non-objects, non-string measurements,
non-boolean claims, non-integer `securityVersion`, and a missing `kind`. No
`any`; the only `unknown` is at the deserialization boundary, which is correct.
`normalizeDigest` canonicalizes the `sha256:` prefix and case. `teeMeasurementDigestMatches`
treats an *expected* digest as required and a *missing actual* as a failure —
the right default. **Real.**
- **`tee-policy.ts`** — `evaluateTeeEvidencePolicy` is the single trust decision
function and it is comprehensive: kind/provider allowlists, required and
revoked measurements, min/revoked security version, nonce match, timestamp
freshness window (with a 60s forward-skew clamp), and required boolean claims.
Decisions are a closed discriminated union of `reason` strings. Fail-closed:
required-but-missing → `missing-evidence`. **Real and the centerpiece.**
- **`tee-revocation.ts`** — schema-versioned revocation manifest (measurements +
security versions), normalizes string/number/object entries, dedupes, merges
into a policy. Clean. **Real.**
- **`tee-release-policy.ts`** — derives a `TeeEvidencePolicy` from an OS release
manifest (`tee.enabled/providers/measurements/requiredClaims/minSecurityVersion`).
Maps the OS measured-boot contract into the agent's policy. **Real.**
- **`tee-runtime-config.ts`** — env-driven policy resolution
(`ELIZA_TEE_POLICY_JSON/_PATH`, `ELIZA_TEE_RELEASE_MANIFEST_JSON/_PATH`,
`ELIZA_TEE_REVOCATIONS_JSON/_PATH`, `ELIZA_TEE_REQUIRED`, nonce/max-age). **Real.**
- **`tee-signer-backend.ts`** — `TeeSignerBackend` decorates a `SignerBackend` and
re-collects + re-evaluates evidence before *every* `signMessage`/`signTransaction`.
Fail-closed by construction; the test proves the inner signer is never called on
a failed decision. **Real.**
- **`remote-capability-endpoint-provider.ts`** — `connectRemoteCapabilityEndpointProvider`
collects endpoint TEE evidence at provision time and `evaluateProvisionedEndpointTeeTrust`
**throws before `syncRemoteCapabilityPlugins`** when the policy fails. The test
proves no plugin is registered on a missing/mismatched-measurement endpoint.
This is the strongest integration point in the suite. **Real.**
- Tests: `tee-policy.test.ts`, `tee-key-release.test.ts`, `tee-signer-backend.test.ts`,
`tee-revocation.test.ts`, `tee-release-policy.test.ts`, `tee-runtime-config.test.ts`,
`dstack-tee-provider.test.ts`, `remote-capability-tee-policy.test.ts` cover the
positive and the critical negative paths. The two harness scripts
(`tee-full-stack-local.ts`, `tee-local-smoke.ts`) exercise collect → policy →
key-release end to end against a mock KMS and write JSON evidence artifacts.
### 1.2 What is mock, stubbed, or thin — be specific
> **Status reconciliation (Phase A landed).** Several gaps called out below have
> since been closed in software (Phase A — no hardware required) and are marked
> **DONE** inline with the implementing file. What remains genuinely blocked is
> **real hardware quote verification** (TDX signature / RTMR / `report_data`
> validation, the dstack guest agent, RA-TLS KMS) — Phase B/C, **BLOCKED on CPU
> TDX hardware**. Confidential-GPU (H100) is **out of scope** (CPU TDX CVM only).
> The software items below do **not** make the
> system hardware-trusted; they verify a signed evidence *document* and fail
> closed when it is absent, simulated, replayed, or tampered.
- **No real quote verification anywhere.** `dstack-tee-provider.ts` *fetches or
reads* a `TeeEvidence` JSON blob (`ELIZA_TEE_EVIDENCE_JSON/_URL/_PATH`,
`DSTACK_TAPPD_URL`) and normalizes it. It never parses a TDX quote, never
checks an Intel PCS/QvL signature, never validates RTMRs against `report_data`,
never verifies the DICE/RA-TLS cert chain. **`evidence.quote` is carried but
never cryptographically verified.** The policy verifier trusts whatever the
provider hands it. This is the single biggest gap: today the system verifies a
*self-asserted JSON document*, not a hardware attestation. This is acceptable
for the macOS-feasible lane and unit tests, but it is **not** the security
property the product claims, and the codebase does not currently hide that —
it just hasn't built the verifier. (Honest gap, not slop.)
- **DONE (A7) — nonce + `report_data` replay binding.** `tee-key-release.ts` now
generates a fresh ephemeral X25519 keypair and a fresh nonce per request and
binds `report_data = SHA256(nonce || epk_pub)` (`TeeReportDataChallenge`,
`collectEvidenceWithReportData`). The issued nonce is set as `policy.expectedNonce`
so `evaluateTeeEvidencePolicy` rejects `nonce-mismatch`, closing the replay gap
for a passive collector. (Originally: the client sent whatever nonce was already
in the evidence — replayable.) The crypto binding is real; **the quote that
carries `report_data` is still not hardware-verified — Phase B2, BLOCKED.**
- **`LocalTeeKeyReleaseClient` is an HMAC KDF, not a KMS.** It derives key
material with `HMAC-SHA256(masterSecret, keyId|context|agent|policy|device)`.
This is a faithful *model* of "deterministic app-key bound to measurement" and
it correctly varies output per agent/policy measurement (proven by test), but
the `masterSecret` lives in agent memory. It is a local-dev stand-in for the
dstack decentralized KMS, not the KMS.
- **DONE (A4) — production boot-gate wiring.** `runtime/eliza.ts` now runs
`runTeeBootGate` at startup: it calls `evaluateTeeBootGate` (which resolves the
runtime policy, merges the production profile, collects evidence, and evaluates
trust ONCE), fails closed on any error, and publishes the decision via
`setTeeBootGateState`. Secret-bearing paths consult `teeBootGateBlocksSecrets()`
(`tee-boot-gate-state.ts`), and `assertTeeBootGateAllowsSecrets` throws
fail-closed when secrets are disabled. `ELIZA_TEE_REQUIRED=true` with no trusted
evidence ⇒ no model-key release, no signing, no remote-plugin sync; degraded
boot allowed, silent secret release never. (Originally: `tee-*` was only
`export *`-ed with no boot consumer.)
- **DONE (A8) — confidential-inference unseal plumbing.**
`tee-confidential-inference.ts` releases the `model-key` (`MODEL_KEY_ID`) only
after the key-release client's evidence satisfies the policy, then decrypts the
at-rest weights blob in process memory and hands it to the local runtime. The
test asserts the weights and key never touch disk, env, or the structured
logger, and that tampered/absent evidence makes the key unavailable so unseal
throws (negative path enforced by data unavailability). (Originally: no unseal
path, `model-key` had no consumer.) This runs against the **local HMAC KDF /
mock KMS** — the **real RA-TLS KMS and real quote verification are Phase B,
BLOCKED.**
- **DONE (A3/A6) — centralized production profile rejecting DevMode/simulated
evidence.** `tee-production-profile.ts` (`mergeTeeProductionProfile`) forces
`required`, `requiredClaims` (`debugDisabled`/`productionLifecycle`), and
`rejectSimulatedEvidence` on, so a caller cannot accept DevMode evidence by
forgetting a claim. `evaluateTeeEvidencePolicy` rejects evidence that
self-identifies as mock/simulated/debug/devmode (kind, hardwareVendor, provider,
quote markers, verifier) — the `tee-production-profile.test.ts` matrix proves
it. This is an agent-side compensating control against dstack #608 (DevMode
allow-all); it does **not** replace **real quote-signature verification —
Phase B2, BLOCKED.** KMS-identity pinning / RA-TLS cert verification config
fields exist (A6) but the live RA-TLS handshake is Phase B.
- **Two `Tee*` type homes.** `packages/core/src/types/tee.ts` defines a *legacy*
`TeeAgent`/`RemoteAttestationQuote`/`TEEMode`/`TeeType` set (old plugin-tee
shape). The *new* canonical types live in `packages/agent/src/services/tee-evidence.ts`.
These do not conflict (different concepts) but the naming overlap is a trap.
Plan: leave core's legacy types alone unless a consumer needs them; treat
`tee-evidence.ts` as the single source of truth for confidential-AI evidence
and document that in core's file.
### 1.3 Contracts that already exist (and we must not redefine)
- The normalized `TeeEvidence` shape and `evaluateTeeEvidencePolicy` decision
union are the cross-layer contract. The chip lane (`06-os-on-tee-software.md`)
and OS lane (`tee-measured-boot-contract.md`) both explicitly anchor to them
and promise *not* to fork them. Keep it that way.
- Measurement names: `boot`, `os`, `agent`, `policy`, `device`, `container`,
`compose`, `npuFirmware`, `gpuFirmware` (+ open string). Claims: `debugDisabled`,
`productionLifecycle`, `secureBoot`, `memoryEncrypted`, `ioProtected`,
`gpuProtected`, `npuProtected`.
**Verdict:** the policy/evidence/revocation core is production-grade and the
right design. The verifier of *real* quotes, the *nonce/epk binding*, the
*confidential-inference unseal path*, the *production hardening profile*, and
the *boot wiring* are the missing pieces. The work is integration + a real
verifier, not a rebuild.
---
## 2. Confidential AI inference path (the headline)
### 2.1 Goal
Model weights are encrypted at rest on the device's storage. They are decrypted
only inside the confidential domain, only after attestation-gated release of the
`model-key`, and loaded directly into the in-domain local model runtime (eliza-1)
and the NPU. Prompts, embeddings, KV-cache, and generated tokens live only in
private (memory-encrypted) pages and never cross to a non-measured device in
cleartext.
### 2.2 Weights-at-rest + unseal flow (local-in-TEE)
```text
1. Boot the confidential domain (dstack CVM / CoVE TVM).
OS/silicon lane produces the measured-launch quote.
2. In-domain attestation agent assembles TeeEvidence (kind, measurements
incl. agent+policy+container+npuFirmware, claims, freshness).
3. Agent boot resolves the production TeeEvidencePolicy
(resolveTeeRuntimePolicy + production profile, §4.2).
4. Agent requests model-key from KMS:
- generate ephemeral X25519 keypair (epk) in-domain
- obtain a fresh verifier nonce
- bind reportData = SHA256(nonce || epk_pub) into the quote
- send {keyId:"model-key", evidence, policy} over RA-TLS
5. KMS verifies the quote (RTMRs/measurements, signature, freshness, policy),
derives the app-deterministic model-key, wraps it to epk, returns it.
6. Agent unwraps model-key in-domain, decrypts the weights blob
(AES-256-GCM / XChaCha20-Poly1305) into private memory.
7. Weights stream into the local model runtime (eliza-1) and the NPU
private queues. The model-key and cleartext weights never leave the domain.
8. Inference: prompts/embeddings/KV-cache stay in private pages. Output crosses
to the UI only through the explicit shared-page copy path.
```
If step 5 fails (tampered agent/OS/policy → measurement-mismatch, stale quote,
debug enabled, rolled-back version), the KMS withholds `model-key`, the weights
stay ciphertext, and **inference is impossible** — the negative path is enforced
by *data unavailability*, not by a software check that could be patched out.
This is the property called out in chip §3.4 and it is the design we adopt.
### 2.3 KV-cache, prompts, user data confinement
- `ELIZA_STATE_DIR` (default `~/.local/state/eliza`) maps to a private,
sealed-key-encrypted volume that only mounts after attestation-gated key
release. (dstack does this via LUKS2 — see the LUKS2 advisory in §5; we must
bind the volume key to attestation, not to a host-readable key.)
- KV-cache and intermediate tensors live only in confidential (memory-encrypted)
pages. There is no swap to a non-measured device; if swap is required it must
be encrypted with an in-domain key.
- Cleartext crosses the boundary only through the shared-page virtio path and
only for data the user marked exportable (a measured policy field). UI text,
rendering, and notifications are explicitly allowed outside.
### 2.4 Maps to the NPU-as-confidential-I/O and eliza-1
- The chip lane's NPU private queues (lane 03, `npuProtected` claim,
`npuFirmware` measurement) are the hardware mechanism that keeps weights and
activations off shared buses during inference. The agent's contract with that
layer is: do not release `model-key` and do not start private inference unless
`claims.npuProtected === true` and `measurements.npuFirmware` matches the
manifest. This is a policy assertion the agent already supports; the plan adds
it to the production profile (§4.2).
- eliza-1 is the in-domain local model runtime. The plan's only requirement on
it: it must accept weights from an in-memory decrypted buffer (no temp file
on a non-measured FS) and must not log prompts/weights anywhere reachable by
the host (§4.4).
### 2.5 Confidential inference is CPU-only (device CVM or cloud dStack CVM)
Confidential inference runs **on-CPU inside a CVM** — never on a confidential
GPU. **NVIDIA H100 / confidential-GPU is out of scope.** Two confidential
placements, both CPU TDX/TVM:
| Aspect | Device-local CVM/TVM (default) | Cloud dStack CVM (opt-in) |
| --- | --- | --- |
| Where weights decrypt | On-device CVM/TVM (on-CPU) | dStack **CPU-based Intel TDX** CVM (on-CPU) |
| Verifier/KMS | On-device verifier (`eliza-local-verifier`) | Cloud dStack KMS (added to `allowedProviders`) |
| What the agent sends | nothing leaves the device | nothing leaves the cloud CVM (inference is on-CPU in-domain) |
| Extra claim required | `npuProtected` + `npuFirmware` (on-device secure I/O) | none beyond the CPU TDX quote/measurement |
| Policy difference | `required:true`, local golden digests | adds the Cloud dStack KMS provider |
The agent runs the **same** `evaluateTeeEvidencePolicy` for both; only the policy
object differs (allowed providers / measurements). Inference sent to a
**non-attested external endpoint** (a third-party model API) is **not
confidential** — cleartext to that operator — and is the opt-in, non-confidential
path for user-marked-exportable data only, not a confidential-GPU path.
---
## 3. End-to-end attestation + key release contract
### 3.1 The flow, by component
```text
device evidence (OS/silicon) [OS + chip lanes]
-> in-domain attestation agent emits TeeEvidence [dstack-tee-provider / chip agent]
-> normalizeTeeEvidence(...) [tee-evidence.ts] STRICT
-> evaluateTeeEvidencePolicy(evidence, policy) [tee-policy.ts] TRUST DECISION
policy from resolveTeeRuntimePolicy(env) [tee-runtime-config.ts]
+ mergeTeeRevocationsIntoPolicy(...) [tee-revocation.ts]
+ production profile (§4.2) [NEW]
-> trusted? release secret [tee-key-release.ts]
model-key | agent-session | remote-signing
-> unseal: decrypt weights / mount state / sign [NEW consumers]
```
### 3.2 Freshness / nonce / report_data binding (must be added)
This is the contract the docs specify but the client does not yet implement:
1. The agent generates an ephemeral X25519 keypair `epk` per release request.
2. The agent obtains a fresh challenge `nonce` from the verifier/KMS (or, in
local mode, from the local verifier).
3. The quote request sets `report_data = SHA256(nonce || epk_pub)` so the quote
is bound to *this* live channel and is not replayable.
4. `policy.expectedNonce` is set to the issued nonce; `policy.maxAgeMs` bounds
staleness; `evaluateTeeEvidencePolicy` rejects `missing-nonce`/`nonce-mismatch`/
`timestamp-stale`.
5. The KMS wraps the released key to `epk_pub`; the agent unwraps in-domain.
`HttpTeeKeyReleaseClient` must be extended (or a `RaTlsTeeKeyReleaseClient`
added) to do steps 13 and to *verify* the returned key was wrapped to its epk.
**Never accept a key for a quote whose `report_data` the client did not bind to
a nonce it generated.**
### 3.3 Measurement matching set (the agent's required claims)
For a production local-in-TEE confidential-AI release the policy must require:
- `requiredMeasurements`: `agent`, `policy`, `container`/`compose`, `os`, `boot`,
`device`, and `npuFirmware` (when local private inference is enabled), each
matching the signed reproducible-build manifest's golden digest.
- `requiredClaims`: `debugDisabled:true`, `secureBoot:true`, `memoryEncrypted:true`,
`ioProtected:true`, `productionLifecycle:true`, and `npuProtected:true` (local)
or `gpuProtected:true` (cloud-routed).
- `minSecurityVersion`: the anti-rollback floor from the RoT counter.
- `expectedNonce` + `maxAgeMs`: per §3.2.
- revocations merged from the signed revocation manifest.
### 3.4 Rollback / revocation
- Rollback: `minSecurityVersion` + `revokedSecurityVersions` (both supported).
- Revocation: `revokedMeasurements` per name. The revocation manifest is signed
by an authority (`authority` field) — **the agent must verify that signature
before merging** (gap: `mergeTeeRevocationsIntoPolicy` trusts the manifest;
add signature verification at the load boundary in `tee-runtime-config.ts`).
### 3.5 Map to dstack KMS deterministic app keys
dstack's KMS runs in its own TEE, verifies the TDX quote, and derives a
*deterministic per-app key bound to the measured app identity* (compose digest +
args + env) with authorization enforced by on-chain policy that operators cannot
bypass. The agent's `model-key`/`agent-session`/`remote-signing` scopes map to
dstack `keyId`s; the binding inputs (agent/policy/container measurements) in our
`deriveKeyMaterial` model mirror dstack's app-identity binding. In production the
`LocalTeeKeyReleaseClient` HMAC model is replaced by the real dstack guest-agent
socket (`/var/run/dstack.sock`) → KMS path; our `HttpTeeKeyReleaseClient` becomes
an RA-TLS client to that socket/endpoint.
---
## 4. Secret & capability gating
### 4.1 `ELIZA_TEE_REQUIRED=true` behavior (must be wired)
Today `resolveTeeRuntimePolicy` returns `{ required:true, ... }` but no boot code
consumes it. Plan: at agent boot, if `ELIZA_TEE_REQUIRED==="true"` (or a policy
resolves with `required:true`), the agent must:
- collect evidence via the configured provider and evaluate it once at boot;
- if not trusted, **refuse to release any high-value secret** (model-key,
signing key, remote-signing) and refuse to sync remote capability plugins —
the agent may still boot in a degraded, secret-less mode but must surface the
failed decision (structured logger, `[TeeBootGate]`), never silently continue
with secrets.
- wrap the active `SignerBackend` in `TeeSignerBackend` so *every* sign re-checks.
- gate `model-key` release behind the same policy (so weights stay sealed).
### 4.2 A single production profile (new, prevents footguns)
Add a `teeProductionProfile()` helper that returns the non-negotiable claim/
freshness floor and is intersected with the resolved policy, so a caller cannot
forget `debugDisabled`/`memoryEncrypted`/freshness in production:
```ts
// strongly typed, no fallbacks; rejects dev/debug evidence by construction
export function teeProductionProfile(): Required<Pick<TeeEvidencePolicy,
"required" | "requiredClaims">> & Pick<TeeEvidencePolicy, "maxAgeMs"> {
return {
required: true,
requiredClaims: {
debugDisabled: true,
secureBoot: true,
memoryEncrypted: true,
ioProtected: true,
productionLifecycle: true,
},
maxAgeMs: 300_000,
};
}
```
The boot path must merge this into the resolved policy whenever the build is a
production/`stable` channel, never accepting DevMode/debug evidence under it.
### 4.3 Host↔guest capability bridge + RemoteSigningService
Per `tee-protected-agent-vm.md`: the host owns UI/network/storage-brokering; the
guest owns secrets, signing keys, decrypted model keys, private tool output.
Allowed host→guest calls: `plugin.modules.list`, approved remote plugin
actions, workspace-scoped file IO, terminal exec only when policy allows the
endpoint, and signing through `RemoteSigningService`. Denied by default: host FS
escape, raw device access, unmeasured plugin loading, unsigned policy mutation,
and **secret export to host logs/env dumps/crash reports/analytics**.
`RemoteSigningService` must use `TeeSignerBackend` so the host can request a
signature but the key never leaves the domain and every sign re-attests.
### 4.4 Deny secret export to host surfaces (new gate)
- No secret/weight/prompt value may be written to a logger that ships off-domain,
to `process.env` dumps, to crash/telemetry payloads, or to a non-measured FS.
- Add a redaction assertion at the structured-logger boundary and a CI gate that
greps the crash/telemetry serializers for secret-scope keys. (Logger-only per
commandment 9; never `console`.)
### 4.5 Fail-closed on missing/stale/debug/mismatched evidence
The decision union already encodes every failure cause. The plan is to ensure
**every** high-value path routes through `evaluateTeeEvidencePolicy` and throws
on `!trusted`: plugin sync (done — `remote-capability-endpoint-provider`),
signing (done — `TeeSignerBackend`), model-key release (to wire), agent-session
secret release (to wire), and remote capability calls (done). No path may have a
fallback that proceeds without a `trusted:true` decision.
---
## 5. dstack provider hardening (defenses against the known issues)
The brief and dstack's own advisories establish the threat classes. Concrete
agent-side defenses:
1. **KMS attestation bypass** — never trust a key the agent did not bind to its
own fresh nonce + epk (§3.2). The agent independently runs
`evaluateTeeEvidencePolicy` on the evidence *before* using any returned key;
it does not delegate the trust decision to the KMS response alone. Verify the
returned key is wrapped to the agent's epk.
2. **Permissive DevMode auth / fake-quote pathways** — the production profile
(§4.2) requires `debugDisabled:true` and `productionLifecycle:true`; DevMode
or simulated quotes (e.g. `quote:"simulated-cove-quote"`, `hardwareVendor:"mock-*"`)
must be rejected. Add an explicit allowlist of accepted `verifier`/`hardwareVendor`
values in production, and refuse `kind` values that indicate mock providers.
The *real quote signature* must be verified (BLOCKED on hardware; see §7) —
until then production must not claim hardware trust.
3. **Disabled-TLS-verification gateway** — the KMS client must use RA-TLS with
full certificate-chain verification pinned to the expected KMS identity;
**refuse plain HTTP and refuse `NODE_TLS_REJECT_UNAUTHORIZED=0`**. Add a guard
that throws if the KMS URL is not `https:`/RA-TLS in production
(`normalizeBaseUrl` already enforces http(s); tighten to https-only +
identity pin).
4. **KMS identity pinning** — pin the KMS's measured identity (its own quote /
on-chain policy identity) so a rogue or downgraded KMS cannot answer. Add
`expectedKmsMeasurement` / `expectedKmsPublicKey` to the release client config
and verify it on the RA-TLS handshake.
5. **World-readable key material / decrypted env vars** — never place secrets or
the decrypted weights/model-key in env vars or world-readable files. Keep them
in-process memory; the sealed state volume key must be attestation-bound, not
a static host-readable key (the LUKS2 advisory GHSA-jxq2-hpw3-m5wf is exactly
this risk). Zeroize key buffers after use.
6. **Decompression bomb / malformed evidence**`normalizeTeeEvidence` already
rejects malformed shapes; add a max-size cap on the evidence/quote payload in
`dstack-tee-provider.ts` HTTP/file reads and a JSON-parse size guard before
parsing.
7. **Cert / constant-time issues** — use constant-time comparison for nonce and
wrapped-key checks; rely on a vetted RA-TLS library for cert verification
rather than hand-rolled parsing.
These are agent-side compensating controls; they do not fix dstack itself but
ensure the agent never extends trust to a quote/KMS it cannot independently
verify and pin.
---
## 6. The agent↔OS↔silicon contract
### 6.1 Fields the agent consumes (provider-neutral)
The agent consumes only the normalized `TeeEvidence` from `tee-evidence.ts`,
regardless of provider (`dstack`/`tdx`/`cove`/`eliza-vault`). Required from the
layer below:
- `kind`, `provider`, `securityVersion`, `freshness{nonce,timestamp,verifier}`,
`quote`, `certificatePem`, `reportData`.
- `measurements`: `boot`, `os`, `agent`, `policy`, `device`, `container`/`compose`
(containerized), `npuFirmware` (local private inference), `gpuFirmware`
(cloud confidential GPU).
- `claims`: `debugDisabled`, `secureBoot`, `memoryEncrypted`, `ioProtected`,
`productionLifecycle`, `npuProtected` (local), `gpuProtected` (cloud).
### 6.2 Is the existing `requiredClaims` set the right contract?
Yes. `debugDisabled`, `secureBoot`, `memoryEncrypted`, `ioProtected` are the
correct core set, and the type already carries `productionLifecycle`,
`npuProtected`, `gpuProtected`. For confidential AI the only additions needed
are *usage of* the already-present fields:
- **Use `npuProtected` + `measurements.npuFirmware`** as a hard gate for local
private inference (present in types and fixtures; not yet required by any
production profile).
- `gpuProtected` + `measurements.gpuFirmware` exist in the type for
completeness but are **out of scope / no consumer** — confidential-GPU (H100)
is not pursued; confidential inference runs on-CPU inside the CVM.
- Consider a `monitor` measurement name (TSM/security-manager digest) for the
CoVE path — chip §2.1/§3.1 emits `monitor`; `TeeMeasurementName` is an open
string so it already type-checks, but add `"monitor"` to the named union for
discoverability.
- Optionally add a `modelWeights` measurement name so the released-against
weights digest can be matched (defense in depth: bind `model-key` release to
the expected weights digest). Open-string already allows it; naming it makes
it a first-class contract field.
No breaking changes to the type are required — the contract is right; the work
is *enforcing the optional fields* in the production profile.
---
## 7. Sequenced Plan
Effort in person-weeks (PW). Every blocked item names its dependency and stays
fail-closed.
### Phase A — buildable now (no hardware), software-only
All Phase A items are **DONE** (software-only, no hardware). They verify a signed
evidence *document* and fail closed; none of them claims hardware-verified trust —
that stays Phase B/C, BLOCKED.
| ID | Status | Work item | Gate |
| --- | --- | --- | --- |
| A1 | DONE | Extend `tee-full-stack-local.ts` with real policy vectors: per-topology policies (local-only, desktop, cloud-routed) + golden/tampered fixtures for every decision reason (kind/provider/measurement/version/nonce/timestamp/claim/revoked). | `tee-full-stack-local` |
| A2 | DONE | Negative-test matrix asserting each `reason` in the decision union for crafted evidence. Pure data, no `any`. | `tee-evidence-policy.matrix.test.ts` |
| A3 | DONE | `teeProductionProfile()` + profile-merge helper (§4.2); rejects DevMode/debug evidence. | `tee-production-profile.ts` / `.test.ts` |
| A4 | DONE | Boot wiring (§4.1): `runTeeBootGate` in `runtime/eliza.ts`, fail-closed `[TeeBootGate]`, gate model-key + agent-session release; cross-module state via `tee-boot-gate-state.ts`. | `tee-boot-gate*.ts` / `.test.ts` |
| A5 | DONE | Revocation-manifest Ed25519 signature verification at load (§3.4); deny unsigned/invalid manifests. | `tee-revocation.ts` / `tee-revocation-signature.test.ts` |
| A6 | DONE | dstack provider hardening (no hardware): payload size cap, https-only + KMS-identity-pin config fields, constant-time nonce/key compare, reject mock `verifier`/`hardwareVendor`/simulated quotes under production profile. | unit |
| A7 | DONE | Nonce + epk binding in the key-release client (§3.2): generate epk, issue/echo nonce, set `report_data`, verify key wrapped to epk. (Crypto only; no real quote yet.) | `tee-key-release.ts` / `.test.ts` |
| A8 | DONE | Confidential-inference unseal *plumbing* (§2.2 steps 47) against the mock KMS: encrypt weights at rest, release `model-key`, decrypt in-memory, hand to the local runtime; assert no temp-file/plaintext on disk and no secret in logs (§4.4). | `tee-confidential-inference.ts` / `.test.ts` |
| A9 | DONE | CI gate (§4.4): grep crash/telemetry serializers + env-dump paths for secret-scope keys; fail on leak. In-lane via `tee-secret-hygiene.test.ts`; repo-wide CLI gate `packages/scripts/audit-tee-secret-leak.mjs` (`--self-test`). | lint gate |
Critical path for Phase A: A3 → A4 → A8 (the headline unseal plumbing depends on
the production profile and boot wiring). A1/A2/A5/A6/A7/A9 parallelized.
### Phase B — cloud-TDX-gated (real dstack KMS on CPU-based Intel TDX)
The agent runs inside a **CPU-based Intel TDX CVM** (Phala dStack). The whole
agent — runtime, DB, vault/secrets, and any on-CPU inference — is the
confidential workload. **Confidential-GPU (NVIDIA H100) is out of scope** (was
B4); the trust boundary is the CPU CVM, not a GPU.
| ID | Work item | Effort | Gate |
| --- | --- | --- | --- |
| B1 | Real dstack guest-agent integration: read evidence from `/var/run/dstack.sock` / `DSTACK_TAPPD_URL`, request real TDX quote, populate `TeeEvidence`. | 3 | dstack CVM on CPU TDX |
| B2 | Real TDX quote verification (Intel PCS/QvL): verify quote signature, RTMRs, and `report_data == H(nonce\|\|epk)`. **The current provider does none of this.** | 4 | CPU TDX hardware |
| B3 | Real RA-TLS KMS client with KMS-identity pinning; deterministic app-key release for `model-key`/`agent-session`/`remote-signing`. | 3 | dstack KMS |
**Confidential inference policy (no H100):** inference that must stay
confidential runs **on-CPU inside the CVM** (cleartext weights/prompts/KV-cache
never leave the CPU TDX domain). Cloud-routed inference to an external endpoint
is **outside** the confidential boundary by definition — a documented limitation,
not an H100/`gpuProtected` work item.
Phase B is **BLOCKED on CPU TDX hardware availability**. Until B2 lands, the
system must not claim hardware-verified trust; it verifies a signed evidence
document only. State this in any release notes.
### Phase C — chip-silicon-gated (real CoVE quote on E1)
| ID | Work item | Effort | Gate |
| --- | --- | --- | --- |
| C1 | Consume real CoVE quote from the E1 in-domain attestation agent (chip §3.1); verify DICE/RoT cert chain + RTMR/`report_data` binding. | 4 | E1 silicon / CoVE QEMU+Salus |
| C2 | NPU private-queue gate: enforce `npuProtected` + `npuFirmware` before private inference (binds chip lane 03). | 2 | E1 secure I/O |
| C3 | Attestation-bound sealed state volume (replace host-readable LUKS2 key with attestation-released key). | 2 | E1 + KMS |
Phase C is **BLOCKED on E1 silicon / a riscv64 CoVE QEMU+Salus target** (chip
lane WI-6/WI-7/WI-9). It stays fail-closed with the named dependency.
### Fail-closed gate summary
- Phase A gates close immediately (software, mock KMS, real crypto).
- Phase B gates stay BLOCKED on CPU TDX hardware; the agent must refuse to
assert hardware trust until B2 verifies real quotes. (Confidential-GPU / H100
is out of scope — CPU TDX CVM only.)
- Phase C gates stay BLOCKED on E1 silicon; same rule.
- At every phase, `ELIZA_TEE_REQUIRED=true` + production profile means: no
trusted evidence → no model-key, no signing, no remote-plugin sync, no private
inference. Degraded boot is allowed; silent secret release is never allowed.
---
## 8. Open decisions for a human
- **Weights encryption envelope** — AES-256-GCM vs XChaCha20-Poly1305, and
whether weights are a single blob or per-shard (per-shard lets large models
stream-decrypt without a full plaintext copy in memory). Recommend per-shard.
- **`modelWeights` measurement** — bind `model-key` release to the expected
weights digest as defense in depth? Recommend yes (defense in depth, cheap).
- **Local verifier trust root** — for local-only mode the on-device verifier is
rooted in the RoT-derived key; confirm the local verifier's measured identity
is itself in `measurements.policy`/`device` so it can't be swapped.
- **Cloud KMS as escrow** — whether cross-device key escrow via Eliza Cloud is in
scope for v1 (the chip doc lists it as optional). Out of scope unless requested.
+402
View File
@@ -0,0 +1,402 @@
{
"name": "@elizaos/agent",
"version": "2.0.3-beta.7",
"description": "Standalone elizaOS-based agent and backend server package.",
"type": "module",
"license": "MIT",
"homepage": "https://github.com/elizaos/eliza",
"repository": {
"type": "git",
"url": "https://github.com/elizaos/eliza.git",
"directory": "packages/agent"
},
"keywords": [
"eliza",
"agent",
"backend",
"runtime",
"elizaos"
],
"main": "./dist/index.js",
"files": [
"dist"
],
"engines": {
"node": ">=24.0.0"
},
"bin": {
"eliza-autonomous": "./src/bin.ts"
},
"scripts": {
"clean": "node ../scripts/rm-path-recursive.mjs dist",
"start": "bun run src/bin.ts",
"dev": "bun --hot src/bin.ts",
"prepublishOnly": "bun run build:dist",
"build": "bun run build:dist",
"gen:optional-plugin-imports": "bun run scripts/gen-optional-plugin-imports.ts",
"build:mobile": "bun run scripts/build-mobile-bundle.mjs",
"build:ios-bun": "bun run scripts/build-mobile-bundle.mjs --target=ios",
"build:ios-jsc": "bun run scripts/build-mobile-bundle.mjs --target=ios-jsc",
"verify:mobile-workspace-resolution": "bun run scripts/build-mobile-bundle.mjs --verify-workspace-resolution",
"build:docker-dist": "bun run build:dist",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"build:dist": "node scripts/assert-package-boundary-imports.mjs && node ../scripts/rm-path-recursive.mjs dist && tsc --noCheck -p tsconfig.build.json && node ../scripts/prepare-package-dist.mjs packages/agent && node ../scripts/copy-package-assets.mjs packages/agent assets && node ../scripts/rewrite-dist-relative-imports-node-esm.mjs packages/agent && node ../scripts/copy-package-assets.mjs packages/agent src/api/dynamic-view-host-external.mjs src/api/dynamic-view-host-external.d.mts src/api/public-route-audit.baseline.json",
"test:sandbox-live": "bun run scripts/live-sandbox-smoke.ts",
"lint": "bunx @biomejs/biome check --write ./src/actions ./src/api ./src/awareness ./src/bin.ts ./src/cli ./src/config ./src/contracts ./src/diagnostics ./src/hooks ./src/index.ts ./src/providers ./src/runtime ./src/security ./src/services ./src/shared ./src/triggers ./src/utils ./src/version-resolver.ts",
"lint:check": "bunx @biomejs/biome check ./src/actions ./src/api ./src/awareness ./src/bin.ts ./src/cli ./src/config ./src/contracts ./src/diagnostics ./src/hooks ./src/index.ts ./src/providers ./src/runtime ./src/security ./src/services ./src/shared ./src/triggers ./src/utils ./src/version-resolver.ts",
"format": "bunx @biomejs/biome format --write src",
"format:check": "bunx @biomejs/biome format src",
"pack:dry-run": "cd dist && npm pack --dry-run",
"test": "node scripts/run-vitest-batches.mjs",
"test:remote-capabilities": "cd ../.. && bunx vitest run packages/agent/src/services/remote-plugin-adapter.test.ts packages/agent/src/services/remote-capability-router.test.ts packages/agent/src/services/remote-capability-endpoint-provider.test.ts packages/agent/src/services/remote-capability-endpoint-conformance.test.ts packages/agent/src/services/remote-capability-cloud-sandbox.test.ts packages/agent/src/services/remote-capability-live-report.test.ts packages/agent/src/api/remote-capability-routes.test.ts packages/agent/src/__tests__/views-registry-integration.test.ts packages/core/src/capabilities/index.test.ts --coverage.enabled=false",
"test:remote-capabilities:source-build": "cd ../.. && bunx vitest run packages/agent/src/services/remote-plugin-adapter.test.ts -t \"builds a remote plugin from source|loads a built remote plugin from a separate capability server process\" --coverage.enabled=false",
"test:remote-capabilities:docker": "cd ../.. && ELIZA_REMOTE_CAPABILITY_DOCKER_SMOKE=1 bunx vitest run packages/agent/src/services/remote-plugin-adapter.test.ts -t \"Docker container capability server\" --coverage.enabled=false",
"test:remote-capabilities:cloud-live": "cd ../.. && ELIZA_REMOTE_CAPABILITY_CLOUD_LIVE=1 bunx vitest run packages/agent/src/services/remote-capability-cloud-sandbox.cloud-smoke.test.ts --coverage.enabled=false",
"test:remote-capabilities:provider-live": "cd ../.. && ELIZA_REMOTE_CAPABILITY_PROVIDER_LIVE=1 bunx vitest run packages/agent/src/services/remote-capability-url-endpoint-providers.provider-smoke.test.ts --coverage.enabled=false"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"eliza-source": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
},
"bun": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
},
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./package.json": "./package.json",
"./api": {
"types": "./src/api/index.ts",
"eliza-source": {
"types": "./src/api/index.ts",
"import": "./src/api/index.ts",
"default": "./src/api/index.ts"
},
"bun": {
"types": "./src/api/index.ts",
"import": "./src/api/index.ts",
"default": "./src/api/index.ts"
},
"import": "./dist/api/index.js",
"default": "./dist/api/index.js"
},
"./api/*": {
"types": "./src/api/*.ts",
"eliza-source": {
"types": "./src/api/*.ts",
"import": "./src/api/*.ts",
"default": "./src/api/*.ts"
},
"bun": {
"types": "./src/api/*.ts",
"import": "./src/api/*.ts",
"default": "./src/api/*.ts"
},
"import": "./dist/api/*.js",
"default": "./dist/api/*.js"
},
"./config/character-schema": "./src/config/character-schema.ts",
"./config/plugin-auto-enable": "./src/config/plugin-auto-enable.ts",
"./security/mcp-server-config": "./src/security/mcp-server-config.ts",
"./runtime": {
"types": "./src/runtime/index.ts",
"eliza-source": {
"types": "./src/runtime/index.ts",
"import": "./src/runtime/index.ts",
"default": "./src/runtime/index.ts"
},
"bun": {
"types": "./src/runtime/index.ts",
"import": "./src/runtime/index.ts",
"default": "./src/runtime/index.ts"
},
"import": "./dist/runtime/index.js",
"default": "./dist/runtime/index.js"
},
"./runtime/*": {
"types": "./src/runtime/*.ts",
"eliza-source": {
"types": "./src/runtime/*.ts",
"import": "./src/runtime/*.ts",
"default": "./src/runtime/*.ts"
},
"bun": {
"types": "./src/runtime/*.ts",
"import": "./src/runtime/*.ts",
"default": "./src/runtime/*.ts"
},
"import": "./dist/runtime/*.js",
"default": "./dist/runtime/*.js"
},
"./services/app-session-gate": {
"eliza-source": {
"types": "./src/services/app-session-gate.ts",
"import": "./src/services/app-session-gate.ts",
"default": "./src/services/app-session-gate.ts"
},
"bun": {
"types": "./src/services/app-session-gate.ts",
"import": "./src/services/app-session-gate.ts",
"default": "./src/services/app-session-gate.ts"
},
"import": "./dist/services/app-session-gate.js",
"default": "./dist/services/app-session-gate.js",
"types": "./src/services/app-session-gate.d.ts"
},
"./services/tee-evidence": {
"types": "./src/services/tee-evidence.ts",
"eliza-source": {
"types": "./src/services/tee-evidence.ts",
"import": "./src/services/tee-evidence.ts",
"default": "./src/services/tee-evidence.ts"
},
"bun": {
"types": "./src/services/tee-evidence.ts",
"import": "./src/services/tee-evidence.ts",
"default": "./src/services/tee-evidence.ts"
},
"import": "./dist/services/tee-evidence.js",
"default": "./dist/services/tee-evidence.js"
},
"./services/tee-evidence-provider": {
"types": "./src/services/tee-evidence-provider.ts",
"eliza-source": {
"types": "./src/services/tee-evidence-provider.ts",
"import": "./src/services/tee-evidence-provider.ts",
"default": "./src/services/tee-evidence-provider.ts"
},
"bun": {
"types": "./src/services/tee-evidence-provider.ts",
"import": "./src/services/tee-evidence-provider.ts",
"default": "./src/services/tee-evidence-provider.ts"
},
"import": "./dist/services/tee-evidence-provider.js",
"default": "./dist/services/tee-evidence-provider.js"
},
"./services/permissions/probers/index": {
"types": "./src/services/permissions/probers/index.d.ts",
"eliza-source": {
"types": "./src/services/permissions/probers/index.ts",
"import": "./src/services/permissions/probers/index.ts",
"default": "./src/services/permissions/probers/index.ts"
},
"bun": {
"types": "./src/services/permissions/probers/index.ts",
"import": "./src/services/permissions/probers/index.ts",
"default": "./src/services/permissions/probers/index.ts"
},
"import": "./dist/services/permissions/probers/index.js",
"default": "./dist/services/permissions/probers/index.js"
},
"./services/permissions/probers/*": {
"types": "./src/services/permissions/probers/*.d.ts",
"eliza-source": {
"types": "./src/services/permissions/probers/*.ts",
"import": "./src/services/permissions/probers/*.ts",
"default": "./src/services/permissions/probers/*.ts"
},
"bun": {
"types": "./src/services/permissions/probers/*.ts",
"import": "./src/services/permissions/probers/*.ts",
"default": "./src/services/permissions/probers/*.ts"
},
"import": "./dist/services/permissions/probers/*.js",
"default": "./dist/services/permissions/probers/*.js"
},
"./security/*": {
"types": "./src/security/*.d.ts",
"eliza-source": {
"types": "./src/security/*.ts",
"import": "./src/security/*.ts",
"default": "./src/security/*.ts"
},
"bun": {
"types": "./src/security/*.ts",
"import": "./src/security/*.ts",
"default": "./src/security/*.ts"
},
"import": "./dist/security/*.js",
"default": "./dist/security/*.js"
},
"./services/*": {
"types": "./src/services/*.d.ts",
"eliza-source": {
"types": "./src/services/*.ts",
"import": "./src/services/*.ts",
"default": "./src/services/*.ts"
},
"bun": {
"types": "./src/services/*.ts",
"import": "./src/services/*.ts",
"default": "./src/services/*.ts"
},
"import": "./dist/services/*.js",
"default": "./dist/services/*.js"
},
"./config/*": {
"types": "./src/config/*.d.ts",
"eliza-source": {
"types": "./src/config/*.ts",
"import": "./src/config/*.ts",
"default": "./src/config/*.ts"
},
"bun": {
"types": "./src/config/*.ts",
"import": "./src/config/*.ts",
"default": "./src/config/*.ts"
},
"import": "./dist/config/*.js",
"default": "./dist/config/*.js"
},
"./utils/*": {
"types": "./src/utils/*.d.ts",
"eliza-source": {
"types": "./src/utils/*.ts",
"import": "./src/utils/*.ts",
"default": "./src/utils/*.ts"
},
"bun": {
"types": "./src/utils/*.ts",
"import": "./src/utils/*.ts",
"default": "./src/utils/*.ts"
},
"import": "./dist/utils/*.js",
"default": "./dist/utils/*.js"
},
"./*.css": "./dist/*.css",
"./*": {
"types": "./dist/*.d.ts",
"eliza-source": {
"types": "./src/*.ts",
"import": "./src/*.ts",
"default": "./src/*.ts"
},
"import": "./dist/*.js",
"default": "./dist/*.js"
}
},
"publishConfig": {
"access": "public",
"directory": "dist"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.13",
"@ai-sdk/google": "^3.0.75",
"@ai-sdk/openai": "^3.0.58",
"@clack/prompts": "^1.0.0",
"@electric-sql/pglite": "^0.4.0",
"@elizaos/plugin-contacts": "workspace:*",
"@elizaos/plugin-phone": "workspace:*",
"@elizaos/plugin-wifi": "workspace:*",
"@elizaos/core": "workspace:*",
"@elizaos/plugin-agent-orchestrator": "workspace:*",
"@elizaos/plugin-agent-skills": "workspace:*",
"@elizaos/plugin-anthropic": "workspace:*",
"@elizaos/plugin-aosp-local-inference": "workspace:*",
"@elizaos/plugin-app-control": "workspace:*",
"@elizaos/plugin-app-manager": "workspace:*",
"@elizaos/plugin-background-runner": "workspace:*",
"@elizaos/plugin-browser": "workspace:*",
"@elizaos/plugin-capacitor-bridge": "workspace:*",
"@elizaos/plugin-cloud-apps": "workspace:*",
"@elizaos/plugin-coding-tools": "workspace:*",
"@elizaos/plugin-commands": "workspace:*",
"@elizaos/plugin-computeruse": "workspace:*",
"@elizaos/plugin-discord": "workspace:*",
"@elizaos/plugin-elizacloud": "workspace:*",
"@elizaos/plugin-gitpathologist": "workspace:*",
"@elizaos/plugin-imessage": "workspace:*",
"@elizaos/plugin-local-inference": "workspace:*",
"@elizaos/plugin-mcp": "workspace:*",
"@elizaos/plugin-native-filesystem": "workspace:*",
"@elizaos/plugin-openai": "workspace:*",
"@elizaos/plugin-ollama": "workspace:*",
"@elizaos/plugin-registry": "workspace:*",
"@elizaos/plugin-remote-manifest": "workspace:*",
"@elizaos/security": "workspace:*",
"@elizaos/plugin-shell": "workspace:*",
"@elizaos/plugin-pty": "workspace:*",
"@elizaos/plugin-birdclaw": "workspace:*",
"@elizaos/plugin-worker-runtime": "workspace:*",
"@elizaos/plugin-scheduling": "workspace:*",
"@elizaos/plugin-signal": "workspace:*",
"@elizaos/plugin-sql": "workspace:*",
"@elizaos/plugin-streaming": "workspace:*",
"@elizaos/plugin-task-coordinator": "workspace:*",
"@elizaos/plugin-video": "workspace:*",
"@elizaos/plugin-vision": "workspace:*",
"@elizaos/plugin-wallet": "workspace:*",
"@elizaos/plugin-whatsapp": "workspace:*",
"@elizaos/plugin-workflow": "workspace:*",
"@elizaos/plugin-x402": "workspace:*",
"@elizaos/registry": "workspace:*",
"@elizaos/shared": "workspace:*",
"@elizaos/skills": "workspace:*",
"@elizaos/vault": "workspace:*",
"@noble/curves": "2.2.0",
"@noble/hashes": "2.2.0",
"@solana/web3.js": "1.98.4",
"async-lock": "^1.4.1",
"clean-git-ref": "^2.0.1",
"crc-32": "^1.2.0",
"diff3": "0.0.4",
"drizzle-orm": "0.45.2",
"esbuild": "^0.28.0",
"ethers": "^6.16.0",
"git-workspace-service": "0.4.5",
"hono": "^4.12.18",
"ignore": "^5.1.4",
"isomorphic-git": "^1.35.0",
"jpeg-js": "^0.4.4",
"jsdom": "^29.0.0",
"json5": "^2.2.3",
"minimisted": "^2.0.0",
"pako": "^1.0.10",
"pg": "^8.16.3",
"pify": "^4.0.1",
"pngjs": "^7.0.0",
"readable-stream": "^4.0.0",
"sha.js": "^2.4.12",
"simple-get": "^4.0.1",
"ws": "^8.19.0",
"zod": "^4.4.3",
"@elizaos/auth": "workspace:*"
},
"peerDependencies": {
"@elizaos/plugin-inbox": "workspace:*"
},
"peerDependenciesMeta": {
"@elizaos/plugin-inbox": {
"optional": true
}
},
"devDependencies": {
"@types/bun": "^1.3.12",
"@types/jsdom": "^28.0.0",
"@types/node": "^25.2.0",
"@types/pg": "^8.15.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/ws": "^8.18.1",
"@lydell/node-pty": "^1.1.0",
"ai": "^6.0.134",
"bun-types": "^1.3.12",
"fast-check": "^4.8.0",
"typescript": "^6.0.3",
"vite": "^8.0.8",
"vitest": "^4.0.18"
},
"elizaos": {
"scripts": {
"testLanes": [
"server"
]
}
}
}
@@ -0,0 +1,16 @@
/**
* Types for assert-package-boundary-imports.mjs so the vitest guard test can
* import the walker under typecheck.
*/
export interface CrossPackageImportViolation {
file: string;
line: number;
specifier: string;
}
export declare const agentPackageRoot: string;
export declare function findCrossPackageImports(
packageRoot?: string,
): CrossPackageImportViolation[];
@@ -0,0 +1,105 @@
/**
* Build-time guard: no relative import in packages/agent/src may resolve
* outside the package root.
*
* A relative specifier that escapes the package (e.g.
* "../../../core/src/services/message/mute-state.ts") pulls the sibling
* package's TypeScript sources into this package's tsc program. Because those
* files sit outside `rootDir`, tsc emits their .js/.d.ts NEXT TO the sources —
* gitignored litter inside the sibling's src/ — and the emitted dist import
* ("../../../core/src/....js") only resolves while that litter and the
* sibling's src tree exist at runtime. Published or litter-cleaned installs
* crash, and stale .js shadows .ts for every tool that resolves the sibling's
* source (#13515). Cross-package code must come through the sibling's package
* entry (e.g. "@elizaos/core"), which resolves to its built dist.
*
* Runs ahead of tsc in `build:dist` / `build:docker-dist`; the same walker is
* asserted clean by src/__tests__/package-boundary-imports.test.ts.
*/
import { readdirSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
export const agentPackageRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
// `from "…"` (import/export/re-export), dynamic `import("…")`, and
// side-effect `import "…"` — only relative specifiers are captured.
const RELATIVE_SPECIFIER_RE =
/(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)["'](\.\.?\/[^"']*)["']/g;
function* walkSourceFiles(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === "dist") continue;
yield* walkSourceFiles(full);
continue;
}
if (!/\.tsx?$/.test(entry.name) || entry.name.endsWith(".d.ts")) continue;
// Mirror the build program: tsconfig.build.json excludes test files, so
// they never reach tsc emit and cannot litter a sibling package.
if (/\.test\.tsx?$/.test(entry.name)) continue;
yield full;
}
}
// Blank out comment content (newline-preserving, so reported line numbers stay
// accurate) — prose like `callers that import from "../../../../src/api/server"`
// must not read as a violation. The `[^:]` guard keeps "://" in string URLs
// from being treated as a line comment.
function stripComments(text) {
return text
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(
/(^|[^:])\/\/[^\n]*/gm,
(m, pre) => pre + " ".repeat(m.length - pre.length),
);
}
/**
* Scan every non-declaration .ts/.tsx under `packageRoot`/src and return each
* relative import whose resolved path escapes `packageRoot`.
*/
export function findCrossPackageImports(packageRoot = agentPackageRoot) {
const srcDir = path.join(packageRoot, "src");
const violations = [];
for (const file of walkSourceFiles(srcDir)) {
const text = stripComments(readFileSync(file, "utf8"));
const fileDir = path.dirname(file);
for (const match of text.matchAll(RELATIVE_SPECIFIER_RE)) {
const specifier = match[1];
const resolved = path.resolve(fileDir, specifier);
const relToPackage = path.relative(packageRoot, resolved);
if (relToPackage.startsWith("..")) {
const line = text.slice(0, match.index).split("\n").length;
violations.push({
file: path.relative(packageRoot, file),
line,
specifier,
});
}
}
}
return violations;
}
const isMain =
process.argv[1] &&
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
if (isMain) {
const violations = findCrossPackageImports();
if (violations.length > 0) {
console.error(
"✗ Relative imports escaping the package root (import via the sibling's package entry, e.g. @elizaos/core — a cross-package src import makes tsc emit .js litter into the sibling's src/ and the dist depend on it):",
);
for (const v of violations) {
console.error(` ${v.file}:${v.line} "${v.specifier}"`);
}
process.exit(1);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
#!/usr/bin/env bun
/**
* Regenerate `src/runtime/optional-plugin-imports.generated.ts` from the
* `OPTIONAL_STATIC_PLUGIN_PACKAGES` source of truth so the bundler sees literal
* `import()` specifiers without a hand-maintained if-chain.
*
* Run: bun run --cwd packages/agent gen:optional-plugin-imports
* A drift check (optional-plugins.test.ts) fails CI if this output is stale.
*/
import { spawnSync } from "node:child_process";
import { writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
OPTIONAL_STATIC_PLUGIN_PACKAGES,
renderOptionalPluginImportsModule,
} from "../src/runtime/optional-plugins.ts";
const here = path.dirname(fileURLToPath(import.meta.url));
const outPath = path.resolve(
here,
"..",
"src",
"runtime",
"optional-plugin-imports.generated.ts",
);
writeFileSync(
outPath,
renderOptionalPluginImportsModule(OPTIONAL_STATIC_PLUGIN_PACKAGES),
);
// Format so the committed file matches the repo's biome style; the drift test
// asserts on the exported keys, not text, so formatting is free to differ.
spawnSync("bunx", ["@biomejs/biome", "format", "--write", outPath], {
stdio: "inherit",
});
console.log(
`[gen-optional-plugin-imports] wrote ${OPTIONAL_STATIC_PLUGIN_PACKAGES.length} importers -> ${path.relative(path.resolve(here, ".."), outPath)}`,
);
@@ -0,0 +1,390 @@
/** Runs a live sandbox smoke path for agent plugin isolation and runtime launch behavior. */
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
import { createInterface } from "node:readline";
import { CapabilityError, type IAgentRuntime, type UUID } from "@elizaos/core";
import {
E2BRemoteCapabilityRouterService,
type E2BSandboxFactory,
type SandboxRunnerProvider,
} from "../src/services/e2b-capability-router.ts";
async function loadE2BSandboxFactory(
runtime: IAgentRuntime,
): Promise<E2BSandboxFactory | undefined> {
try {
const mod = await import("@elizaos/plugin-e2b-sandbox");
return await mod.E2BSandboxFactoryService.start(runtime);
} catch {
return undefined;
}
}
type SmokeTarget = SandboxRunnerProvider | "codex-app-server";
type JsonRecord = Record<string, unknown>;
type SmokeOutcome = {
target: SmokeTarget;
status: "passed" | "skipped" | "failed";
message: string;
};
const requestedTarget = readArg("target") ?? readArg("provider") ?? "all";
const strict =
hasArg("strict") || process.env.ELIZA_SANDBOX_LIVE_STRICT === "1";
const targets = resolveTargets(requestedTarget);
const outcomes: SmokeOutcome[] = [];
for (const target of targets) {
try {
outcomes.push(await runTarget(target));
} catch (error) {
// A CAPABILITY_UNAVAILABLE thrown from the router means the provider is not
// configured/installed in this environment (e.g. the e2b factory plugin is
// absent, or credentials are partial) — that is a SKIP, not a failure. The
// `strict && skipped` gate below still fails-closed under --strict. Any
// other error is a genuine smoke failure and stays `failed`.
outcomes.push({
target,
status: isCapabilityUnavailable(error) ? "skipped" : "failed",
message: error instanceof Error ? error.message : String(error),
});
}
}
for (const outcome of outcomes) {
process.stdout.write(
`${outcome.status.toUpperCase()} ${outcome.target}: ${outcome.message}\n`,
);
}
const failures = outcomes.filter((outcome) => outcome.status === "failed");
const skipped = outcomes.filter((outcome) => outcome.status === "skipped");
if (failures.length > 0 || (strict && skipped.length > 0)) {
process.exitCode = 1;
}
async function runTarget(target: SmokeTarget): Promise<SmokeOutcome> {
if (target === "codex-app-server") return runCodexAppServerSmoke();
return runSandboxProviderSmoke(target);
}
async function runSandboxProviderSmoke(
provider: SandboxRunnerProvider,
): Promise<SmokeOutcome> {
if (provider === "eliza-cloud" && !hasElizaCloudRunnerTarget()) {
return {
target: provider,
status: "skipped",
message:
"Eliza Cloud runner requires ELIZA_CLOUD_CODING_REMOTE_RUNNER_IMAGE or a direct runner URL.",
};
}
const runtime = makeRuntime({ ELIZA_CODING_REMOTE_RUNNER: provider });
// The e2b (`e2b.dev`) SDK backend lives in the optional
// `@elizaos/plugin-e2b-sandbox` plugin (not in `@elizaos/agent`); inject its
// sandbox factory so the router can reach the e2b provider in this smoke.
const factory =
provider === "e2b" ? await loadE2BSandboxFactory(runtime) : undefined;
// In non-strict mode a provider that cannot initialize is a SKIP, not a
// failure: `--strict` is the fail-closed switch (the workflow only passes it
// on manual dispatch), so a routine push smoke must stay green when a
// provider is genuinely unconfigurable in this environment. Here the e2b
// backend is unreachable whenever its optional plugin (and the `e2b` SDK it
// pulls in) is not installed — surface that as a clear skip up front rather
// than letting the router throw CAPABILITY_UNAVAILABLE mid-run and reading as
// a hard failure.
if (provider === "e2b" && !factory) {
return {
target: provider,
status: "skipped",
message:
"e2b backend unavailable: @elizaos/plugin-e2b-sandbox is not installed (add the plugin, or run with --strict to fail-close).",
};
}
const service = new E2BRemoteCapabilityRouterService(
runtime,
undefined,
factory,
);
const availability = await service.availability();
if (!availability.available) {
return {
target: provider,
status: "skipped",
message: availability.reason ?? "provider is not configured",
};
}
const stamp = new Date().toISOString().replace(/[:.]/g, "-");
const filePath = `.eliza-sandbox-live-${stamp}.txt`;
const text = `eliza sandbox live ${provider} ${stamp}\n`;
const command = await service.pty.runCommand({
command: "sh",
args: ["-lc", "printf eliza-sandbox-live"],
cwd: ".",
timeoutMs: 60_000,
});
if (
command.exitCode !== 0 ||
!command.output.includes("eliza-sandbox-live")
) {
throw new Error(
`pty command failed with exitCode=${String(command.exitCode)}`,
);
}
await service.fs.writeText({ path: filePath, text, overwrite: true });
const read = await service.fs.readText({ path: filePath, maxBytes: 4096 });
if (read.text !== text) {
throw new Error(`fs read/write mismatch for ${filePath}`);
}
const list = await service.fs.list({
path: ".",
includeHidden: true,
limit: 200,
});
if (!list.entries.some((entry) => entry.name === filePath)) {
throw new Error(`fs list did not include ${filePath}`);
}
const git = await service.git.commandRun({ root: ".", args: ["--version"] });
if (
git.operation.status !== "completed" ||
!git.operation.stdout.toLowerCase().includes("git")
) {
throw new Error("git command route did not return a git version");
}
await service.stop();
return {
target: provider,
status: "passed",
message: "fs, pty, and git routes completed against live provider",
};
}
async function runCodexAppServerSmoke(): Promise<SmokeOutcome> {
const codex = process.env.CODEX_BIN?.trim() || "codex";
if (!(await commandAvailable(codex))) {
return {
target: "codex-app-server",
status: "skipped",
message: `${codex} is not available on PATH`,
};
}
const server = spawn(codex, ["app-server", "--listen", "stdio://"], {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env, NO_COLOR: "1" },
});
const client = new JsonRpcClient(server);
try {
const initialize = await client.request("initialize", {
clientInfo: {
name: "eliza_sandbox_live_smoke",
title: "Eliza sandbox live smoke",
version: "0.1.0",
},
});
requireObject(initialize, "initialize result");
client.notify("initialized", {});
const models = await client.request("model/list", {
limit: 5,
includeHidden: false,
});
requireObject(models, "model/list result");
const account = await client.request("account/read", {
refreshToken: false,
});
requireObject(account, "account/read result");
return {
target: "codex-app-server",
status: "passed",
message:
"stdio app-server initialized and responded to model/account RPCs",
};
} finally {
await client.close();
}
}
class JsonRpcClient {
private nextId = 1;
private readonly pending = new Map<
number,
{
resolve(value: unknown): void;
reject(error: Error): void;
}
>();
private stderr = "";
constructor(private readonly server: ChildProcessWithoutNullStreams) {
const lines = createInterface({ input: server.stdout });
lines.on("line", (line) => this.onLine(line));
server.stderr.on("data", (chunk: Buffer) => {
this.stderr = `${this.stderr}${chunk.toString("utf8")}`.slice(-4000);
});
server.once("error", (error) => {
for (const pending of this.pending.values()) pending.reject(error);
this.pending.clear();
});
server.once("exit", (code, signal) => {
const error = new Error(
`codex app-server exited code=${String(code)} signal=${String(signal)} ${this.stderr}`.trim(),
);
for (const pending of this.pending.values()) pending.reject(error);
this.pending.clear();
});
}
request(method: string, params: JsonRecord): Promise<unknown> {
const id = this.nextId++;
const promise = new Promise<unknown>((resolve, reject) => {
this.pending.set(id, { resolve, reject });
});
this.write({ id, method, params });
return withTimeout(promise, 20_000, `${method} timed out`);
}
notify(method: string, params: JsonRecord): void {
this.write({ method, params });
}
async close(): Promise<void> {
if (this.server.exitCode !== null || this.server.signalCode !== null)
return;
this.server.kill("SIGTERM");
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
if (this.server.exitCode === null && this.server.signalCode === null) {
this.server.kill("SIGKILL");
}
resolve();
}, 1500);
this.server.once("exit", () => {
clearTimeout(timer);
resolve();
});
});
}
private onLine(line: string): void {
let message: unknown;
try {
message = JSON.parse(line) as unknown;
} catch {
return;
}
if (!isObject(message) || typeof message.id !== "number") return;
const pending = this.pending.get(message.id);
if (!pending) return;
this.pending.delete(message.id);
if (isObject(message.error)) {
pending.reject(
new Error(String(message.error.message ?? "Codex RPC error")),
);
return;
}
pending.resolve(message.result);
}
private write(message: JsonRecord): void {
this.server.stdin.write(`${JSON.stringify(message)}\n`);
}
}
function makeRuntime(settings: Record<string, string>): IAgentRuntime {
const runtime: Partial<IAgentRuntime> = {
agentId: "11111111-1111-1111-1111-111111111111" as UUID,
character: { name: "Sandbox Live Smoke" },
getSetting: (key: string) => settings[key] ?? process.env[key],
getService: () => null,
};
return runtime as IAgentRuntime;
}
function resolveTargets(value: string): SmokeTarget[] {
if (value === "all") {
return ["e2b", "eliza-cloud", "home", "codex-app-server"];
}
if (
value === "e2b" ||
value === "eliza-cloud" ||
value === "home" ||
value === "codex-app-server"
) {
return [value];
}
throw new Error(`Unsupported sandbox live target: ${value}`);
}
function readArg(name: string): string | undefined {
const prefix = `--${name}=`;
return process.argv
.slice(2)
.find((arg) => arg.startsWith(prefix))
?.slice(prefix.length);
}
function hasArg(name: string): boolean {
return process.argv.slice(2).includes(`--${name}`);
}
function hasElizaCloudRunnerTarget(): boolean {
return Boolean(
readEnv("ELIZA_CLOUD_SANDBOX_BASE_URL") ||
readEnv("ELIZA_CLOUD_REMOTE_RUNNER_URL") ||
readEnv("ELIZA_CLOUD_RUNNER_URL") ||
readEnv("ELIZA_CLOUD_SANDBOX_IMAGE") ||
readEnv("ELIZA_CLOUD_CODING_REMOTE_RUNNER_IMAGE") ||
readEnv("ELIZA_CODING_REMOTE_RUNNER_IMAGE"),
);
}
function readEnv(key: string): string | undefined {
const value = process.env[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
async function commandAvailable(command: string): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn(command, ["--help"], {
stdio: "ignore",
env: { ...process.env, NO_COLOR: "1" },
});
child.once("error", () => resolve(false));
child.once("exit", (code) => resolve(code === 0));
});
}
function requireObject(value: unknown, label: string): JsonRecord {
if (!isObject(value)) throw new Error(`${label} was not an object`);
return value;
}
function isObject(value: unknown): value is JsonRecord {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function isCapabilityUnavailable(error: unknown): boolean {
return (
error instanceof CapabilityError && error.code === "CAPABILITY_UNAVAILABLE"
);
}
async function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
message: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => reject(new Error(message)), timeoutMs);
});
try {
return await Promise.race([promise, timeout]);
} finally {
if (timer) clearTimeout(timer);
}
}
@@ -0,0 +1,80 @@
// Mobile agent bundle shim for the app-core runtime hooks that the on-device
// agent can safely import without pulling in the desktop dashboard stack.
"use strict";
// Mobile agent bundle shim for @elizaos/app-core.
//
// The app-core package root is a broad desktop/node barrel that re-exports UI,
// registry, payment, training, and server modules. The on-device agent runtime
// only imports a small boot-hook surface from it, and most of that surface is
// disabled on mobile before use. Keeping this shim bundle-only prevents the iOS
// full-Bun payload from parsing the entire desktop app stack at startup.
const noopAsync = async () => undefined;
const vault = {
has: async () => false,
reveal: async () => "",
set: noopAsync,
remove: noopAsync,
};
function resolveRuntimeMode(config) {
const target =
config && typeof config === "object" ? config.deploymentTarget : null;
const runtime =
target && typeof target === "object" && typeof target.runtime === "string"
? target.runtime
: "local";
if (runtime === "remote") {
const remoteApiBase =
typeof target.remoteApiBase === "string" ? target.remoteApiBase : null;
return {
mode: "remote",
deploymentTarget: target,
remoteApiBase,
remoteApiBaseError: remoteApiBase ? null : "remoteApiBase is required",
remoteAccessToken:
typeof target.remoteAccessToken === "string"
? target.remoteAccessToken
: null,
};
}
if (runtime === "cloud") {
return {
mode: "cloud",
deploymentTarget: target,
remoteApiBase: null,
remoteApiBaseError: null,
remoteAccessToken: null,
};
}
const cloud = config && typeof config === "object" ? config.cloud : null;
return {
mode:
cloud && typeof cloud === "object" && cloud.enabled === false
? "local-only"
: "local",
deploymentTarget: target,
remoteApiBase: null,
remoteApiBaseError: null,
remoteAccessToken: null,
};
}
function getBuildVariant() {
return process.env.ELIZA_BUILD_VARIANT === "store" ? "store" : "direct";
}
module.exports = {
hydrateWalletKeysFromNodePlatformSecureStore: noopAsync,
runVaultBootstrap: async () => ({ migrated: 0, failed: [] }),
sharedVault: () => vault,
getDefaultAccountPool: () => null,
applyAccountPoolApiCredentials: noopAsync,
startAccountPoolKeepAlive: () => undefined,
ensureLocalInferenceHandler: noopAsync,
resolveRuntimeMode,
getBuildVariant,
isStoreBuild: () => getBuildVariant() === "store",
};
@@ -0,0 +1,24 @@
// @node-rs/argon2 stub for the Android mobile agent bundle.
//
// The native package emits a host-specific `.node` binary during Bun.build.
// That is unusable on Android and must never be staged into `dist-mobile`.
// Mobile does not run the desktop password-auth endpoints; if a route reaches
// this surface, fail closed instead of silently accepting a password.
"use strict";
const NOT_AVAILABLE_MSG =
"@node-rs/argon2 is not available in the Android mobile agent bundle";
async function unavailable() {
throw new Error(NOT_AVAILABLE_MSG);
}
module.exports = {
Algorithm: {
Argon2d: 0,
Argon2i: 1,
Argon2id: 2,
},
hash: unavailable,
verify: unavailable,
};
@@ -0,0 +1,25 @@
// canvas stub for the mobile agent bundle.
"use strict";
const NOT_AVAILABLE_MSG =
"canvas is not available in the Android mobile bundle";
function unavailable() {
throw new Error(NOT_AVAILABLE_MSG);
}
module.exports = {
__mobileStub: true,
createCanvas: unavailable,
loadImage: unavailable,
Canvas: class {
constructor() {
unavailable();
}
},
Image: class {
constructor() {
unavailable();
}
},
};
@@ -0,0 +1,8 @@
// Mobile bundle stub for the E2B capability router registration path, which is
// unavailable in the on-device agent runtime.
module.exports = {
registerE2BRemoteCapabilityRouterIfEnabled: async () => ({
registered: false,
reason: "mobile-bundle",
}),
};
@@ -0,0 +1,58 @@
// Compact embedding preset table for mobile agent bundles, pinned to the small
// CPU-safe local embedding model.
"use strict";
const COMPACT_ELIZA_1_EMBEDDING = {
model: "gte-small_fp16.gguf",
modelRepo: "ChristianAzinn/gte-small-gguf",
dimensions: 384,
gpuLayers: 0,
contextSize: 512,
downloadSizeMB: 64,
};
const EMBEDDING_PRESETS = {
fallback: {
tier: "fallback",
label: "Efficient (mobile CPU)",
description: "gte-small local embeddings for the mobile agent bundle",
...COMPACT_ELIZA_1_EMBEDDING,
},
standard: {
tier: "standard",
label: "Efficient (mobile)",
description: "gte-small local embeddings for the mobile agent bundle",
...COMPACT_ELIZA_1_EMBEDDING,
},
performance: {
tier: "performance",
label: "Efficient (mobile)",
description: "gte-small local embeddings for the mobile agent bundle",
...COMPACT_ELIZA_1_EMBEDDING,
},
};
function detectEmbeddingTier() {
return "fallback";
}
function detectEmbeddingPreset() {
return EMBEDDING_PRESETS.fallback;
}
function selectEmbeddingTierFromHardware() {
return "fallback";
}
function selectEmbeddingPresetFromHardware() {
return EMBEDDING_PRESETS.fallback;
}
module.exports = {
COMPACT_ELIZA_1_EMBEDDING,
EMBEDDING_PRESETS,
detectEmbeddingPreset,
detectEmbeddingTier,
selectEmbeddingPresetFromHardware,
selectEmbeddingTierFromHardware,
};
@@ -0,0 +1,6 @@
// empty.cjs — used by the mobile bundle to stub CSS imports.
// The agent never renders pixels on-device; CSS pulled transitively
// through host-specific UI modules has no
// runtime effect.
"use strict";
module.exports = {};
@@ -0,0 +1,36 @@
// ios-bun-spawn stub for the iOS Bun-port agent bundle.
//
// `Bun.spawn` and `Bun.spawnSync` are inert on iOS for the same reason
// `child_process.spawn` is inert: the sandbox forbids it. The Bun fork for
// iOS removes the underlying syscall but still exposes the API surface so
// imports don't break — this stub provides the JS-side error semantics.
//
// References:
// - native/ios-bun-port/PLATFORM_MATRIX.md
// - native/ios-bun-port/milestones/M05-audit-syscalls.md
"use strict";
const NOT_AVAILABLE_MSG =
"Bun.spawn is not available on iOS — the iOS sandbox forbids subprocess creation.";
function spawn(_argv, _opts) {
throw new Error(NOT_AVAILABLE_MSG);
}
function spawnSync(_argv, _opts) {
return {
success: false,
exitCode: 127,
signalCode: null,
stdout: new Uint8Array(),
stderr: new TextEncoder().encode(NOT_AVAILABLE_MSG),
pid: -1,
resourceUsage: null,
};
}
module.exports = {
__iosStub: true,
spawn,
spawnSync,
};
@@ -0,0 +1,120 @@
// ios-child-process stub for the iOS Bun-port agent bundle.
//
// iOS sandbox forbids subprocess creation (posix_spawn / fork / exec are
// blocked at the kernel level). Any agent code that imports `node:child_process`
// or calls `Bun.spawn` will hit this stub on iOS builds.
//
// The stub does NOT throw at module load time — that would prevent the
// agent from booting if any transitive dep imports `node:child_process`
// (e.g., a logger that uses `child_process` for stack-tracing). Instead,
// each export throws with a useful message when actually called.
//
// Used by ios-target builds; see eliza/packages/agent/scripts/build-mobile-bundle.mjs
// --target=ios.
//
// References:
// - native/ios-bun-port/PLATFORM_MATRIX.md
// - native/ios-bun-port/milestones/M05-audit-syscalls.md
"use strict";
const { EventEmitter } = require("node:events");
const NOT_AVAILABLE_MSG =
"child_process is not available on iOS — the iOS sandbox forbids subprocess creation. " +
"Refactor to use in-process logic, a Capacitor native plugin, or an HTTP call to a remote service.";
function unavailable() {
throw new Error(NOT_AVAILABLE_MSG);
}
class UnavailableChildProcess extends EventEmitter {
constructor() {
super();
this.pid = -1;
this.killed = false;
this.exitCode = null;
this.signalCode = null;
// Defer the throw to the next tick so callers that try to wire up
// listeners on the returned object don't crash before they get the
// chance. Emit 'error' instead of throwing synchronously to match
// Node.js behavior for spawn errors.
queueMicrotask(() => {
this.emit("error", new Error(NOT_AVAILABLE_MSG));
this.emit("exit", 127, null);
this.emit("close", 127, null);
});
}
kill() {
return false;
}
ref() {}
unref() {}
disconnect() {}
send() {
return false;
}
}
function spawn(_command, _args, _options) {
return new UnavailableChildProcess();
}
function spawnSync() {
return {
pid: -1,
output: [null, null, Buffer.from(NOT_AVAILABLE_MSG)],
stdout: Buffer.alloc(0),
stderr: Buffer.from(NOT_AVAILABLE_MSG),
status: 127,
signal: null,
error: new Error(NOT_AVAILABLE_MSG),
};
}
function exec(_cmd, _opts, cb) {
const child = new UnavailableChildProcess();
if (typeof cb === "function") {
queueMicrotask(() =>
cb(new Error(NOT_AVAILABLE_MSG), "", NOT_AVAILABLE_MSG),
);
}
return child;
}
function execSync() {
unavailable();
}
function execFile(_file, _args, _opts, cb) {
const child = new UnavailableChildProcess();
if (typeof cb === "function") {
queueMicrotask(() =>
cb(new Error(NOT_AVAILABLE_MSG), "", NOT_AVAILABLE_MSG),
);
}
return child;
}
function execFileSync() {
unavailable();
}
function fork() {
return new UnavailableChildProcess();
}
module.exports = {
__iosStub: true,
spawn,
spawnSync,
exec,
execSync,
execFile,
execFileSync,
fork,
ChildProcess: UnavailableChildProcess,
};
// Named exports for ESM-style imports
module.exports.default = module.exports;
@@ -0,0 +1,92 @@
// ios-ffi stub for the iOS Bun-port agent bundle.
//
// `bun:ffi` on iOS is locked down to statically-linked symbols only. The
// runtime allow-list (see native/ios-bun-port/stubs/ios-ffi-allowlist.zig)
// gates which symbols can be resolved. This JS-side stub exposes the same
// API surface as desktop Bun's `bun:ffi`, but:
//
// - `cc()` (the TinyCC C-compiler shim) throws — TinyCC is excluded from
// the iOS Bun build (see milestones/M05).
// - `dlopen(path)` with an absolute path throws — only `dlopen(null)`
// and `dlopen(<sandbox-allowed-system-framework>)` are accepted.
// - `read.<type>`, `FFIType`, `suffix` and the rest are pass-through to
// the underlying runtime implementation (which gates symbols).
//
// On real iOS Bun this stub is NOT loaded — the runtime exposes `bun:ffi`
// natively. This stub exists so the same agent JS bundle can run on
// desktop dev environments (where it would shadow `bun:ffi` and surface
// the iOS-only constraints early).
//
// References:
// - native/ios-bun-port/PLATFORM_MATRIX.md
// - native/ios-bun-port/stubs/ios-ffi-allowlist.zig
"use strict";
const NOT_AVAILABLE_MSG =
"bun:ffi.cc is not available on iOS — TinyCC is excluded from the iOS Bun build. " +
"Pre-compile any FFI C code and ship it as a statically-linked library.";
const ABSOLUTE_DLOPEN_MSG =
"bun:ffi.dlopen of arbitrary disk paths is forbidden on iOS. " +
"Pass null to dlopen for in-binary symbols, or a public iOS system framework path.";
function isAllowedDlopenTarget(path) {
if (path === null || path === undefined) return true; // in-binary symbols
if (typeof path !== "string") return false;
// Allow loading from /System/Library/ (Apple system frameworks).
if (path.startsWith("/System/Library/")) return true;
// Allow loading from /usr/lib/system/ (Apple libSystem etc.).
if (path.startsWith("/usr/lib/system/")) return true;
return false;
}
function dlopen(path, _symbols) {
if (!isAllowedDlopenTarget(path)) {
throw new Error(ABSOLUTE_DLOPEN_MSG);
}
// Pass through to native runtime; in the simulator / desktop fallback
// case, throw because we can't actually open arbitrary symbols.
throw new Error(
"bun:ffi.dlopen passthrough requires the native iOS Bun runtime. " +
"This stub indicates the agent bundle is being run outside the iOS Bun port.",
);
}
function cc(_options) {
throw new Error(NOT_AVAILABLE_MSG);
}
const FFIType = {
char: "char",
int8_t: "int8_t",
uint8_t: "uint8_t",
int16_t: "int16_t",
uint16_t: "uint16_t",
int32_t: "int32_t",
uint32_t: "uint32_t",
int64_t: "int64_t",
uint64_t: "uint64_t",
float: "float",
double: "double",
bool: "bool",
ptr: "ptr",
cstring: "cstring",
function: "function",
void: "void",
};
module.exports = {
__iosStub: true,
dlopen,
cc,
FFIType,
suffix: "a", // iOS uses static archives
read: new Proxy({}, { get: () => () => 0 }),
ptr: () => 0,
toBuffer: () => new Uint8Array(),
toArrayBuffer: () => new ArrayBuffer(0),
CString: class CString extends String {},
CFunction: () => () => {
throw new Error(NOT_AVAILABLE_MSG);
},
};
@@ -0,0 +1,71 @@
// node:dns stub for the iOS JSContext runtime.
//
// URLSession (used by the Swift Capacitor plugin's fetch implementation)
// handles DNS resolution internally, so the agent never needs to resolve
// hostnames itself. This stub returns the input hostname unresolved (as
// if it were already an IP) so any defensive code that calls dns.lookup
// keeps moving rather than crashing.
"use strict";
function lookup(hostname, optionsOrCb, maybeCb) {
const cb = typeof optionsOrCb === "function" ? optionsOrCb : maybeCb;
if (typeof cb === "function") {
queueMicrotask(() => cb(null, hostname, 4));
return;
}
return Promise.resolve({ address: hostname, family: 4 });
}
function resolve(hostname, _rrtype, cb) {
const callback = typeof _rrtype === "function" ? _rrtype : cb;
if (typeof callback === "function") {
queueMicrotask(() => callback(null, [hostname]));
return;
}
return Promise.resolve([hostname]);
}
const noopPromise = () => Promise.resolve([]);
const promises = {
lookup: (hostname) => Promise.resolve({ address: hostname, family: 4 }),
resolve: (hostname) => Promise.resolve([hostname]),
resolve4: (hostname) => Promise.resolve([hostname]),
resolve6: (hostname) => Promise.resolve([hostname]),
resolveCname: noopPromise,
resolveMx: noopPromise,
resolveNs: noopPromise,
resolveTxt: noopPromise,
reverse: (ip) => Promise.resolve([ip]),
Resolver: class Resolver {
setServers() {}
getServers() {
return [];
}
},
};
module.exports = {
__iosJscStub: true,
lookup,
resolve,
resolve4: resolve,
resolve6: resolve,
resolveCname: (_h, cb) =>
typeof cb === "function" ? cb(null, []) : Promise.resolve([]),
resolveMx: (_h, cb) =>
typeof cb === "function" ? cb(null, []) : Promise.resolve([]),
resolveNs: (_h, cb) =>
typeof cb === "function" ? cb(null, []) : Promise.resolve([]),
resolveTxt: (_h, cb) =>
typeof cb === "function" ? cb(null, []) : Promise.resolve([]),
reverse: (ip, cb) =>
typeof cb === "function" ? cb(null, [ip]) : Promise.resolve([ip]),
promises,
Resolver: promises.Resolver,
ADDRCONFIG: 32,
V4MAPPED: 8,
ALL: 16,
};
module.exports.default = module.exports;
@@ -0,0 +1,33 @@
// Generic throw-on-use stub for Node built-ins not supported by the iOS
// JSContext runtime via __ELIZA_BRIDGE__ v1.
//
// Currently used for: node:net, node:tls, node:dgram, node:cluster,
// worker_threads. The Swift Capacitor plugin in
// plugins/plugin-native-bun-runtime/ exposes a fetch-based HTTP/HTTPS
// surface plus a WebSocket bridge, but raw TCP servers, TLS sockets,
// UDP datagrams, cluster fork, and worker threads are not in the v1
// bridge surface. Code that reaches into these modules on ios-jsc gets
// a loud, actionable error rather than a silent no-op.
"use strict";
const NOT_SUPPORTED =
"This Node module is not available in the iOS JSContext runtime (ios-jsc). " +
"Use fetch() / WebSocket via __ELIZA_BRIDGE__ instead, or gate the call " +
"behind a process.env.ELIZA_RUNTIME !== 'ios-jsc' check.";
function throwUnsupported() {
throw new Error(NOT_SUPPORTED);
}
const handler = {
get(_target, prop) {
if (prop === "__iosJscStub") return true;
if (prop === "default") return module.exports;
if (prop === "__esModule") return true;
return throwUnsupported;
},
apply: throwUnsupported,
construct: throwUnsupported,
};
module.exports = new Proxy(() => {}, handler);
@@ -0,0 +1,39 @@
// ios-os stub for the iOS Bun-port agent bundle.
//
// Routes os.homedir() / os.tmpdir() to the iOS app sandbox via
// environment variables set by ElizaBunRuntime.swift at startup:
//
// ELIZA_IOS_HOME → ~/Library/Application Support/Eliza/
// ELIZA_IOS_TMP → NSTemporaryDirectory()
// ELIZA_IOS_DOCUMENTS → ~/Documents/
// ELIZA_IOS_CACHES → ~/Library/Caches/Eliza/
// ELIZA_IOS_APP_SUPPORT → ~/Library/Application Support/Eliza/
//
// The host (Swift code) sets these before bun_embedded_run() so any code
// that calls os.homedir() gets a sandbox-correct path without needing to
// know it's running on iOS.
//
// Falls back to the real `node:os` module when the env vars aren't set —
// useful for desktop dev / Android where this stub is not loaded.
"use strict";
const realOs = require("node:os");
function pickEnv(key, fallback) {
const v = process.env[key];
return typeof v === "string" && v.length > 0 ? v : fallback;
}
module.exports = {
...realOs,
homedir: () => pickEnv("ELIZA_IOS_APP_SUPPORT", realOs.homedir()),
tmpdir: () => pickEnv("ELIZA_IOS_TMP", realOs.tmpdir()),
platform: () => "ios",
type: () => "Darwin",
release: () => pickEnv("ELIZA_IOS_VERSION", realOs.release()),
hostname: () => pickEnv("ELIZA_IOS_HOSTNAME", "ios-device"),
// arch, cpus, totalmem, freemem, networkInterfaces all defer to the real
// module — they work correctly on iOS via standard syscalls.
};
module.exports.default = module.exports;
@@ -0,0 +1,45 @@
// llama-cpp-capacitor stub for the mobile agent bundle.
//
// llama-cpp-capacitor is the WebView-side Capacitor JNI binding for iOS and
// the Capacitor Android variant of the local inference plugin. The bun
// agent process running on AOSP does NOT use it — on Android the runtime
// goes through `bun:ffi` against `libllama.so` + `libeliza-llama-shim.so`
// directly via `aosp-llama-adapter.ts`. The only consumer of this package
// is `plugins/plugin-native-llama/src/capacitor-llama-adapter.ts`, which
// dynamically imports it inside the WebView path. Bun.build still has to
// resolve the import statically; the stub keeps the bundle building while
// guaranteeing the WebView path throws clearly if it's ever hit on AOSP.
"use strict";
const NOT_AVAILABLE_MSG =
"llama-cpp-capacitor is not available on the bun-side AOSP agent — local inference goes through bun:ffi + libllama.so via aosp-llama-adapter.ts";
function unavailable() {
throw new Error(NOT_AVAILABLE_MSG);
}
const capabilities = Object.freeze({
provider: "llama-cpp-capacitor",
available: false,
platform: "aosp-bun-mobile-stub",
text: false,
embeddings: false,
vision: false,
mmproj: false,
imagegen: false,
reason: NOT_AVAILABLE_MSG,
});
module.exports = {
__mobileStub: true,
__mobileCapabilities: capabilities,
capabilities,
LlamaCpp: new Proxy(
{},
{
get() {
return unavailable;
},
},
),
};
@@ -0,0 +1,11 @@
// Mobile bundle stub for DOCX extraction, which is not shipped into the
// on-device agent runtime.
"use strict";
async function extractRawText() {
throw new Error("DOCX extraction is unavailable in the mobile agent bundle");
}
module.exports = {
extractRawText,
};
@@ -0,0 +1,61 @@
// node-llama-cpp stub for the mobile agent bundle.
//
// The mobile agent does not load node-llama-cpp or its platform prebuilds.
// Android AOSP local inference goes through bun:ffi + libllama.so, while the
// Capacitor app path uses the WebView-side llama-cpp-capacitor binding.
"use strict";
const NOT_AVAILABLE_MSG =
"node-llama-cpp is not available in mobile agent bundles";
function unavailable() {
throw new Error(NOT_AVAILABLE_MSG);
}
class LlamaModel {
constructor() {
unavailable();
}
}
class LlamaContext {
constructor() {
unavailable();
}
}
class LlamaChatSession {
constructor() {
unavailable();
}
}
const capabilities = Object.freeze({
provider: "node-llama-cpp",
available: false,
platform: "mobile-stub",
reason: NOT_AVAILABLE_MSG,
});
module.exports = {
__mobileStub: true,
__mobileCapabilities: capabilities,
capabilities,
LlamaModel,
LlamaContext,
LlamaChatSession,
getLlama: unavailable,
getLlamaForOptions: unavailable,
getLlamaForOptionsOrDownload: unavailable,
default: {
__mobileStub: true,
__mobileCapabilities: capabilities,
capabilities,
LlamaModel,
LlamaContext,
LlamaChatSession,
getLlama: unavailable,
getLlamaForOptions: unavailable,
getLlamaForOptionsOrDownload: unavailable,
},
};
@@ -0,0 +1,118 @@
// null-plugin.cjs — used by the mobile bundle for optional @elizaos plugins
// that pull in desktop-only transitive deps (e.g. plugin-cli
// which pins old @elizaos/core, plugin-shell drags in PTY, plugin-pdf needs
// canvas, etc.).
//
// The agent runtime references these packages in three ways:
//
// 1. Top-level `require()` in try/catch — `if (pluginShell) { ... }` etc.
// We can't `module.exports = null` because Bun's `__toESM(mod, 1)`
// helper (used to wrap CJS for ESM `import * as X`) calls
// `__getOwnPropNames(mod)` which throws on null.
//
// 2. ESM namespace and named imports. Some named imports are *invoked* (e.g.
// `wireCoordinatorBridgesWhenReady(state, ...)` in api/server.ts).
// A bare `module.exports = {}` would leave those bindings as
// `undefined` and crash the call.
//
// Solution: a Proxy-backed module where every property access returns a
// no-op function or another stub Proxy. This satisfies both shapes:
// `findRuntimePluginExport` still returns null (the proxy has no
// plugin-shaped fields), but any direct function call short-circuits to
// `undefined`.
"use strict";
const NOOP_FN = function noopStub() {
return undefined;
};
// Use a plain object as the proxy target. Bun's `__toESM` calls
// `Object.getOwnPropertyNames(mod)` on the result of `require()` to
// build the ESM namespace; a function-target Proxy fails that check
// because functions have a non-configurable `prototype` that the
// `ownKeys` trap must include. Plain objects don't have that constraint.
function makeStubProxy() {
// Pre-define the property names that agent-side bundlers (Bun.build,
// and Node-style `__toESM`) rebuild a namespace from at module-load
// time. `__toESM(mod, 1)` reads `Object.getOwnPropertyNames(mod)` and
// uses THAT list — the Proxy `get` trap is bypassed for unknown names,
// so any property that isn't an own key on `target` ends up `undefined`
// in the destructured ESM namespace, no matter how clever the trap is.
//
// Production failure that surfaced this: the agent's plugin-routes
// ctx-object pulled `applyWhatsAppQrOverride: import_plugin_whatsapp3
// .applyWhatsAppQrOverride`, but the namespace had no own keys, so
// the destructure produced `undefined`, and runtime crashed with
// `applyWhatsAppQrOverride3 is not a function`.
//
// Fix: pre-populate the target with no-op functions for every name the
// agent's transitive imports destructure off these stubs. The trap then
// only handles dynamic access (still NOOP_FN), keeping
// findRuntimePluginExport's plugin-shape probe inert.
const PRE_POPULATED_NAMES = [
// plugin-whatsapp surface used by agent api/server.ts +
// plugin-routes.ts
"applyWhatsAppQrOverride",
"handleWhatsAppRoute",
"WHATSAPP_MAX_PAIRING_SESSIONS",
// plugin-signal surface (same routing pattern)
"applySignalQrOverride",
"handleSignalRoute",
// plugin-discord-local (api server)
"handleDiscordLocalRoute",
// plugin-computeruse (api server route handler)
"handleComputerUseRoutes",
// plugin-workflow route surface. Mobile does not host the workflow
// runtime, but api/server.ts still awaits this optional route hook before
// falling through to normal conversation routes.
"handleTriggerRoutes",
// plugin-x402 route helpers
"createPaymentAwareHandler",
"isRoutePaymentWrapped",
// plugin-mcp / plugin-streaming used elsewhere; safer to populate
// since some are stubbed via `optionalPluginStubs`.
"handleMcpRoutes",
"handleTtsRoutes",
"validateX402Startup",
// streamManager has both function- and method-shaped consumers
// (streamManager.list(), streamManager.attach(...) in some paths,
// and direct call sites elsewhere). Match the live shape with a
// function that doubles as a method bag.
"streamManager",
];
const target = {};
for (const name of PRE_POPULATED_NAMES) {
target[name] = NOOP_FN;
}
return new Proxy(target, {
get(t, prop) {
if (Object.hasOwn(t, prop)) return t[prop];
if (prop === "default") return makeStubProxy();
if (prop === "__esModule") return true;
if (prop === "__mobileStub") return true;
if (prop === "then") return undefined;
if (prop === Symbol.iterator) return undefined;
if (prop === Symbol.toPrimitive) return () => "";
// Plugin-shaped fields the resolver reads. Returning undefined makes
// `findRuntimePluginExport` fall through to "no valid Plugin export".
if (prop === "name" || prop === "description") return undefined;
if (
prop === "providers" ||
prop === "actions" ||
prop === "services" ||
prop === "events" ||
prop === "evaluators" ||
prop === "routes" ||
prop === "init"
) {
return undefined;
}
return NOOP_FN;
},
has() {
return true;
},
});
}
module.exports = makeStubProxy();
@@ -0,0 +1,69 @@
// pty-manager stub for the mobile agent bundle.
//
// AOSP builds can expose shell/coding/orchestrator actions, but the published
// pty-manager package depends on native node-pty prebuilds that do not ship for
// Android. Keep module initialization non-throwing so the orchestrator can load
// and report a structured runtime error instead of crashing during service
// startup. A real Android PTY backend should replace this stub when available.
"use strict";
const { EventEmitter } = require("node:events");
const NOT_AVAILABLE_MSG =
"pty-manager is not available in the Android mobile bundle; install a real Android PTY backend or route coding agents to Cloud containers";
function unavailable() {
throw new Error(NOT_AVAILABLE_MSG);
}
class UnavailableBunCompatiblePTYManager extends EventEmitter {
constructor() {
super();
this.sessions = new Map();
}
async spawn() {
unavailable();
}
get(id) {
return this.sessions.get(id);
}
getSession(id) {
return this.sessions.get(id);
}
async send() {
unavailable();
}
async sendKeys() {
unavailable();
}
async writeRaw() {
unavailable();
}
async kill() {
unavailable();
}
onSessionData(_id, _callback) {
return () => {};
}
async *logs() {
// Empty by design: no session can be spawned by this stub.
}
}
module.exports = {
__mobileStub: true,
BunCompatiblePTYManager: UnavailableBunCompatiblePTYManager,
PTYManager: UnavailableBunCompatiblePTYManager,
ShellAdapter: class {},
isBun: () => true,
spawn: unavailable,
};
@@ -0,0 +1,23 @@
// puppeteer-core stub for the mobile agent bundle.
//
// Browser automation is not part of the mobile-agent feature set. The agent's
// `services/browser-capture.ts` is invoked only via the desktop UI and an
// explicit user action; on mobile that route never runs.
"use strict";
const NOT_AVAILABLE_MSG =
"puppeteer-core is not available in the Android mobile bundle";
function unavailable() {
throw new Error(NOT_AVAILABLE_MSG);
}
const launch = unavailable;
const connect = unavailable;
module.exports = {
__mobileStub: true,
launch,
connect,
default: { launch, connect },
};
@@ -0,0 +1,37 @@
// react-dom.cjs — mobile bundle stub matching ./react.cjs.
// The agent runtime never renders DOM on-device; transitive imports
// (e.g. from `@elizaos/plugin-personal-assistant` UI re-exports) get this no-op shim.
"use strict";
const NOOP_FN = function noopReactDomStub() {
return undefined;
};
function makeReactDomProxy() {
const target = function reactDomStub() {
return undefined;
};
return new Proxy(target, {
get(_t, prop) {
if (prop === "default") return module.exports;
if (prop === "__esModule") return true;
if (prop === "createPortal") return NOOP_FN;
if (prop === "flushSync")
return (cb) => (typeof cb === "function" ? cb() : undefined);
if (prop === "version") return "0.0.0-mobile-stub";
if (
prop === "render" ||
prop === "hydrate" ||
prop === "unmountComponentAtNode"
) {
return NOOP_FN;
}
return NOOP_FN;
},
has() {
return true;
},
});
}
module.exports = makeReactDomProxy();
@@ -0,0 +1,19 @@
// react-jsx-runtime.cjs — mobile bundle stub for react/jsx-runtime and
// react/jsx-dev-runtime. The agent never renders JSX on-device; this
// satisfies the bundler so transitive imports through workspace plugin
// `src/index.ts` re-exports resolve cleanly.
"use strict";
const NOOP_FN = function noopJsxStub() {
return undefined;
};
const FRAGMENT = Symbol("React.Fragment");
module.exports = {
__esModule: true,
Fragment: FRAGMENT,
jsx: NOOP_FN,
jsxs: NOOP_FN,
jsxDEV: NOOP_FN,
};
@@ -0,0 +1,79 @@
// react.cjs — used by the mobile bundle to satisfy `import * as React`
// chains that survive in transitive dependencies even though the agent
// runtime never renders any UI on-device.
//
// Some workspace plugins (for example `@elizaos/plugin-personal-assistant`) re-export
// React component subtrees from their `src/index.ts` for the host app
// to consume. The agent only loads the runtime plugin object, but
// Bun.build still has to resolve every import in the dependency closure.
// Without a stub, Bun follows tsconfig path aliases that map `react` to
// `@types/react/index.d.ts`, then dies parsing TypeScript-only syntax
// (`export as namespace React`).
//
// The stub exposes a Proxy-backed namespace so `React.useState`,
// `React.createElement`, and friends evaluate to no-op functions when
// touched. Nothing at runtime should call them — the mobile bundle never
// executes JSX — so this is a strictly-typed safety net.
"use strict";
const NOOP_FN = function noopReactStub() {
return undefined;
};
const FRAGMENT = Symbol("React.Fragment");
function makeReactProxy() {
const target = function reactStub() {
return undefined;
};
return new Proxy(target, {
get(_t, prop) {
if (prop === "default") return module.exports;
if (prop === "__esModule") return true;
if (prop === "Fragment") return FRAGMENT;
if (prop === "createElement") return NOOP_FN;
if (prop === "createContext") {
return () => ({
Provider: NOOP_FN,
Consumer: NOOP_FN,
displayName: undefined,
});
}
if (prop === "memo" || prop === "forwardRef" || prop === "lazy") {
return (component) => component;
}
if (prop === "useState") return (initial) => [initial, NOOP_FN];
if (prop === "useReducer") return (_r, initial) => [initial, NOOP_FN];
if (prop === "useRef") return (initial) => ({ current: initial });
if (prop === "useMemo") return (factory) => factory();
if (prop === "useCallback") return (cb) => cb;
if (prop === "useEffect" || prop === "useLayoutEffect") return NOOP_FN;
if (prop === "useContext") return () => undefined;
if (prop === "useImperativeHandle") return NOOP_FN;
if (prop === "useId") return () => "";
if (prop === "useTransition") return () => [false, NOOP_FN];
if (prop === "useDeferredValue") return (v) => v;
if (prop === "useSyncExternalStore") return (_s, _g, init) => init();
if (prop === "useDebugValue") return NOOP_FN;
if (prop === "Children") {
return {
map: NOOP_FN,
forEach: NOOP_FN,
count: () => 0,
only: NOOP_FN,
toArray: () => [],
};
}
if (prop === "version") return "0.0.0-mobile-stub";
if (prop === "StrictMode" || prop === "Suspense" || prop === "Profiler") {
return FRAGMENT;
}
return NOOP_FN;
},
has() {
return true;
},
});
}
module.exports = makeReactProxy();
@@ -0,0 +1,22 @@
// sharp stub for the mobile agent bundle.
"use strict";
const NOT_AVAILABLE_MSG =
"sharp is not available in the Android mobile bundle — use a JS-only image path or skip the operation";
function unavailable() {
throw new Error(NOT_AVAILABLE_MSG);
}
const sharp = function sharp() {
return unavailable();
};
sharp.cache = () => sharp;
sharp.concurrency = () => 1;
sharp.simd = () => false;
sharp.format = {};
sharp.versions = {};
module.exports = sharp;
module.exports.default = sharp;
module.exports.__mobileStub = true;
@@ -0,0 +1,55 @@
// Minimal source-map API stub for mobile bundles that only need constructors
// and stringification shapes at module-evaluation time.
"use strict";
class SourceNode {
constructor(_line, _column, _source, chunks = "") {
this.children = [];
if (chunks) this.add(chunks);
}
add(chunk) {
if (Array.isArray(chunk)) {
for (const item of chunk) this.add(item);
return this;
}
if (chunk !== undefined && chunk !== null)
this.children.push(String(chunk));
return this;
}
prepend(chunk) {
if (chunk !== undefined && chunk !== null)
this.children.unshift(String(chunk));
return this;
}
toString() {
return this.children.join("");
}
toStringWithSourceMap() {
return {
code: this.toString(),
map: {
toString: () => "",
},
};
}
}
class SourceMapGenerator {
addMapping() {}
setSourceContent() {}
toString() {
return "";
}
}
class SourceMapConsumer {}
module.exports = {
SourceMapConsumer,
SourceMapGenerator,
SourceNode,
};
@@ -0,0 +1,12 @@
// Mobile bundle stub for PDF extraction, which is excluded from the on-device
// agent runtime.
"use strict";
function unavailable() {
throw new Error("PDF extraction is unavailable in the mobile agent bundle");
}
module.exports = {
extractText: unavailable,
getDocumentProxy: unavailable,
};
@@ -0,0 +1,28 @@
// zlib-sync stub for the mobile agent bundle.
//
// zlib-sync is a native Node binding pulled in by discord.js for compressed
// gateway frames. The bun mobile bundle ships no x64 / arm64 prebuild for
// AOSP bionic, so the static `require("./build/Release/zlib_sync.node")`
// at the top of the package's index.js fails Bun.build. discord.js falls
// back to uncompressed transport when the binding throws, so this stub
// keeps the bundle building without breaking the runtime.
"use strict";
function unavailable() {
throw new Error(
"zlib-sync is not available in the AOSP agent bundle — falling back to uncompressed transport",
);
}
module.exports = {
__mobileStub: true,
Inflate: unavailable,
Deflate: unavailable,
Z_NO_FLUSH: 0,
Z_PARTIAL_FLUSH: 1,
Z_SYNC_FLUSH: 2,
Z_FULL_FLUSH: 3,
Z_FINISH: 4,
Z_BLOCK: 5,
Z_TREES: 6,
};
@@ -0,0 +1,206 @@
/**
* Live-model trajectory harness for the per-view proactive greeting (#13587).
*
* Drives the REAL judge path — declared anticipatory intent + rendered live view
* state → `buildProactiveJudgePrompt` → a LIVE model → `parseProactiveJudge...`
* — for a set of views, and prints the full prompt + raw model output + parsed
* greeting for each. Knowledge/transcripts use the real `renderLiveStateForScope`
* renderer over a stub documents store; wallet uses a representative rendered
* wallet brief (its live state is fetched from the running local API in prod).
*
* Run: OPENAI_API_KEY=$CEREBRAS_API_KEY OPENAI_BASE_URL=https://api.cerebras.ai/v1 \
* bun packages/agent/scripts/proactive-greeting-live-trajectory.ts
*/
import type { IAgentRuntime, ViewSwitchedPayload } from "@elizaos/core";
import { renderLiveStateForScope } from "../src/providers/page-scoped-live-state.ts";
import {
buildProactiveJudgePrompt,
parseProactiveJudgeDecisionOutput,
} from "../src/services/proactive-interaction-decider.ts";
const BASE_URL =
process.env.OPENAI_BASE_URL ||
process.env.CEREBRAS_BASE_URL ||
"https://api.cerebras.ai/v1";
const API_KEY = process.env.OPENAI_API_KEY || process.env.CEREBRAS_API_KEY;
const MODEL = process.env.LIVE_MODEL || "gpt-oss-120b";
if (!API_KEY) {
console.error("No OPENAI_API_KEY / CEREBRAS_API_KEY set — cannot run live.");
process.exit(1);
}
const now = Date.now();
function stubRuntime(documents: unknown[]): IAgentRuntime {
return {
agentId: "live-agent",
reportError: (scope: string, err: unknown) =>
console.error(`[reportError:${scope}]`, err),
getMemories: async () => documents,
} as unknown as IAgentRuntime;
}
async function callLiveModel(prompt: string): Promise<string> {
const res = await fetch(`${BASE_URL}/chat/completions`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify({
model: MODEL,
messages: [{ role: "user", content: prompt }],
temperature: 0.4,
}),
});
if (!res.ok) {
throw new Error(`live model ${res.status}: ${await res.text()}`);
}
const json = (await res.json()) as {
choices?: Array<{ message?: { content?: string } }>;
};
return json.choices?.[0]?.message?.content ?? "";
}
interface Case {
label: string;
payload: ViewSwitchedPayload;
liveState: string | null;
}
async function buildCases(): Promise<Case[]> {
const knowledgeRuntime = stubRuntime([
{
agentId: "live-agent",
createdAt: now,
metadata: { tags: ["attachment", "media-format:pdf"], addedAt: now },
},
{
agentId: "live-agent",
createdAt: now,
metadata: { tags: ["attachment", "media-format:image"], addedAt: now },
},
{
agentId: "live-agent",
createdAt: now,
metadata: { tags: ["transcript"], addedAt: now },
},
]);
const transcriptsRuntime = stubRuntime([
{
agentId: "live-agent",
createdAt: now,
metadata: { tags: ["transcript"], addedAt: now },
},
{
agentId: "live-agent",
createdAt: now,
metadata: { tags: ["transcript"], addedAt: now },
},
]);
return [
{
label: "settings (intent, no live-state surface)",
payload: {
viewId: "settings",
viewLabel: "Settings",
initiatedBy: "user",
anticipatoryIntent:
"Offer to set up the model/provider, voice, or connectors — recommend the smallest concrete configuration step from current settings state.",
viewPurpose: "Configuration, plugins, credentials, and preferences",
} as ViewSwitchedPayload,
liveState: null,
},
{
label: "documents/knowledge (intent + real live-state renderer)",
payload: {
viewId: "documents",
viewLabel: "Knowledge",
initiatedBy: "user",
anticipatoryIntent:
"Offer to triage the newest ingested attachments/documents — summarize, tag, or file them — grounded in the recent-attachment counts.",
viewPurpose:
"Agent knowledge documents, uploads, and retrieval sources",
} as ViewSwitchedPayload,
liveState: await renderLiveStateForScope(
knowledgeRuntime,
"page-knowledge",
),
},
{
label: "transcripts (intent + real live-state renderer)",
payload: {
viewId: "transcripts",
viewLabel: "Transcripts",
initiatedBy: "user",
anticipatoryIntent:
"Offer to summarize or extract action items from the most recent voice transcripts, grounded in the recent-transcript count.",
viewPurpose: "Recorded voice transcripts — play, scrub, and read",
} as ViewSwitchedPayload,
liveState: await renderLiveStateForScope(
transcriptsRuntime,
"page-transcripts",
),
},
{
label: "wallet (intent + representative live-state brief)",
payload: {
viewId: "wallet",
viewLabel: "Wallet",
initiatedBy: "user",
anticipatoryIntent:
"Offer a portfolio summary and a fund/swap next step, grounded in balances and readiness.",
viewPurpose: "Token inventory, NFTs, LP positions, balance, and P&L",
} as ViewSwitchedPayload,
liveState: [
"Live wallet state:",
"- Wallet source: env",
"- EVM address: 0x1234...abcd",
"- Readiness: EVM balances ready, execution ready",
"- Token inventory: 2 assets.",
" - 0.42 ETH",
" - 150.0 USDC",
"- 24h activity: 3 swaps, realized P&L 0.05 BNB, volume 1.2 BNB.",
].join("\n"),
},
{
label: "database (NO intent — must be allowed to stay silent)",
payload: {
viewId: "database",
viewLabel: "Database",
initiatedBy: "user",
} as ViewSwitchedPayload,
liveState: null,
},
];
}
async function main() {
const cases = await buildCases();
for (const c of cases) {
const hasIntent =
typeof c.payload.anticipatoryIntent === "string" &&
c.payload.anticipatoryIntent.trim().length > 0;
const prompt = buildProactiveJudgePrompt(c.payload, c.liveState);
console.log(`\n${"=".repeat(78)}`);
console.log(`CASE: ${c.label}`);
console.log("-".repeat(78));
console.log(`PROMPT:\n${prompt}`);
const raw = await callLiveModel(prompt);
console.log("-".repeat(78));
console.log(`RAW MODEL OUTPUT:\n${raw}`);
const parsed = parseProactiveJudgeDecisionOutput(raw, {
hasDeclaredIntent: hasIntent,
});
console.log("-".repeat(78));
console.log(
"PARSED GREETING: " +
(parsed ? JSON.stringify(parsed) : "null (stayed silent)"),
);
}
console.log(`\n${"=".repeat(78)}`);
console.log("done");
}
void main();
@@ -0,0 +1,93 @@
/**
* Runs the agent Vitest suite one file per process so package-level tests do
* not share leaked module state or a long-lived transform heap. The file
* selection mirrors vitest.config.ts and keeps `bun run --cwd packages/agent
* test` as the single package entrypoint.
*/
import { spawnSync } from "node:child_process";
import { readdirSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const packageRoot = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"..",
);
const batchSize = Number.parseInt(process.env.AGENT_TEST_BATCH_SIZE ?? "1", 10);
const roots = ["src", "test"];
const excludedPatterns = [
/\.e2e\.test\.[cm]?tsx?$/,
/\.integration\.test\.[cm]?tsx?$/,
/\.live\.test\.[cm]?tsx?$/,
/\.live\.e2e\.test\.[cm]?tsx?$/,
/\.real\.test\.[cm]?tsx?$/,
/-real\.test\.[cm]?tsx?$/,
];
function walk(relativeDir, out) {
const absoluteDir = path.join(packageRoot, relativeDir);
for (const entry of readdirSync(absoluteDir)) {
const relativePath = path.join(relativeDir, entry);
const absolutePath = path.join(packageRoot, relativePath);
const stat = statSync(absolutePath);
if (stat.isDirectory()) {
if (entry === "dist" || entry === "node_modules") continue;
walk(relativePath, out);
continue;
}
if (!stat.isFile()) continue;
if (!/\.test\.[cm]?tsx?$/.test(entry)) continue;
if (excludedPatterns.some((pattern) => pattern.test(relativePath))) {
continue;
}
out.push(relativePath);
}
}
const files = roots.flatMap((root) => {
const out = [];
walk(root, out);
return out;
});
files.sort();
if (files.length === 0) {
console.error("[agent-test] No test files matched the package Vitest config.");
process.exit(1);
}
if (!Number.isFinite(batchSize) || batchSize < 1) {
console.error("[agent-test] AGENT_TEST_BATCH_SIZE must be a positive integer.");
process.exit(1);
}
const inheritedNodeOptions = process.env.NODE_OPTIONS ?? "";
const nodeOptions = inheritedNodeOptions.includes("--max-old-space-size")
? inheritedNodeOptions
: `${inheritedNodeOptions} --max-old-space-size=8192`.trim();
for (let start = 0; start < files.length; start += batchSize) {
const batch = files.slice(start, start + batchSize);
const batchNumber = Math.floor(start / batchSize) + 1;
const batchCount = Math.ceil(files.length / batchSize);
console.log(
`[agent-test] batch ${batchNumber}/${batchCount}: ${batch.length} file(s)`,
);
const result = spawnSync(
"bunx",
["vitest", "run", "--config", "vitest.config.ts", ...batch],
{
cwd: packageRoot,
env: { ...process.env, NODE_OPTIONS: nodeOptions },
stdio: "inherit",
},
);
if (result.error) {
console.error(`[agent-test] Failed to start Vitest: ${result.error.message}`);
process.exit(1);
}
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
@@ -0,0 +1,505 @@
/**
* tee-dstack-local-smoke.ts — LOCAL dstack dev-mode end-to-end smoke.
*
* WHAT THIS PROVES (and what it does NOT):
* This script stands up a local HTTP endpoint on 127.0.0.1 that emulates the
* dstack guest-agent (tappd) contract — a `GetQuote`-style RPC that binds a
* caller-supplied `report_data` into the returned quote, plus the normalized
* `TeeEvidence` document the in-domain attestation agent would assemble from
* that quote. The dstack provider is pointed at it via ELIZA_TEE_EVIDENCE_URL.
*
* It then exercises the FULL confidential-AI plumbing without any TDX
* hardware:
* - DEV lane: a permissive dev policy ALLOWS the (simulated, devmode)
* evidence, a key releases over both the local KDF client and the HTTP
* (RA-TLS-shaped) client with wrapTeeReleaseKey, and a small sealed
* weights blob unseals in memory. End-to-end plumbing works locally.
* - PRODUCTION lane: the production profile (teeProductionProfile /
* mergeTeeProductionProfile, rejectSimulatedEvidence) REJECTS the SAME
* simulated evidence, and model-key unseal under it FAILS — the
* ciphertext stays sealed.
*
* This is DEV-ONLY. It proves the PLUMBING, not hardware trust. The evidence
* it serves is SIMULATED (kind "dstack", hardwareVendor "mock-devmode",
* verifier "local-dstack-sim", quote marked "...-devmode-simulated"); there is
* NO TDX/CoVE quote-signature verification anywhere in this path. Real quote
* verification (Intel PCS/QvL, RTMRs, report_data binding, RA-TLS cert chain)
* is plan Phase B2 and is BLOCKED on a TDX host. Until B2 lands the system
* must not claim hardware-verified trust — and the production lane below
* demonstrates that it refuses to.
*
* REAL dstack BINARY: none is available on this host (`which dstack` fails; no
* dstack repo/simulator under the tree). This file IS the faithful local
* simulator. If a real dstack guest-agent / simulator socket becomes available
* (DSTACK_TAPPD_URL), the dstack provider already prefers it; this script only
* supplies the local stand-in.
*
* Run: bun packages/agent/scripts/tee-dstack-local-smoke.ts
* Exit: 0 with a PASS summary; non-zero on any unexpected outcome.
*/
import { type BinaryLike, createHash, randomBytes } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { createServer, type Server } from "node:http";
import { collectDstackTeeEvidence } from "../../../plugins/plugin-tee/src/confidential/dstack-tee-provider.ts";
import {
MODEL_KEY_ID,
type SealedWeightsBlob,
sealModelWeightsShards,
unsealModelWeights,
} from "../src/services/tee-confidential-inference.ts";
import type { TeeEvidence } from "../src/services/tee-evidence.ts";
import {
HttpTeeKeyReleaseClient,
LocalTeeKeyReleaseClient,
type TeeKeyReleaseRequest,
wrapTeeReleaseKey,
} from "../src/services/tee-key-release.ts";
import {
evaluateTeeEvidencePolicy,
type TeeEvidencePolicy,
type TeeEvidencePolicyDecision,
} from "../src/services/tee-policy.ts";
import { mergeTeeProductionProfile } from "../src/services/tee-production-profile.ts";
// ---------------------------------------------------------------------------
// Simulated devmode evidence (the dstack guest-agent / tappd would emit this).
// The non-production markers are deliberate and load-bearing for the prod lane:
// hardwareVendor "mock-devmode", verifier "local-dstack-sim", and a quote string
// tagged "devmode-simulated" all trip rejectSimulatedEvidence in production.
// ---------------------------------------------------------------------------
const sha256Hex = (input: BinaryLike): string =>
createHash("sha256").update(input).digest("hex");
const measurementDigest = (label: string): string =>
`sha256:${sha256Hex(`local-dstack-sim:${label}`)}`;
const NONCE_HEX = randomBytes(32).toString("hex");
const TIMESTAMP = new Date().toISOString();
const AGENT_DIGEST = measurementDigest("agent");
const POLICY_DIGEST = measurementDigest("policy");
const CONTAINER_DIGEST = measurementDigest("container");
const OS_DIGEST = measurementDigest("os");
const BOOT_DIGEST = measurementDigest("boot");
const DEVICE_DIGEST = measurementDigest("device");
const NPU_FIRMWARE_DIGEST = measurementDigest("npuFirmware");
// The sealed weights blob (small) is sealed below; its plaintext digest is used
// to bind model-key release to the expected weights (modelWeights measurement).
const PLAINTEXT_WEIGHTS = Buffer.from(
"eliza-1 dev-mode sealed weights blob — local plumbing proof only",
"utf8",
);
const WEIGHTS_SHA256 = sha256Hex(PLAINTEXT_WEIGHTS);
type SimulatedEvidence = TeeEvidence & {
kind: "dstack";
measurements: Record<string, string>;
freshness: { nonce: string; timestamp: string; verifier: string };
};
function buildSimulatedEvidence(
reportDataHex: string,
nonceHex: string,
): SimulatedEvidence {
return {
kind: "dstack",
provider: "dstack",
hardwareVendor: "mock-devmode",
platformVersion: "dstack-local-sim-0",
securityVersion: 3,
measurements: {
boot: BOOT_DIGEST,
os: OS_DIGEST,
agent: AGENT_DIGEST,
policy: POLICY_DIGEST,
container: CONTAINER_DIGEST,
device: DEVICE_DIGEST,
npuFirmware: NPU_FIRMWARE_DIGEST,
modelWeights: `sha256:${WEIGHTS_SHA256}`,
},
freshness: {
nonce: nonceHex,
timestamp: TIMESTAMP,
verifier: "local-dstack-sim",
},
claims: {
debugDisabled: true,
productionLifecycle: true,
secureBoot: true,
memoryEncrypted: true,
ioProtected: true,
npuProtected: true,
},
// A genuine tappd quote is an Intel TDX quote blob; here it is an explicit
// devmode-simulated marker so production rejection has something to catch
// and so nothing downstream can mistake it for a real quote.
quote: `dstack-devmode-simulated:${reportDataHex.slice(0, 16)}`,
reportData: reportDataHex,
};
}
// ---------------------------------------------------------------------------
// Local dstack guest-agent (tappd) simulator: a GetQuote-style endpoint that
// binds report_data, an evidence endpoint the dstack provider reads, and a KMS
// key-release endpoint that wraps the key to the agent's ephemeral epk.
// ---------------------------------------------------------------------------
type KeyReleasePayload = {
keyId: string;
context?: string;
nonce: string;
ephemeralPublicKey: string;
reportData?: string;
policy: TeeEvidencePolicy;
evidence: TeeEvidence;
};
function defaultReportData(): string {
// report_data = SHA256(nonce || 0). When a client (HttpTeeKeyReleaseClient)
// re-collects with its own binding, the evidence endpoint echoes the supplied
// report_data instead (see handler below).
return sha256Hex(Buffer.from(NONCE_HEX, "hex"));
}
function startSimulator(): Promise<{ server: Server; baseUrl: string }> {
const server = createServer((request, response) => {
void routeRequest(request, response);
});
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("dstack simulator did not bind to a TCP port."));
return;
}
resolve({ server, baseUrl: `http://127.0.0.1:${address.port}` });
});
});
}
async function routeRequest(
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
const url = new URL(request.url ?? "/", "http://127.0.0.1");
// tappd GetQuote-style: bind a caller-supplied report_data + nonce into the
// returned quote and emit the normalized evidence document for it. A bare
// /evidence collect (no params) gets the static device nonce.
if (url.pathname === "/prpc/Tappd.GetQuote" || url.pathname === "/evidence") {
const reportData =
url.searchParams.get("report_data") ?? defaultReportData();
const nonce = url.searchParams.get("nonce") ?? NONCE_HEX;
respondJson(response, 200, buildSimulatedEvidence(reportData, nonce));
return;
}
if (url.pathname === "/v1/tee/key-release" && request.method === "POST") {
await handleKeyRelease(request, response);
return;
}
respondJson(response, 404, { error: "not-found", path: url.pathname });
}
async function handleKeyRelease(
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
const payload = JSON.parse(await readBody(request)) as KeyReleasePayload;
const decision = evaluateTeeEvidencePolicy(payload.evidence, payload.policy);
if (!decision.trusted) {
respondJson(response, 403, { decision });
return;
}
// Deterministic per-app key bound to the measured identity (models the dstack
// KMS deriving an app key from compose/agent/policy measurements).
const keyMaterialHex = sha256Hex(
Buffer.concat([
Buffer.from("local-dstack-sim-kms\n", "utf8"),
Buffer.from(payload.keyId, "utf8"),
Buffer.from(payload.context ?? "", "utf8"),
Buffer.from(payload.evidence.measurements?.agent ?? "", "utf8"),
Buffer.from(payload.evidence.measurements?.policy ?? "", "utf8"),
]),
);
const wrappedKey = wrapTeeReleaseKey({
keyMaterialHex,
agentEphemeralPublicKeyDerBase64: payload.ephemeralPublicKey,
nonceHex: payload.nonce,
});
respondJson(response, 200, {
keyId: payload.keyId,
wrappedKey,
nonce: payload.nonce,
decision,
});
}
function respondJson(
response: ServerResponse,
status: number,
body: unknown,
): void {
response.writeHead(status, { "content-type": "application/json" });
response.end(JSON.stringify(body));
}
async function readBody(request: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
// ---------------------------------------------------------------------------
// Self-checking assertions.
// ---------------------------------------------------------------------------
class SmokeFailure extends Error {}
function assert(condition: boolean, message: string): asserts condition {
if (!condition) throw new SmokeFailure(message);
}
const isHex32Bytes = (value: string): boolean => /^[a-f0-9]{64}$/.test(value);
function summarizeDecision(decision: TeeEvidencePolicyDecision): {
trusted: boolean;
reason: TeeEvidencePolicyDecision["reason"];
detail?: string;
} {
return {
trusted: decision.trusted,
reason: decision.reason,
...(decision.detail === undefined ? {} : { detail: decision.detail }),
};
}
// ---------------------------------------------------------------------------
// Main.
// ---------------------------------------------------------------------------
const { server, baseUrl } = await startSimulator();
const endpointUrl = `${baseUrl}/evidence`;
try {
// Collect SIMULATED evidence through the real dstack provider, pointed at the
// local tappd-style endpoint via ELIZA_TEE_EVIDENCE_URL semantics.
const evidence = await collectDstackTeeEvidence({ endpointUrl, env: {} });
assert(evidence.kind === "dstack", "collected evidence must be kind dstack");
assert(
evidence.hardwareVendor === "mock-devmode",
"collected evidence must carry the devmode marker",
);
// === DEV LANE =========================================================
// A permissive dev policy that matches the measured identity but does NOT
// reject simulated evidence. This is what local development uses.
const devPolicy: TeeEvidencePolicy = {
required: true,
allowedKinds: ["dstack"],
allowedProviders: ["dstack"],
minSecurityVersion: 1,
expectedNonce: NONCE_HEX,
maxAgeMs: 300_000,
nowMs: Date.parse(TIMESTAMP),
requiredMeasurements: {
boot: BOOT_DIGEST,
os: OS_DIGEST,
agent: AGENT_DIGEST,
policy: POLICY_DIGEST,
container: CONTAINER_DIGEST,
device: DEVICE_DIGEST,
npuFirmware: NPU_FIRMWARE_DIGEST,
modelWeights: `sha256:${WEIGHTS_SHA256}`,
},
requiredClaims: {
debugDisabled: true,
productionLifecycle: true,
secureBoot: true,
memoryEncrypted: true,
ioProtected: true,
npuProtected: true,
},
};
const devDecision = evaluateTeeEvidencePolicy(evidence, devPolicy);
assert(
devDecision.trusted && devDecision.reason === "allowed",
`dev policy must ALLOW simulated evidence, got: ${JSON.stringify(summarizeDecision(devDecision))}`,
);
// Local KDF key-release (LocalTeeKeyReleaseClient).
const localClient = new LocalTeeKeyReleaseClient({
masterSecretHex: "33".repeat(32),
evidenceProvider: {
id: "local-dstack-sim",
collectEvidence: async () => evidence,
},
});
const localRelease = await localClient.releaseKey({
keyId: "agent-session",
context: "tee-dstack-local-smoke",
policy: devPolicy,
});
assert(
localRelease.decision.trusted && isHex32Bytes(localRelease.keyMaterialHex),
"local KDF client must release a 32-byte key under the dev policy",
);
// HTTP (RA-TLS-shaped) key-release with nonce/epk binding + wrapTeeReleaseKey.
// The provider re-collects evidence bound to the client's report_data; the
// simulator echoes it via the evidence endpoint, so report_data matches.
const httpClient = new HttpTeeKeyReleaseClient({
baseUrl,
evidenceProvider: {
id: "local-dstack-sim",
collectEvidence: async () => evidence,
collectEvidenceWithReportData: async (challenge) =>
collectDstackTeeEvidence({
endpointUrl: `${baseUrl}/prpc/Tappd.GetQuote?report_data=${challenge.reportDataHex}&nonce=${challenge.nonce}`,
env: {},
}),
},
});
const httpReleaseRequest: TeeKeyReleaseRequest = {
keyId: "remote-signing",
context: "tee-dstack-local-smoke-http",
// expectedNonce is overwritten by the client with its fresh binding nonce;
// drop it here so the client's report_data binding governs freshness.
policy: { ...devPolicy, expectedNonce: undefined },
};
const httpRelease = await httpClient.releaseKey(httpReleaseRequest);
assert(
httpRelease.decision.trusted && isHex32Bytes(httpRelease.keyMaterialHex),
"HTTP client must release a 32-byte wrapped key under the dev policy",
);
// Confidential-inference unseal: the blob must be sealed with the SAME key the
// unseal path releases. unsealModelWeights releases keyId="model-key" with
// context "tee-dstack-local-smoke", so seal with exactly that released key.
const modelKeyRelease = await localClient.releaseKey({
keyId: MODEL_KEY_ID,
context: "tee-dstack-local-smoke",
policy: devPolicy,
});
assert(
isHex32Bytes(modelKeyRelease.keyMaterialHex),
"local KDF client must release a 32-byte model-key under the dev policy",
);
const sealKey = Buffer.from(modelKeyRelease.keyMaterialHex, "hex");
const manifest = sealModelWeightsShards({
weights: PLAINTEXT_WEIGHTS,
key: sealKey,
shardSizeBytes: PLAINTEXT_WEIGHTS.length,
});
sealKey.fill(0);
// Single-shard manifest collapses to the single-blob envelope shape.
const onlyShard = manifest.shards[0];
assert(onlyShard !== undefined, "sealed manifest must have a shard");
const sealedBlob: SealedWeightsBlob = {
algorithm: "aes-256-gcm",
ivBase64: onlyShard.ivBase64,
authTagBase64: onlyShard.authTagBase64,
ciphertextBase64: onlyShard.ciphertextBase64,
weightsSha256: manifest.weightsSha256,
};
assert(
manifest.weightsSha256 === WEIGHTS_SHA256,
"sealed manifest digest must match the modelWeights binding",
);
const requiredUnsealMeasurements = [
"agent",
"policy",
"container",
"os",
"npuFirmware",
"modelWeights",
] as const;
const unsealed = await unsealModelWeights({
keyReleaseClient: localClient,
policy: devPolicy,
sealedWeights: sealedBlob,
requiredMeasurements: requiredUnsealMeasurements,
context: "tee-dstack-local-smoke",
});
assert(
unsealed.weights.equals(PLAINTEXT_WEIGHTS) &&
unsealed.weightsSha256 === WEIGHTS_SHA256,
"dev unseal must recover the exact plaintext weights",
);
unsealed.weights.fill(0);
// === PRODUCTION LANE ==================================================
// Apply the production profile to the SAME dev policy + SAME simulated
// evidence. rejectSimulatedEvidence must fire on the devmode markers.
const prodPolicy = mergeTeeProductionProfile(devPolicy, {
inference: "local",
});
assert(
prodPolicy.rejectSimulatedEvidence === true && prodPolicy.required === true,
"production profile must force rejectSimulatedEvidence + required",
);
const prodDecision = evaluateTeeEvidencePolicy(evidence, prodPolicy);
assert(
!prodDecision.trusted &&
prodDecision.reason === "simulated-evidence-rejected",
`production profile must REJECT simulated evidence, got: ${JSON.stringify(summarizeDecision(prodDecision))}`,
);
// The unseal under the production profile must FAIL — ciphertext stays sealed.
let prodUnsealError: string | undefined;
try {
await unsealModelWeights({
keyReleaseClient: localClient,
policy: prodPolicy,
sealedWeights: sealedBlob,
requiredMeasurements: requiredUnsealMeasurements,
context: "tee-dstack-local-smoke-prod",
});
} catch (error) {
prodUnsealError = error instanceof Error ? error.message : String(error);
}
assert(
prodUnsealError !== undefined,
"model-key unseal MUST fail under the production profile (ciphertext stays sealed)",
);
// === PASS SUMMARY =====================================================
const summary = {
ok: true,
realDstackBinaryFound: false,
simulator: { kind: "local-tappd-getquote-emulator", endpoint: endpointUrl },
dev: {
decision: summarizeDecision(devDecision),
localKeyRelease: { keyId: localRelease.keyId, released: true },
httpKeyRelease: { keyId: httpRelease.keyId, released: true },
weightsUnsealed: true,
weightsSha256: WEIGHTS_SHA256,
},
production: {
decision: summarizeDecision(prodDecision),
unsealRejected: true,
unsealError: prodUnsealError,
},
};
console.log("PASS — dstack local dev-mode smoke");
console.log(JSON.stringify(summary, null, 2));
console.log(
"DEV-ONLY: this proves the confidential-AI PLUMBING, not hardware trust. " +
"Real TDX quote verification is Phase B2 and is BLOCKED on a TDX host.",
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`FAIL — dstack local dev-mode smoke: ${message}`);
process.exitCode = 1;
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
if (process.exitCode && process.exitCode !== 0) {
process.exit(process.exitCode);
}
@@ -0,0 +1,764 @@
/**
* TEE full-stack local harness (plan §7 Phase A item A1).
*
* A runnable, self-checking, hardware-free end-to-end exercise of the
* confidential-AI trust pipeline:
*
* collect TeeEvidence
* -> evaluateTeeEvidencePolicy (the single trust decision)
* -> HttpTeeKeyReleaseClient -> mock KMS (wrapTeeReleaseKey)
*
* It defines the three deployment topologies the product supports
* (local-only, desktop, cloud-routed), drives a golden (trusted) key release
* for each, and then drives a golden + tampered fixture for EVERY decision
* reason in the closed `TeeEvidencePolicyDecision` union, asserting the exact
* reason each time. Artifacts are written under evidence/tee/.
*
* This is NOT the unit test (tee-evidence-policy.matrix.test.ts owns the pure
* data matrix). This script wires the same vectors through the real key-release
* client + mock KMS so the wrap/unwrap, nonce, and report_data binding paths are
* exercised end to end. Real TDX/CoVE quote-signature verification is BLOCKED on
* hardware (plan Phase B/C); this verifies a normalized evidence document only.
*
* Run: bun packages/agent/scripts/tee-full-stack-local.ts
* Exit: 0 on all-green, non-zero on any mismatch.
*/
import { createCipheriv, createHash, randomBytes } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import {
type SealedWeightsBlob,
unsealModelWeights,
} from "../src/services/tee-confidential-inference.ts";
import type {
TeeEvidence,
TeeMeasurementName,
} from "../src/services/tee-evidence.ts";
import {
HttpTeeKeyReleaseClient,
type TeeReportDataBoundEvidenceProvider,
wrapTeeReleaseKey,
} from "../src/services/tee-key-release.ts";
import {
evaluateTeeEvidencePolicy,
type TeeEvidencePolicy,
type TeeEvidencePolicyDecision,
} from "../src/services/tee-policy.ts";
import { mergeTeeProductionProfile } from "../src/services/tee-production-profile.ts";
import {
mergeTeeRevocationsIntoPolicy,
type TeeRevocationManifest,
} from "../src/services/tee-revocation.ts";
// Fixed clock so timestamp-freshness vectors are deterministic.
const NOW = Date.parse("2026-05-20T12:00:00.000Z");
const FRESH_TIMESTAMP = "2026-05-20T11:59:30.000Z"; // 30s old, inside every topology window.
const ISSUED_NONCE = "issued-nonce-full-stack-local";
// Deterministic golden measurement digests. Distinct per name so a tamper of
// one cannot accidentally collide with another.
const digest = (label: string) =>
`sha256:${createHash("sha256").update(`golden:${label}`).digest("hex")}`;
const GOLDEN_MEASUREMENTS: Record<TeeMeasurementName, string> = {
boot: digest("boot"),
os: digest("os"),
agent: digest("agent"),
policy: digest("policy"),
device: digest("device"),
container: digest("container"),
npuFirmware: digest("npuFirmware"),
gpuFirmware: digest("gpuFirmware"),
};
const ZERO_DIGEST = `sha256:${"0".repeat(64)}`;
// ---------------------------------------------------------------------------
// Topology policies. Each is the production profile (which forces required,
// rejectSimulatedEvidence, the base claims, and a 5-min freshness floor)
// merged with the topology's allowlists, golden digests, nonce, and clock.
// ---------------------------------------------------------------------------
type Topology = "local-only" | "desktop" | "cloud-routed";
type TopologyFixture = {
topology: Topology;
/** The golden evidence the in-domain attestation agent would emit. */
evidence: TeeEvidence;
/** Policy the agent evaluates (production profile + topology specifics). */
policy: TeeEvidencePolicy;
/** KMS provider id this topology releases keys against. */
kmsProvider: string;
/** Key-release scope exercised for this topology. */
keyId: string;
};
function buildLocalOnly(): TopologyFixture {
// Local-only: on-device CVM/TVM, on-device verifier, NPU confidential I/O.
// required + npuProtected + local golden digests (boot/os/agent/policy/
// device/container/npuFirmware). No cloud provider.
const evidence: TeeEvidence = {
kind: "cove",
provider: "eliza-vault",
hardwareVendor: "elizaos-e1",
platformVersion: "local-only-cove",
securityVersion: 7,
measurements: {
boot: GOLDEN_MEASUREMENTS.boot,
os: GOLDEN_MEASUREMENTS.os,
agent: GOLDEN_MEASUREMENTS.agent,
policy: GOLDEN_MEASUREMENTS.policy,
device: GOLDEN_MEASUREMENTS.device,
container: GOLDEN_MEASUREMENTS.container,
npuFirmware: GOLDEN_MEASUREMENTS.npuFirmware,
},
freshness: {
nonce: ISSUED_NONCE,
timestamp: FRESH_TIMESTAMP,
verifier: "eliza-e1-verifier",
},
claims: {
debugDisabled: true,
secureBoot: true,
memoryEncrypted: true,
ioProtected: true,
productionLifecycle: true,
npuProtected: true,
},
};
const policy = mergeTeeProductionProfile(
{
allowedKinds: ["cove"],
allowedProviders: ["eliza-vault"],
minSecurityVersion: 7,
expectedNonce: ISSUED_NONCE,
maxAgeMs: 60_000,
nowMs: NOW,
requiredMeasurements: {
boot: GOLDEN_MEASUREMENTS.boot,
os: GOLDEN_MEASUREMENTS.os,
agent: GOLDEN_MEASUREMENTS.agent,
policy: GOLDEN_MEASUREMENTS.policy,
device: GOLDEN_MEASUREMENTS.device,
container: GOLDEN_MEASUREMENTS.container,
npuFirmware: GOLDEN_MEASUREMENTS.npuFirmware,
},
},
{ inference: "local" },
);
return {
topology: "local-only",
evidence,
policy,
kmsProvider: "eliza-e1-verifier",
keyId: "model-key",
};
}
function buildDesktop(): TopologyFixture {
// Desktop: local agent in a dstack CVM (TDX) on a workstation. Local private
// inference (npuProtected) but no on-chip NPU firmware gate — the desktop
// attests boot/os/agent/policy/device/container only.
const evidence: TeeEvidence = {
kind: "dstack",
provider: "dstack",
hardwareVendor: "intel",
platformVersion: "desktop-tdx",
securityVersion: 5,
measurements: {
boot: GOLDEN_MEASUREMENTS.boot,
os: GOLDEN_MEASUREMENTS.os,
agent: GOLDEN_MEASUREMENTS.agent,
policy: GOLDEN_MEASUREMENTS.policy,
device: GOLDEN_MEASUREMENTS.device,
container: GOLDEN_MEASUREMENTS.container,
},
freshness: {
nonce: ISSUED_NONCE,
timestamp: FRESH_TIMESTAMP,
verifier: "intel-pcs",
},
claims: {
debugDisabled: true,
secureBoot: true,
memoryEncrypted: true,
ioProtected: true,
productionLifecycle: true,
npuProtected: true,
},
};
const policy = mergeTeeProductionProfile(
{
allowedKinds: ["dstack"],
allowedProviders: ["dstack"],
minSecurityVersion: 5,
expectedNonce: ISSUED_NONCE,
maxAgeMs: 120_000,
nowMs: NOW,
requiredMeasurements: {
boot: GOLDEN_MEASUREMENTS.boot,
os: GOLDEN_MEASUREMENTS.os,
agent: GOLDEN_MEASUREMENTS.agent,
policy: GOLDEN_MEASUREMENTS.policy,
device: GOLDEN_MEASUREMENTS.device,
container: GOLDEN_MEASUREMENTS.container,
},
},
{ inference: "local" },
);
return {
topology: "desktop",
evidence,
policy,
kmsProvider: "dstack",
keyId: "agent-session",
};
}
function buildCloudRouted(): TopologyFixture {
// Cloud-routed: prompt data goes to a dstack CVM on TDX + H100 confidential
// GPU. Adds the cloud KMS provider, requires gpuProtected + gpuFirmware.
const evidence: TeeEvidence = {
kind: "tdx",
provider: "eliza-cloud-kms",
hardwareVendor: "intel",
platformVersion: "cloud-tdx-h100",
securityVersion: 9,
measurements: {
boot: GOLDEN_MEASUREMENTS.boot,
os: GOLDEN_MEASUREMENTS.os,
agent: GOLDEN_MEASUREMENTS.agent,
policy: GOLDEN_MEASUREMENTS.policy,
device: GOLDEN_MEASUREMENTS.device,
container: GOLDEN_MEASUREMENTS.container,
gpuFirmware: GOLDEN_MEASUREMENTS.gpuFirmware,
},
freshness: {
nonce: ISSUED_NONCE,
timestamp: FRESH_TIMESTAMP,
verifier: "intel-pcs",
},
claims: {
debugDisabled: true,
secureBoot: true,
memoryEncrypted: true,
ioProtected: true,
productionLifecycle: true,
gpuProtected: true,
},
};
const policy = mergeTeeProductionProfile(
{
allowedKinds: ["tdx"],
// Cloud-routed adds the cloud KMS provider to the allowlist.
allowedProviders: ["dstack", "eliza-cloud-kms"],
minSecurityVersion: 9,
expectedNonce: ISSUED_NONCE,
maxAgeMs: 60_000,
nowMs: NOW,
requiredMeasurements: {
boot: GOLDEN_MEASUREMENTS.boot,
os: GOLDEN_MEASUREMENTS.os,
agent: GOLDEN_MEASUREMENTS.agent,
policy: GOLDEN_MEASUREMENTS.policy,
device: GOLDEN_MEASUREMENTS.device,
container: GOLDEN_MEASUREMENTS.container,
gpuFirmware: GOLDEN_MEASUREMENTS.gpuFirmware,
},
},
{ inference: "cloud" },
);
return {
topology: "cloud-routed",
evidence,
policy,
kmsProvider: "eliza-cloud-kms",
keyId: "remote-signing",
};
}
const topologies: TopologyFixture[] = [
buildLocalOnly(),
buildDesktop(),
buildCloudRouted(),
];
// ---------------------------------------------------------------------------
// Mock KMS: verifies the same policy the agent evaluates, then wraps the
// released key to the agent's ephemeral public key with wrapTeeReleaseKey so
// the client's unwrap (X25519 ECDH + HKDF + AES-256-GCM) succeeds.
// ---------------------------------------------------------------------------
function makeMockKmsFetch(kmsSecretLabel: string): typeof fetch {
return (async (_url, init) => {
const body = JSON.parse(String(init?.body)) as {
keyId: string;
nonce: string;
ephemeralPublicKey: string;
reportData: string;
evidence: TeeEvidence;
policy: TeeEvidencePolicy;
};
const decision = evaluateTeeEvidencePolicy(body.evidence, body.policy);
if (!decision.trusted) {
return Response.json({ decision }, { status: 403 });
}
// Deterministic per-app key bound to the measured agent/policy identity,
// mirroring dstack's deterministic-app-key model.
const keyMaterialHex = createHash("sha256")
.update(kmsSecretLabel)
.update(body.keyId)
.update(body.evidence.measurements?.agent ?? "")
.update(body.evidence.measurements?.policy ?? "")
.digest("hex");
const wrappedKey = wrapTeeReleaseKey({
keyMaterialHex,
agentEphemeralPublicKeyDerBase64: body.ephemeralPublicKey,
nonceHex: body.nonce,
});
return Response.json({
keyId: body.keyId,
nonce: body.nonce,
wrappedKey,
decision,
});
}) as typeof fetch;
}
function makeEvidenceProvider(
fixture: TopologyFixture,
): TeeReportDataBoundEvidenceProvider {
return {
id: `full-stack-${fixture.topology}`,
collectEvidence: async () => fixture.evidence,
collectEvidenceWithReportData: async ({ nonce, reportDataHex }) => ({
...fixture.evidence,
reportData: reportDataHex,
freshness: { ...fixture.evidence.freshness, nonce },
}),
};
}
type TopologyRunResult = {
topology: Topology;
kmsProvider: string;
keyId: string;
golden: ReturnType<typeof summarize>;
keyMaterialSha256: string;
ok: boolean;
};
async function runTopology(
fixture: TopologyFixture,
): Promise<TopologyRunResult> {
const golden = evaluateTeeEvidencePolicy(fixture.evidence, fixture.policy);
const client = new HttpTeeKeyReleaseClient({
baseUrl: "https://kms.example.test",
fetch: makeMockKmsFetch(`full-stack-${fixture.topology}-kms`),
evidenceProvider: makeEvidenceProvider(fixture),
});
const release = await client.releaseKey({
keyId: fixture.keyId,
context: `full-stack-${fixture.topology}`,
policy: fixture.policy,
});
const keyMaterialSha256 = createHash("sha256")
.update(release.keyMaterialHex)
.digest("hex");
const ok =
golden.trusted &&
golden.reason === "allowed" &&
release.decision.trusted &&
/^[a-f0-9]{64}$/.test(release.keyMaterialHex) &&
release.keyId === fixture.keyId;
return {
topology: fixture.topology,
kmsProvider: fixture.kmsProvider,
keyId: fixture.keyId,
golden: summarize(golden),
keyMaterialSha256,
ok,
};
}
// ---------------------------------------------------------------------------
// Decision-reason matrix. A golden (trusted) base + a tampered variant per
// reason in the closed union. The base is the cloud-routed evidence/policy
// (richest measurement + claim set). Each tampered case asserts the EXACT
// reason the policy must return; the harness exits non-zero on any mismatch.
// ---------------------------------------------------------------------------
const REASON_BASE_FIXTURE = buildCloudRouted();
const reasonBaseEvidence = REASON_BASE_FIXTURE.evidence;
const reasonBasePolicy = REASON_BASE_FIXTURE.policy;
type ReasonVector = {
reason: TeeEvidencePolicyDecision["reason"];
evidence: unknown;
policy: TeeEvidencePolicy | undefined;
trusted: boolean;
};
const reasonVectors: ReasonVector[] = [
// Trusted reasons.
{
reason: "no-policy",
evidence: reasonBaseEvidence,
policy: undefined,
trusted: true,
},
{
reason: "not-required",
evidence: undefined,
policy: { required: false },
trusted: true,
},
{
reason: "allowed",
evidence: reasonBaseEvidence,
policy: reasonBasePolicy,
trusted: true,
},
// Untrusted reasons (golden base, one field tampered each).
{
reason: "missing-evidence",
evidence: undefined,
policy: { required: true },
trusted: false,
},
{
reason: "invalid-evidence",
// No `kind` → normalizeTeeEvidence throws → invalid-evidence.
evidence: { provider: "eliza-cloud-kms" },
policy: { required: true },
trusted: false,
},
{
reason: "simulated-evidence-rejected",
// Production profile sets rejectSimulatedEvidence; a mock vendor is refused.
evidence: { ...reasonBaseEvidence, hardwareVendor: "mock-macos" },
policy: reasonBasePolicy,
trusted: false,
},
{
reason: "kind-not-allowed",
evidence: { ...reasonBaseEvidence, kind: "sev-snp" },
policy: reasonBasePolicy,
trusted: false,
},
{
reason: "provider-not-allowed",
evidence: { ...reasonBaseEvidence, provider: "rogue-kms" },
policy: reasonBasePolicy,
trusted: false,
},
{
reason: "measurement-mismatch",
evidence: {
...reasonBaseEvidence,
measurements: { ...reasonBaseEvidence.measurements, agent: ZERO_DIGEST },
},
policy: reasonBasePolicy,
trusted: false,
},
{
reason: "measurement-revoked",
evidence: reasonBaseEvidence,
policy: mergeTeeRevocationsIntoPolicy(reasonBasePolicy, {
schemaVersion: 1,
revokedMeasurements: { agent: [GOLDEN_MEASUREMENTS.agent] },
} satisfies TeeRevocationManifest),
trusted: false,
},
{
reason: "security-version-too-low",
evidence: { ...reasonBaseEvidence, securityVersion: 1 },
policy: reasonBasePolicy,
trusted: false,
},
{
reason: "security-version-revoked",
evidence: reasonBaseEvidence,
policy: mergeTeeRevocationsIntoPolicy(reasonBasePolicy, {
schemaVersion: 1,
revokedSecurityVersions: [9],
} satisfies TeeRevocationManifest),
trusted: false,
},
{
reason: "missing-nonce",
evidence: {
...reasonBaseEvidence,
freshness: { timestamp: FRESH_TIMESTAMP, verifier: "intel-pcs" },
},
policy: reasonBasePolicy,
trusted: false,
},
{
reason: "nonce-mismatch",
evidence: {
...reasonBaseEvidence,
freshness: { ...reasonBaseEvidence.freshness, nonce: "stale-nonce" },
},
policy: reasonBasePolicy,
trusted: false,
},
{
reason: "missing-timestamp",
evidence: {
...reasonBaseEvidence,
freshness: { nonce: ISSUED_NONCE, verifier: "intel-pcs" },
},
policy: reasonBasePolicy,
trusted: false,
},
{
reason: "timestamp-invalid",
evidence: {
...reasonBaseEvidence,
freshness: { ...reasonBaseEvidence.freshness, timestamp: "not-a-date" },
},
policy: reasonBasePolicy,
trusted: false,
},
{
reason: "timestamp-stale",
evidence: reasonBaseEvidence,
// Advance the clock 10 min past the 5-min production freshness floor.
policy: { ...reasonBasePolicy, nowMs: NOW + 10 * 60_000 },
trusted: false,
},
{
reason: "claim-mismatch",
evidence: {
...reasonBaseEvidence,
claims: { ...reasonBaseEvidence.claims, gpuProtected: false },
},
policy: reasonBasePolicy,
trusted: false,
},
];
const ALL_REASONS: TeeEvidencePolicyDecision["reason"][] = [
"no-policy",
"not-required",
"allowed",
"missing-evidence",
"invalid-evidence",
"simulated-evidence-rejected",
"kind-not-allowed",
"provider-not-allowed",
"measurement-mismatch",
"measurement-revoked",
"security-version-too-low",
"security-version-revoked",
"missing-nonce",
"nonce-mismatch",
"missing-timestamp",
"timestamp-invalid",
"timestamp-stale",
"claim-mismatch",
];
type ReasonResult = {
reason: TeeEvidencePolicyDecision["reason"];
expectedTrusted: boolean;
decision: ReturnType<typeof summarize>;
ok: boolean;
};
function runReasonMatrix(): { results: ReasonResult[]; coverageOk: boolean } {
const results = reasonVectors.map((vector) => {
const decision = evaluateTeeEvidencePolicy(vector.evidence, vector.policy);
return {
reason: vector.reason,
expectedTrusted: vector.trusted,
decision: summarize(decision),
ok:
decision.reason === vector.reason &&
decision.trusted === vector.trusted,
};
});
const covered = new Set(reasonVectors.map((vector) => vector.reason));
const coverageOk =
covered.size === ALL_REASONS.length &&
ALL_REASONS.every((reason) => covered.has(reason));
return { results, coverageOk };
}
// ---------------------------------------------------------------------------
// Confidential-inference unseal (plan §2.2 steps 47) against the mock KMS.
// Seal a weights blob at rest, release model-key gated on the local-only
// policy + modelWeights digest, decrypt in memory; assert the happy path
// recovers the plaintext and the tampered path is denied.
// ---------------------------------------------------------------------------
const UNSEAL_REQUIRED: readonly TeeMeasurementName[] = [
"agent",
"policy",
"container",
"os",
"npuFirmware",
"modelWeights",
];
async function runUnseal(): Promise<{
happyPath: boolean;
deniedPath: boolean;
weightsSha256: string;
}> {
const plaintextWeights = Buffer.from("eliza-1 full-stack weights fixture\n");
const weightsSha256 = createHash("sha256")
.update(plaintextWeights)
.digest("hex");
const modelKey = randomBytes(32);
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", modelKey, iv);
const ciphertext = Buffer.concat([
cipher.update(plaintextWeights),
cipher.final(),
]);
const sealedWeights: SealedWeightsBlob = {
algorithm: "aes-256-gcm",
ivBase64: iv.toString("base64"),
authTagBase64: cipher.getAuthTag().toString("base64"),
ciphertextBase64: ciphertext.toString("base64"),
weightsSha256,
};
const local = buildLocalOnly();
const modelKeyEvidence: TeeEvidence = {
...local.evidence,
measurements: {
...local.evidence.measurements,
modelWeights: `sha256:${weightsSha256}`,
},
};
const unsealPolicy: TeeEvidencePolicy = {
...local.policy,
requiredMeasurements: {
...(local.policy.requiredMeasurements ?? {}),
modelWeights: `sha256:${weightsSha256}`,
},
};
const unseal = await unsealModelWeights({
keyReleaseClient: {
releaseKey: async (req) => {
const decision = evaluateTeeEvidencePolicy(
modelKeyEvidence,
req.policy,
);
return {
keyId: req.keyId,
keyMaterialHex: decision.trusted ? modelKey.toString("hex") : "",
decision,
};
},
},
policy: unsealPolicy,
sealedWeights,
requiredMeasurements: UNSEAL_REQUIRED,
context: "full-stack-local-inference",
});
const happyPath =
unseal.weights.equals(plaintextWeights) &&
unseal.weightsSha256 === weightsSha256 &&
unseal.decision.trusted === true;
const deniedPath = await unsealModelWeights({
keyReleaseClient: {
releaseKey: async (req) => {
const decision = evaluateTeeEvidencePolicy(
{
...modelKeyEvidence,
measurements: {
...modelKeyEvidence.measurements,
agent: ZERO_DIGEST,
},
},
req.policy,
);
return { keyId: req.keyId, keyMaterialHex: "", decision };
},
},
policy: unsealPolicy,
sealedWeights,
requiredMeasurements: UNSEAL_REQUIRED,
}).then(
() => false,
(error: unknown) =>
error instanceof Error && /model-key release denied/.test(error.message),
);
return { happyPath, deniedPath, weightsSha256 };
}
// ---------------------------------------------------------------------------
// Drive everything, self-check, write artifacts.
// ---------------------------------------------------------------------------
const topologyResults = await Promise.all(topologies.map(runTopology));
const { results: reasonResults, coverageOk } = runReasonMatrix();
const unseal = await runUnseal();
const topologiesOk = topologyResults.every((result) => result.ok);
const reasonsOk = reasonResults.every((result) => result.ok);
const unsealOk = unseal.happyPath && unseal.deniedPath;
const output = {
ok: topologiesOk && reasonsOk && coverageOk && unsealOk,
topologies: topologyResults,
decisionReasonMatrix: {
coverageOk,
reasonsCovered: reasonResults.length,
reasonsExpected: ALL_REASONS.length,
results: reasonResults,
},
modelKeyUnseal: unseal,
};
if (!output.ok) {
const failedReasons = reasonResults.filter((result) => !result.ok);
const failedTopologies = topologyResults.filter((result) => !result.ok);
throw new Error(
`TEE full-stack local failed: ${JSON.stringify(
{
topologiesOk,
reasonsOk,
coverageOk,
unsealOk,
failedTopologies: failedTopologies.map((result) => result.topology),
failedReasons: failedReasons.map((result) => result.reason),
},
null,
2,
)}`,
);
}
const outputPath = "evidence/tee/full-stack-local-2026-05-20.json";
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(output, null, 2)}\n`);
console.log(
`TEE full-stack local passed: ${topologyResults.length} topologies, ${reasonResults.length} decision reasons, unseal OK -> ${outputPath}`,
);
function summarize(decision: TeeEvidencePolicyDecision) {
return {
trusted: decision.trusted,
reason: decision.reason,
...(decision.detail === undefined ? {} : { detail: decision.detail }),
...(decision.evidence === undefined
? {}
: {
evidence: {
kind: decision.evidence.kind,
provider: decision.evidence.provider,
securityVersion: decision.evidence.securityVersion,
measurements: decision.evidence.measurements,
claims: decision.evidence.claims,
},
}),
};
}
+265
View File
@@ -0,0 +1,265 @@
/** Runs a local TEE smoke harness for agent boot and key-release plumbing. */
import { createHash } from "node:crypto";
import { mkdir, writeFile } from "node:fs/promises";
import type { IncomingMessage, ServerResponse } from "node:http";
import { createServer } from "node:http";
import path from "node:path";
import { collectDstackTeeEvidence } from "../../../plugins/plugin-tee/src/confidential/dstack-tee-provider.ts";
import type { TeeEvidence } from "../src/services/tee-evidence.ts";
import {
HttpTeeKeyReleaseClient,
LocalTeeKeyReleaseClient,
wrapTeeReleaseKey,
} from "../src/services/tee-key-release.ts";
import { evaluateTeeEvidencePolicy } from "../src/services/tee-policy.ts";
const digest = (char: string) => `sha256:${char.repeat(64)}`;
const nonce = "local-tee-smoke-nonce";
const now = "2026-05-20T00:00:00.000Z";
const evidence = {
kind: "dstack",
provider: "dstack",
hardwareVendor: "mock-macos",
platformVersion: "local-smoke",
securityVersion: 1,
measurements: {
boot: digest("a"),
os: digest("b"),
agent: digest("c"),
policy: digest("d"),
container: digest("e"),
},
freshness: {
nonce,
timestamp: now,
verifier: "local-smoke",
},
claims: {
debugDisabled: true,
productionLifecycle: true,
secureBoot: true,
memoryEncrypted: true,
ioProtected: true,
},
};
const server = createServer((request, response) => {
if (request.url !== "/evidence") {
if (request.url === "/v1/tee/key-release" && request.method === "POST") {
void handleKeyReleaseRequest(request, response);
return;
}
response.writeHead(404);
response.end("not found");
return;
}
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify(evidence));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Local TEE smoke server did not bind to a TCP port.");
}
try {
const endpointUrl = `http://127.0.0.1:${address.port}/evidence`;
const collected = await collectDstackTeeEvidence({
endpointUrl,
env: {},
});
const accepted = evaluateTeeEvidencePolicy(collected, {
required: true,
allowedKinds: ["dstack"],
allowedProviders: ["dstack"],
minSecurityVersion: 1,
expectedNonce: nonce,
maxAgeMs: 60_000,
nowMs: Date.parse(now),
requiredMeasurements: {
boot: digest("a"),
os: digest("b"),
agent: digest("c"),
policy: digest("d"),
},
requiredClaims: {
debugDisabled: true,
productionLifecycle: true,
secureBoot: true,
memoryEncrypted: true,
ioProtected: true,
},
});
const rejected = evaluateTeeEvidencePolicy(collected, {
required: true,
requiredMeasurements: {
agent: digest("f"),
},
});
const keyRelease = await new LocalTeeKeyReleaseClient({
masterSecretHex: "22".repeat(32),
evidenceProvider: {
id: "local-smoke",
collectEvidence: async () => collected,
},
}).releaseKey({
keyId: "local-agent-session",
context: "tee-local-smoke",
policy: {
required: true,
allowedKinds: ["dstack"],
expectedNonce: nonce,
requiredMeasurements: {
agent: digest("c"),
policy: digest("d"),
},
requiredClaims: {
debugDisabled: true,
secureBoot: true,
},
},
});
const httpKeyRelease = await new HttpTeeKeyReleaseClient({
baseUrl: `http://127.0.0.1:${address.port}`,
evidenceProvider: {
id: "local-smoke",
collectEvidence: async () => collected,
},
}).releaseKey({
keyId: "remote-agent-session",
context: "tee-local-smoke-http",
policy: {
required: true,
allowedKinds: ["dstack"],
expectedNonce: nonce,
requiredMeasurements: {
agent: digest("c"),
policy: digest("d"),
},
requiredClaims: {
debugDisabled: true,
secureBoot: true,
},
},
});
const result = {
ok:
accepted.trusted === true &&
rejected.trusted === false &&
/^[a-f0-9]{64}$/.test(keyRelease.keyMaterialHex) &&
/^[a-f0-9]{64}$/.test(httpKeyRelease.keyMaterialHex),
endpoint: "local-http-evidence-endpoint",
accepted: summarizeDecision(accepted),
rejected: summarizeDecision(rejected),
keyRelease: {
keyId: keyRelease.keyId,
keyMaterialSha256: createHash("sha256")
.update(keyRelease.keyMaterialHex, "utf8")
.digest("hex"),
decision: summarizeDecision(keyRelease.decision),
},
httpKeyRelease: {
keyId: httpKeyRelease.keyId,
keyMaterialSha256: createHash("sha256")
.update(httpKeyRelease.keyMaterialHex, "utf8")
.digest("hex"),
decision: summarizeDecision(httpKeyRelease.decision),
},
};
if (!result.ok) {
throw new Error(`TEE local smoke failed: ${JSON.stringify(result)}`);
}
const outputPath = path.resolve(
"evidence/tee/local-tee-smoke-2026-05-20.json",
);
await mkdir(path.dirname(outputPath), { recursive: true });
await writeFile(outputPath, `${JSON.stringify(result, null, 2)}\n`);
console.log(`TEE local smoke passed: ${outputPath}`);
} finally {
server.close();
}
async function handleKeyReleaseRequest(
request: IncomingMessage,
response: ServerResponse,
) {
const body = await readRequestBody(request);
const payload = JSON.parse(body) as {
keyId: string;
context?: string;
nonce: string;
ephemeralPublicKey: string;
policy: Parameters<typeof evaluateTeeEvidencePolicy>[1];
evidence: TeeEvidence;
};
const decision = evaluateTeeEvidencePolicy(payload.evidence, payload.policy);
if (!decision.trusted) {
response.writeHead(403, { "content-type": "application/json" });
response.end(JSON.stringify({ decision }));
return;
}
const keyMaterialHex = createHash("sha256")
.update("mock-http-kms\n", "utf8")
.update(payload.keyId, "utf8")
.update(payload.context ?? "", "utf8")
.update(payload.evidence.measurements?.agent ?? "", "utf8")
.update(payload.evidence.measurements?.policy ?? "", "utf8")
.digest("hex");
// Wrap the released key to the agent's ephemeral public key so the client's
// X25519/HKDF/AES-256-GCM unwrap succeeds (plan §3.2 step 5).
const wrappedKey = wrapTeeReleaseKey({
keyMaterialHex,
agentEphemeralPublicKeyDerBase64: payload.ephemeralPublicKey,
nonceHex: payload.nonce,
});
response.writeHead(200, { "content-type": "application/json" });
response.end(
JSON.stringify({
keyId: payload.keyId,
wrappedKey,
// Echo the client-issued nonce for the replay-binding check.
nonce: payload.nonce,
decision,
}),
);
}
async function readRequestBody(request: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks).toString("utf8");
}
function summarizeDecision(decision: {
trusted: boolean;
reason: string;
detail?: string;
evidence?: TeeEvidence;
}) {
return {
trusted: decision.trusted,
reason: decision.reason,
...(decision.detail === undefined ? {} : { detail: decision.detail }),
...(decision.evidence === undefined
? {}
: {
evidence: {
kind: decision.evidence.kind,
provider: decision.evidence.provider,
hardwareVendor: decision.evidence.hardwareVendor,
platformVersion: decision.evidence.platformVersion,
securityVersion: decision.evidence.securityVersion,
measurements: decision.evidence.measurements,
freshness: decision.evidence.freshness,
claims: decision.evidence.claims,
},
}),
};
}
@@ -0,0 +1,108 @@
#!/usr/bin/env node
/** Validates deployed TEE agent configuration and attestation endpoints from operator inputs. */
import { readFileSync } from "node:fs";
const input =
process.argv[2] ?? "packages/agent/tee/dstack-agent-deployment.example.json";
const revocationsInput = process.argv[3];
const deployment = JSON.parse(readFileSync(input, "utf8"));
const revocations = revocationsInput
? JSON.parse(readFileSync(revocationsInput, "utf8"))
: undefined;
const errors = validateDeployment(deployment, revocations);
if (errors.length > 0) {
for (const error of errors) {
console.error(`error: ${error}`);
}
process.exit(1);
}
console.log(
`TEE deployment valid: ${input}${
revocationsInput ? ` against ${revocationsInput}` : ""
}`,
);
function validateDeployment(value, revocationManifest) {
const errors = [];
if (value?.schemaVersion !== 1) {
errors.push("schemaVersion must be 1");
}
requireString(errors, value?.deploymentId, "deploymentId");
if (value?.provider !== "dstack") {
errors.push("provider must be dstack");
}
if (!Array.isArray(value?.teeKinds) || value.teeKinds.length === 0) {
errors.push("teeKinds must be a non-empty array");
}
requireString(errors, value?.image?.repository, "image.repository");
requireString(errors, value?.image?.tag, "image.tag");
requireDigest(errors, value?.image?.digest, "image.digest");
requireDigest(errors, value?.composeDigest, "composeDigest");
requireDigest(errors, value?.policyDigest, "policyDigest");
requireString(errors, value?.kms?.url, "kms.url");
if (value?.kms?.requiresFreshNonce !== true) {
errors.push("kms.requiresFreshNonce must be true");
}
for (const claim of ["debugDisabled", "secureBoot"]) {
if (value?.requiredClaims?.[claim] !== true) {
errors.push(`requiredClaims.${claim} must be true`);
}
}
for (const measurement of ["agent", "policy", "container"]) {
requireDigest(
errors,
value?.requiredMeasurements?.[measurement],
`requiredMeasurements.${measurement}`,
);
}
if (!Array.isArray(value?.secretScopes) || value.secretScopes.length === 0) {
errors.push("secretScopes must be a non-empty array");
}
if (value?.network?.raTlsRequired !== true) {
errors.push("network.raTlsRequired must be true");
}
if (value?.requiredMeasurements?.agent !== value?.image?.digest) {
errors.push("requiredMeasurements.agent must match image.digest");
}
if (value?.requiredMeasurements?.policy !== value?.policyDigest) {
errors.push("requiredMeasurements.policy must match policyDigest");
}
if (value?.requiredMeasurements?.container !== value?.composeDigest) {
errors.push("requiredMeasurements.container must match composeDigest");
}
validateDeploymentNotRevoked(errors, value, revocationManifest);
return errors;
}
function validateDeploymentNotRevoked(errors, deployment, revocationManifest) {
if (revocationManifest === undefined) return;
if (revocationManifest?.schemaVersion !== 1) {
errors.push("revocation manifest schemaVersion must be 1");
return;
}
const revokedMeasurements = revocationManifest.revokedMeasurements ?? {};
for (const [name, digest] of Object.entries(
deployment.requiredMeasurements ?? {},
)) {
const revokedDigests = (revokedMeasurements[name] ?? [])
.map((entry) => (typeof entry === "string" ? entry : entry?.value))
.filter((entry) => typeof entry === "string");
if (revokedDigests.includes(digest)) {
errors.push(`requiredMeasurements.${name} is revoked`);
}
}
}
function requireString(errors, value, field) {
if (typeof value !== "string" || value.trim().length === 0) {
errors.push(`${field} must be a non-empty string`);
}
}
function requireDigest(errors, value, field) {
if (typeof value !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value)) {
errors.push(`${field} must be sha256:<64 lowercase hex>`);
}
}
@@ -0,0 +1,124 @@
#!/usr/bin/env node
/** Validates TEE revocation handling against configured deployment state. */
import { readFileSync } from "node:fs";
const input = process.argv[2] ?? "packages/agent/tee/revocations.example.json";
const manifest = JSON.parse(readFileSync(input, "utf8"));
const errors = validateRevocations(manifest);
if (errors.length > 0) {
for (const error of errors) {
console.error(`error: ${error}`);
}
process.exit(1);
}
console.log(`TEE revocations valid: ${input}`);
function validateRevocations(value) {
const errors = [];
if (value?.schemaVersion !== 1) {
errors.push("schemaVersion must be 1");
}
requireOptionalString(errors, value?.authority, "authority");
requireOptionalTimestamp(errors, value?.generatedAt, "generatedAt");
const revokedMeasurements = value?.revokedMeasurements;
if (
revokedMeasurements !== undefined &&
(!revokedMeasurements ||
typeof revokedMeasurements !== "object" ||
Array.isArray(revokedMeasurements))
) {
errors.push("revokedMeasurements must be an object when present");
}
for (const [name, entries] of Object.entries(revokedMeasurements ?? {})) {
if (!["boot", "os", "agent", "policy", "container"].includes(name)) {
errors.push(`revokedMeasurements.${name} is not a known measurement`);
}
if (!Array.isArray(entries) || entries.length === 0) {
errors.push(`revokedMeasurements.${name} must be a non-empty array`);
continue;
}
entries.forEach((entry, index) =>
validateDigestEntry(
errors,
entry,
`revokedMeasurements.${name}[${index}]`,
),
);
}
const revokedSecurityVersions = value?.revokedSecurityVersions;
if (
revokedSecurityVersions !== undefined &&
!Array.isArray(revokedSecurityVersions)
) {
errors.push("revokedSecurityVersions must be an array when present");
}
for (const [index, entry] of (revokedSecurityVersions ?? []).entries()) {
validateSecurityVersionEntry(
errors,
entry,
`revokedSecurityVersions[${index}]`,
);
}
return errors;
}
function validateDigestEntry(errors, entry, field) {
if (typeof entry === "string") {
requireDigest(errors, entry, field);
return;
}
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`${field} must be a digest string or object`);
return;
}
requireDigest(errors, entry.value, `${field}.value`);
requireOptionalString(errors, entry.reason, `${field}.reason`);
requireOptionalString(errors, entry.source, `${field}.source`);
requireOptionalTimestamp(errors, entry.revokedAt, `${field}.revokedAt`);
}
function validateSecurityVersionEntry(errors, entry, field) {
if (typeof entry === "number") {
requireSecurityVersion(errors, entry, field);
return;
}
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`${field} must be a version number or object`);
return;
}
requireSecurityVersion(errors, entry.value, `${field}.value`);
requireOptionalString(errors, entry.reason, `${field}.reason`);
requireOptionalString(errors, entry.source, `${field}.source`);
requireOptionalTimestamp(errors, entry.revokedAt, `${field}.revokedAt`);
}
function requireDigest(errors, value, field) {
if (typeof value !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value)) {
errors.push(`${field} must be sha256:<64 lowercase hex>`);
}
}
function requireSecurityVersion(errors, value, field) {
if (!Number.isSafeInteger(value) || value < 0) {
errors.push(`${field} must be a non-negative safe integer`);
}
}
function requireOptionalString(errors, value, field) {
if (value !== undefined && (typeof value !== "string" || !value.trim())) {
errors.push(`${field} must be a non-empty string when present`);
}
}
function requireOptionalTimestamp(errors, value, field) {
if (value === undefined) return;
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
errors.push(`${field} must be an ISO-parseable timestamp when present`);
}
}
@@ -0,0 +1,53 @@
/**
* Guards @elizaos/agent source against relative imports that escape the
* package root (e.g. "../../../core/src/…"): tsc emits the pulled-in sibling
* sources as gitignored .js litter inside the sibling's src/ and the built
* dist then depends on that litter existing at runtime (#13515). Real walker
* over the real src tree plus a fixture proving the walker detects the
* failure signature.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { findCrossPackageImports } from "../../scripts/assert-package-boundary-imports.mjs";
describe("package boundary imports", () => {
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) {
rmSync(root, { recursive: true, force: true });
}
});
it("detects relative imports that escape the package root", () => {
const packageRoot = mkdtempSync(path.join(os.tmpdir(), "boundary-"));
tempRoots.push(packageRoot);
const apiDir = path.join(packageRoot, "src", "api");
mkdirSync(apiDir, { recursive: true });
writeFileSync(
path.join(apiDir, "inbox-routes.ts"),
[
`import { helper } from "../shared/helper.ts";`,
`import {`,
` resolveEffectiveMuteState,`,
`} from "../../../core/src/services/message/mute-state.ts";`,
`export const x = [helper, resolveEffectiveMuteState];`,
].join("\n"),
);
expect(findCrossPackageImports(packageRoot)).toEqual([
{
file: path.join("src", "api", "inbox-routes.ts"),
line: 4,
specifier: "../../../core/src/services/message/mute-state.ts",
},
]);
});
it("has no cross-package relative imports in packages/agent/src", () => {
expect(findCrossPackageImports()).toEqual([]);
});
});
@@ -0,0 +1,367 @@
/**
* Plugin lifecycle leak tests.
*
* Verifies that loading and unloading plugins leaves the runtime in a clean
* baseline state with no accumulated actions, providers, evaluators, or routes.
* Also exercises dispose hooks and documents detectable vs. undetectable leak
* patterns.
*/
import type { Plugin } from "@elizaos/core";
import { describe, expect, it } from "vitest";
import {
createTestPlugin,
createTestRuntime,
cyclePlugin,
} from "./plugin-lifecycle-test-utils.ts";
// ---------------------------------------------------------------------------
// 1. Timer leak: documents that setInterval leaked in init() without clearInterval
// in dispose() is NOT caught by the lifecycle system (the interval keeps
// running), but the plugin still unloads correctly from the runtime's
// perspective (no actions/providers/routes leak).
// ---------------------------------------------------------------------------
describe("timer leak — plugin forgets clearInterval in dispose", () => {
it("plugin unloads cleanly from the runtime even when an interval leaks", async () => {
const intervalIds: ReturnType<typeof setInterval>[] = [];
const leakyPlugin: Plugin = {
name: "timer-leak-plugin",
description: "Registers an interval in init but never clears it",
init: async () => {
// Deliberately leaked interval — no dispose cleanup
intervalIds.push(setInterval(() => {}, 10_000));
},
// No dispose hook — interval leaks
actions: [
{
name: "TIMER_LEAK_ACTION",
description: "action from leaky plugin",
examples: [],
similes: [],
validate: async () => true,
handler: async () => ({ success: true }),
},
],
};
const runtime = createTestRuntime();
await runtime.registerPlugin(leakyPlugin);
expect(runtime.actions.some((a) => a.name === "TIMER_LEAK_ACTION")).toBe(
true,
);
await runtime.unloadPlugin("timer-leak-plugin");
// Runtime is clean — no action residue
expect(runtime.actions.some((a) => a.name === "TIMER_LEAK_ACTION")).toBe(
false,
);
// Clean up the leaked intervals so vitest exits cleanly
for (const id of intervalIds) clearInterval(id);
});
it("a well-written plugin that clears its interval in dispose leaves no action residue", async () => {
let intervalId: ReturnType<typeof setInterval> | null = null;
const cleanPlugin: Plugin = {
name: "timer-clean-plugin",
description: "Registers and clears an interval correctly",
init: async () => {
intervalId = setInterval(() => {}, 10_000);
},
dispose: async () => {
if (intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
},
actions: [
{
name: "TIMER_CLEAN_ACTION",
description: "action from clean plugin",
examples: [],
similes: [],
validate: async () => true,
handler: async () => ({ success: true }),
},
],
};
const runtime = createTestRuntime();
await runtime.registerPlugin(cleanPlugin);
await runtime.unloadPlugin("timer-clean-plugin");
expect(intervalId).toBeNull();
expect(runtime.actions.some((a) => a.name === "TIMER_CLEAN_ACTION")).toBe(
false,
);
});
});
// ---------------------------------------------------------------------------
// 2. Event listener leak: runtime event handlers are tracked by the lifecycle
// system and are removed on unload even without a dispose hook.
// ---------------------------------------------------------------------------
describe("event listener cleanup via lifecycle tracking", () => {
it("event handlers registered via registerEvent are removed on unload", async () => {
const calls: string[] = [];
const pluginWithEvent: Plugin = {
name: "event-listener-plugin",
description: "Registers a runtime event handler",
events: {
MESSAGE_RECEIVED: [
async () => {
calls.push("handled");
},
],
},
};
const runtime = createTestRuntime();
await runtime.registerPlugin(pluginWithEvent);
// Event should be registered
expect(runtime.events.MESSAGE_RECEIVED?.length).toBeGreaterThan(0);
await runtime.unloadPlugin("event-listener-plugin");
// Event handler should be removed
const remaining = runtime.events.MESSAGE_RECEIVED ?? [];
expect(remaining.length).toBe(0);
});
});
// ---------------------------------------------------------------------------
// 3. Clean plugin round-trip: 10 load/unload cycles with a well-written plugin.
// Baseline counts must be restored after every unload.
// ---------------------------------------------------------------------------
describe("clean plugin round-trip (10 cycles)", () => {
it("action, provider, evaluator, and route counts return to baseline each cycle", async () => {
const runtime = createTestRuntime();
const baselineActions = runtime.actions.length;
const baselineProviders = runtime.providers.length;
const baselineEvaluators = runtime.evaluators.length;
const baselineRoutes = runtime.routes.length;
const plugin = createTestPlugin();
const metrics = await cyclePlugin(runtime, plugin, 10);
expect(metrics).toHaveLength(10);
for (const m of metrics) {
expect(m.actionCountAfter).toBe(baselineActions);
expect(m.providerCountAfter).toBe(baselineProviders);
expect(m.evaluatorCountAfter).toBe(baselineEvaluators);
expect(m.routeCountAfter).toBe(baselineRoutes);
}
});
});
// ---------------------------------------------------------------------------
// 4. Actions unregistered: after unload, plugin's actions are not in runtime.actions
// ---------------------------------------------------------------------------
describe("actions unregistered after unload", () => {
it("plugin actions are not present in runtime.actions after unloadPlugin", async () => {
const plugin = createTestPlugin({ name: "action-cleanup-plugin" });
const runtime = createTestRuntime();
await runtime.registerPlugin(plugin);
const registeredActionNames = runtime.actions.map((a) => a.name);
expect(registeredActionNames.length).toBeGreaterThan(0);
await runtime.unloadPlugin("action-cleanup-plugin");
for (const name of registeredActionNames) {
// The baseline runtime may re-register its own actions; only check
// the plugin's action doesn't linger
if (name.startsWith("ACTION_CLEANUP_PLUGIN")) {
expect(runtime.actions.some((a) => a.name === name)).toBe(false);
}
}
});
it("the exact action name from the plugin is not in runtime.actions after unload", async () => {
const action = {
name: "MY_UNIQUE_PLUGIN_ACTION_12345",
description: "unique action",
examples: [] as never[],
similes: [] as string[],
validate: async () => true,
handler: async () => ({ success: true }),
};
const plugin: Plugin = {
name: "unique-action-plugin",
description: "plugin with a uniquely named action",
actions: [action],
};
const runtime = createTestRuntime();
await runtime.registerPlugin(plugin);
expect(runtime.actions.some((a) => a.name === action.name)).toBe(true);
await runtime.unloadPlugin("unique-action-plugin");
expect(runtime.actions.some((a) => a.name === action.name)).toBe(false);
});
});
// ---------------------------------------------------------------------------
// 5. Routes removed after unload
// ---------------------------------------------------------------------------
describe("routes removed after unload", () => {
it("plugin routes are not present in runtime.routes after unloadPlugin", async () => {
const plugin: Plugin = {
name: "route-plugin",
description: "plugin that registers a route",
routes: [
{
type: "GET",
path: "/api/test-lifecycle-route",
rawPath: true,
handler: async (_req, res) => {
res.json({ ok: true });
},
},
],
};
const runtime = createTestRuntime();
const baselineRoutes = runtime.routes.length;
await runtime.registerPlugin(plugin);
expect(runtime.routes.length).toBe(baselineRoutes + 1);
await runtime.unloadPlugin("route-plugin");
expect(runtime.routes.length).toBe(baselineRoutes);
expect(
runtime.routes.some((r) => r.path === "/api/test-lifecycle-route"),
).toBe(false);
});
});
// ---------------------------------------------------------------------------
// 6. Load → unload → reload stability: plugin works correctly on second load
// ---------------------------------------------------------------------------
describe("load-unload-load stability", () => {
it("a plugin re-registered after unload works the same as the first load", async () => {
const handlerCalls: number[] = [];
const plugin: Plugin = {
name: "stability-plugin",
description: "Plugin for reload stability test",
actions: [
{
name: "STABILITY_PING",
description: "ping action",
examples: [],
similes: [],
validate: async () => true,
handler: async () => {
handlerCalls.push(Date.now());
return { success: true, data: { pong: true } };
},
},
],
};
const runtime = createTestRuntime();
// First load
await runtime.registerPlugin(plugin);
const actionAfterFirstLoad = runtime.actions.find(
(a) => a.name === "STABILITY_PING",
);
expect(actionAfterFirstLoad).toBeDefined();
// Invoke the action
const result1 = (await actionAfterFirstLoad?.handler?.(
runtime as never,
{} as never,
{} as never,
)) as { data?: { pong?: boolean } } | undefined;
expect(result1?.data?.pong).toBe(true);
// Unload
await runtime.unloadPlugin("stability-plugin");
expect(runtime.actions.some((a) => a.name === "STABILITY_PING")).toBe(
false,
);
// Second load
await runtime.registerPlugin(plugin);
const actionAfterReload = runtime.actions.find(
(a) => a.name === "STABILITY_PING",
);
expect(actionAfterReload).toBeDefined();
const result2 = (await actionAfterReload?.handler?.(
runtime as never,
{} as never,
{} as never,
)) as { data?: { pong?: boolean } } | undefined;
expect(result2?.data?.pong).toBe(true);
expect(handlerCalls.length).toBe(2);
});
it("dispose hook is called on each unload across multiple cycles", async () => {
const disposeCalls: number[] = [];
const plugin: Plugin = {
name: "dispose-tracking-plugin",
description: "tracks dispose invocations",
dispose: async () => {
disposeCalls.push(Date.now());
},
};
const runtime = createTestRuntime();
const cycles = 5;
for (let i = 0; i < cycles; i++) {
await runtime.registerPlugin(plugin);
await runtime.unloadPlugin("dispose-tracking-plugin");
}
expect(disposeCalls).toHaveLength(cycles);
});
});
// ---------------------------------------------------------------------------
// 7. No residual ownership after unload
// ---------------------------------------------------------------------------
describe("plugin ownership tracking", () => {
it("getPluginOwnership returns null after unload", async () => {
const plugin = createTestPlugin({ name: "ownership-test-plugin" });
const runtime = createTestRuntime();
await runtime.registerPlugin(plugin);
expect(typeof runtime.getPluginOwnership).toBe("function");
expect(runtime.getPluginOwnership("ownership-test-plugin")).not.toBeNull();
await runtime.unloadPlugin("ownership-test-plugin");
expect(runtime.getPluginOwnership("ownership-test-plugin")).toBeNull();
});
it("getAllPluginOwnership does not include unloaded plugins", async () => {
const plugin1 = createTestPlugin({ name: "owned-plugin-one" });
const plugin2 = createTestPlugin({ name: "owned-plugin-two" });
const runtime = createTestRuntime();
await runtime.registerPlugin(plugin1);
await runtime.registerPlugin(plugin2);
const before = runtime.getAllPluginOwnership().map((o) => o.pluginName);
expect(before).toContain("owned-plugin-one");
expect(before).toContain("owned-plugin-two");
await runtime.unloadPlugin("owned-plugin-one");
const after = runtime.getAllPluginOwnership().map((o) => o.pluginName);
expect(after).not.toContain("owned-plugin-one");
expect(after).toContain("owned-plugin-two");
});
});
@@ -0,0 +1,116 @@
/**
* Shared utilities for plugin lifecycle tests.
*
* Uses `AgentRuntime` from `@elizaos/core` directly — its constructor calls
* `installRuntimePluginLifecycle`, so `unloadPlugin` and `reloadPlugin` are
* always present without any additional setup.
*/
import type { Plugin } from "@elizaos/core";
import { AgentRuntime } from "@elizaos/core";
export type { Plugin };
export { AgentRuntime };
export interface LifecycleCycleMetrics {
cycle: number;
loadMs: number;
unloadMs: number;
actionCountBefore: number;
actionCountAfter: number;
providerCountBefore: number;
providerCountAfter: number;
evaluatorCountBefore: number;
evaluatorCountAfter: number;
routeCountBefore: number;
routeCountAfter: number;
}
export type TestRuntime = AgentRuntime & {
unloadPlugin: (name: string) => Promise<unknown>;
reloadPlugin: (plugin: Plugin) => Promise<void>;
};
/**
* Creates a minimal AgentRuntime suitable for plugin lifecycle testing.
* No database adapter is registered, so adapter-owning plugins will throw
* when unloaded without `allowAdapterUnload`.
*/
export function createTestRuntime(): TestRuntime {
return new AgentRuntime({ logLevel: "fatal" }) as TestRuntime;
}
/**
* Creates a minimal valid plugin for lifecycle testing.
* The plugin has one action, one provider, and an optional dispose hook.
*/
export function createTestPlugin(overrides: Partial<Plugin> = {}): Plugin {
const name = overrides.name ?? "test-lifecycle-plugin";
return {
name,
description: "Minimal plugin for lifecycle testing",
actions: [
{
name: `${name.toUpperCase()}_ACTION`,
description: "A test action",
examples: [],
similes: [],
validate: async () => true,
handler: async () => ({ success: true, data: { ok: true } }),
},
],
providers: [
{
name: `${name.toUpperCase()}_PROVIDER`,
description: "A test provider",
get: async () => ({ text: "test-provider-output" }),
},
],
...overrides,
};
}
/**
* Loads and unloads a plugin N times, recording per-cycle timing and
* runtime component counts before/after each unload.
*
* The same plugin object is reused across cycles (simulating a reload).
*/
export async function cyclePlugin(
runtime: TestRuntime,
plugin: Plugin,
cycles: number,
): Promise<LifecycleCycleMetrics[]> {
const metrics: LifecycleCycleMetrics[] = [];
for (let cycle = 1; cycle <= cycles; cycle++) {
const loadStart = performance.now();
await runtime.registerPlugin(plugin);
const loadMs = performance.now() - loadStart;
const actionCountBefore = runtime.actions.length;
const providerCountBefore = runtime.providers.length;
const evaluatorCountBefore = runtime.evaluators.length;
const routeCountBefore = runtime.routes.length;
const unloadStart = performance.now();
await runtime.unloadPlugin(plugin.name);
const unloadMs = performance.now() - unloadStart;
metrics.push({
cycle,
loadMs,
unloadMs,
actionCountBefore,
actionCountAfter: runtime.actions.length,
providerCountBefore,
providerCountAfter: runtime.providers.length,
evaluatorCountBefore,
evaluatorCountAfter: runtime.evaluators.length,
routeCountBefore,
routeCountAfter: runtime.routes.length,
});
}
return metrics;
}
@@ -0,0 +1,425 @@
/**
* Real-world plugin smoke tests for lifecycle correctness.
*
* These tests use synthetic plugins that match the shape of real plugins in
* this codebase (actions + providers + routes + dispose hooks) but without
* external service dependencies. They validate the full load/unload contract
* under conditions that resemble production plugin structure.
*
* Note on route paths: the runtime prefixes registered routes with
* `/<pluginName>` unless the route sets `rawPath: true`. Assertions check
* for substring inclusion (`.includes(path)`) rather than exact equality.
*/
import type { Plugin } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { installRuntimePluginLifecycle } from "../runtime/plugin-lifecycle.ts";
import { createTestRuntime } from "./plugin-lifecycle-test-utils.ts";
/** Returns true if the runtime has a registered route whose path includes the given segment. */
function hasRoutePath(routes: { path: string }[], segment: string): boolean {
return routes.some((r) => r.path.includes(segment));
}
/**
* Returns a plugin shaped like the agent-skills plugin:
* multiple actions, multiple providers, one omitted service (to
* avoid DB dependency), and a dispose hook.
*/
function makeSyntheticSkillsPlugin(): Plugin {
return {
name: "synthetic-skills-plugin",
description: "Synthetic plugin matching agent-skills structure",
actions: [
{
name: "USE_SKILL",
description: "Invoke an enabled skill by slug",
examples: [],
similes: ["run skill", "execute skill"],
validate: async () => true,
handler: async () => ({
success: true,
data: { result: "skill-output" },
}),
},
{
name: "SKILL",
description: "Manage skills",
examples: [],
similes: [],
validate: async () => true,
handler: async () => ({ success: true, data: { skills: [] } }),
},
],
providers: [
{
name: "ENABLED_SKILLS_PROVIDER",
description: "Lists enabled skills for the planner",
get: async () => ({ text: "no skills enabled" }),
},
{
name: "SKILLS_SUMMARY_PROVIDER",
description: "Summary of installed skills",
get: async () => ({ text: "0 skills installed" }),
},
],
routes: [
{
type: "GET",
path: "/api/skills/catalog",
rawPath: true,
handler: async (_req, res) => {
res.json({ skills: [] });
},
},
{
type: "POST",
path: "/api/skills/enable",
rawPath: true,
handler: async (_req, res) => {
res.json({ ok: true });
},
},
],
dispose: async () => {
// Production variant would stop a background sync task here.
},
};
}
/**
* Returns a plugin shaped like an app plugin:
* routes, one action, and app metadata.
*/
function makeSyntheticAppPlugin(): Plugin {
return {
name: "synthetic-app-plugin",
description: "Synthetic plugin matching app plugin structure",
actions: [
{
name: "APP_ACTION",
description: "An app-level action",
examples: [],
similes: [],
validate: async () => true,
handler: async () => ({ success: true, data: { done: true } }),
},
],
routes: [
{
type: "GET",
path: "/api/app/status",
rawPath: true,
handler: async (_req, res) => {
res.json({ status: "running" });
},
},
],
app: {
displayName: "Synthetic App",
category: "productivity",
},
};
}
/**
* Returns a plugin shaped like a connector plugin:
* events, one action.
*/
function makeSyntheticConnectorPlugin(): Plugin {
return {
name: "synthetic-connector-plugin",
description: "Synthetic plugin matching connector plugin structure",
actions: [
{
name: "SEND_MESSAGE",
description: "Send a message via this connector",
examples: [],
similes: ["message", "send"],
validate: async () => true,
handler: async () => ({ success: true, data: { sent: true } }),
},
],
events: {
MESSAGE_RECEIVED: [
async () => {
// Handle incoming message
},
],
},
dispose: async () => {
// Would close WebSocket / disconnect in production
},
};
}
describe("skills-shaped plugin — 3 load/unload cycles", () => {
it("runs 3 cycles and restores baseline state each time", async () => {
const runtime = createTestRuntime();
const plugin = makeSyntheticSkillsPlugin();
const baselineActions = runtime.actions.length;
const baselineProviders = runtime.providers.length;
const baselineRoutes = runtime.routes.length;
for (let cycle = 1; cycle <= 3; cycle++) {
await runtime.registerPlugin(plugin);
expect(runtime.actions.some((a) => a.name === "USE_SKILL")).toBe(true);
expect(runtime.actions.some((a) => a.name === "SKILL")).toBe(true);
expect(
runtime.providers.some((p) => p.name === "ENABLED_SKILLS_PROVIDER"),
).toBe(true);
expect(hasRoutePath(runtime.routes, "/api/skills/catalog")).toBe(true);
await runtime.unloadPlugin("synthetic-skills-plugin");
expect(runtime.actions.some((a) => a.name === "USE_SKILL")).toBe(false);
expect(runtime.actions.some((a) => a.name === "SKILL")).toBe(false);
expect(
runtime.providers.some((p) => p.name === "ENABLED_SKILLS_PROVIDER"),
).toBe(false);
expect(hasRoutePath(runtime.routes, "/api/skills/catalog")).toBe(false);
expect(runtime.actions.length).toBe(baselineActions);
expect(runtime.providers.length).toBe(baselineProviders);
expect(runtime.routes.length).toBe(baselineRoutes);
}
});
});
describe("app-shaped plugin — 3 load/unload cycles", () => {
it("runs 3 cycles and restores baseline state each time", async () => {
const runtime = createTestRuntime();
const plugin = makeSyntheticAppPlugin();
const baselineActions = runtime.actions.length;
const baselineRoutes = runtime.routes.length;
for (let cycle = 1; cycle <= 3; cycle++) {
await runtime.registerPlugin(plugin);
expect(runtime.actions.some((a) => a.name === "APP_ACTION")).toBe(true);
expect(hasRoutePath(runtime.routes, "/api/app/status")).toBe(true);
await runtime.unloadPlugin("synthetic-app-plugin");
expect(runtime.actions.some((a) => a.name === "APP_ACTION")).toBe(false);
expect(hasRoutePath(runtime.routes, "/api/app/status")).toBe(false);
expect(runtime.actions.length).toBe(baselineActions);
expect(runtime.routes.length).toBe(baselineRoutes);
}
});
});
describe("connector-shaped plugin — 3 load/unload cycles", () => {
it("event handlers are registered and removed each cycle", async () => {
const runtime = createTestRuntime();
const plugin = makeSyntheticConnectorPlugin();
for (let cycle = 1; cycle <= 3; cycle++) {
await runtime.registerPlugin(plugin);
expect(runtime.actions.some((a) => a.name === "SEND_MESSAGE")).toBe(true);
expect(runtime.events.MESSAGE_RECEIVED?.length ?? 0).toBeGreaterThan(0);
await runtime.unloadPlugin("synthetic-connector-plugin");
expect(runtime.actions.some((a) => a.name === "SEND_MESSAGE")).toBe(
false,
);
expect(runtime.events.MESSAGE_RECEIVED?.length ?? 0).toBe(0);
}
});
});
describe("mixed plugins — two plugins coexist, one unloads cleanly", () => {
it("unloading one plugin does not affect a different plugin's registered components", async () => {
const runtime = createTestRuntime();
const skills = makeSyntheticSkillsPlugin();
const app = makeSyntheticAppPlugin();
await runtime.registerPlugin(skills);
await runtime.registerPlugin(app);
expect(runtime.actions.some((a) => a.name === "USE_SKILL")).toBe(true);
expect(runtime.actions.some((a) => a.name === "APP_ACTION")).toBe(true);
// Unload only the skills plugin
await runtime.unloadPlugin("synthetic-skills-plugin");
expect(runtime.actions.some((a) => a.name === "USE_SKILL")).toBe(false);
expect(runtime.actions.some((a) => a.name === "APP_ACTION")).toBe(true);
expect(hasRoutePath(runtime.routes, "/api/app/status")).toBe(true);
});
});
describe("schema-bearing plugin registration", () => {
it("runs plugin migrations after a schema plugin registers against a ready adapter", async () => {
const runtime = createTestRuntime();
installRuntimePluginLifecycle(runtime);
const runPluginMigrations = vi.fn(async () => {});
runtime.registerDatabaseAdapter({
isReady: async () => true,
runPluginMigrations,
} as never);
await runtime.registerPlugin({
name: "schema-ready-plugin",
description: "plugin with a database schema",
schema: {
widgets: {
id: "text",
},
},
actions: [
{
name: "SCHEMA_READY_ACTION",
description: "action",
examples: [],
similes: [],
validate: async () => true,
handler: async () => ({ success: true }),
},
],
});
expect(runPluginMigrations).toHaveBeenCalledOnce();
expect(runPluginMigrations).toHaveBeenCalledWith(
[
{
name: "schema-ready-plugin",
schema: {
widgets: {
id: "text",
},
},
},
],
{
verbose: true,
force: false,
dryRun: false,
},
);
});
it("skips schema migration while the adapter is not ready", async () => {
const runtime = createTestRuntime();
installRuntimePluginLifecycle(runtime);
const runPluginMigrations = vi.fn(async () => {});
runtime.registerDatabaseAdapter({
isReady: async () => false,
runPluginMigrations,
} as never);
await runtime.registerPlugin({
name: "schema-not-ready-plugin",
description: "plugin with a database schema",
schema: {
widgets: {
id: "text",
},
},
});
expect(runPluginMigrations).not.toHaveBeenCalled();
});
it("rolls back plugin components when schema migration fails", async () => {
const runtime = createTestRuntime();
installRuntimePluginLifecycle(runtime);
const runPluginMigrations = vi.fn(async () => {
throw new Error("migration failed");
});
runtime.registerDatabaseAdapter({
isReady: async () => true,
runPluginMigrations,
} as never);
await expect(
runtime.registerPlugin({
name: "schema-failure-plugin",
description: "plugin with a failing database schema",
schema: {
widgets: {
id: "text",
},
},
actions: [
{
name: "SCHEMA_FAILURE_ACTION",
description: "action",
examples: [],
similes: [],
validate: async () => true,
handler: async () => ({ success: true }),
},
],
routes: [
{
type: "GET",
path: "/api/schema-failure",
rawPath: true,
handler: async (_req, res) => {
res.json({ ok: true });
},
},
],
}),
).rejects.toThrow("migration failed");
expect(
runtime.plugins.some((p) => p.name === "schema-failure-plugin"),
).toBe(false);
expect(
runtime.actions.some((a) => a.name === "SCHEMA_FAILURE_ACTION"),
).toBe(false);
expect(hasRoutePath(runtime.routes, "/api/schema-failure")).toBe(false);
});
});
describe("dispose error handling", () => {
it("a plugin whose dispose hook throws does not corrupt the runtime state", async () => {
const plugin: Plugin = {
name: "dispose-error-plugin",
description: "plugin with a throwing dispose hook",
dispose: async () => {
throw new Error("dispose failed intentionally");
},
actions: [
{
name: "DISPOSE_ERROR_ACTION",
description: "action",
examples: [],
similes: [],
validate: async () => true,
handler: async () => ({ success: true }),
},
],
};
const runtime = createTestRuntime();
await runtime.registerPlugin(plugin);
expect(runtime.actions.some((a) => a.name === "DISPOSE_ERROR_ACTION")).toBe(
true,
);
// unloadPlugin wraps dispose errors in AggregateError and rethrows
await expect(
runtime.unloadPlugin("dispose-error-plugin"),
).rejects.toThrow();
// Despite the error, the lifecycle still removes the plugin's components
// because teardownPluginOwnership runs component removal in a separate
// try/catch from the dispose hook.
// The action should be removed even when dispose threw.
expect(runtime.actions.some((a) => a.name === "DISPOSE_ERROR_ACTION")).toBe(
false,
);
});
});
@@ -0,0 +1,544 @@
/**
* Plugin view inventory ratchet + viewType infrastructure coverage (#15269).
*
* The shipped plugin-view inventory is GUI-only: every plugin manifest's
* `views:` declarations are statically parsed from source and asserted to
* declare no non-GUI surface. The viewType routing CONTRACT is preserved
* infrastructure — the views-registry and `handleViewsRoutes` still accept
* non-GUI requests — so this file also registers the real gui inventory through
* the real views-registry, drives navigate / interact dispatch in gui mode, and
* proves the designed degrade for non-GUI requests against a gui-only inventory:
* `getView` falls back to the gui declaration (the broadcast + response carry
* viewType "gui"), and unknown ids 404. No crash, no fabricated non-GUI
* success.
*
* Harness realism: manifests are read off disk and the registry + route
* dispatch are the real modules; a fake `IncomingMessage` and a
* `resolveViewInteractResult` stub stand in for the async view-interact
* round-trip that a live shell client would complete.
*/
import { EventEmitter } from "node:events";
import { readdirSync, readFileSync } from "node:fs";
import type http from "node:http";
import { dirname, join, relative, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import type { Plugin, ViewDeclaration } from "@elizaos/core";
import { describe, expect, it } from "vitest";
import {
registerPluginViews,
unregisterPluginViews,
} from "../api/views-registry.js";
import {
clearCurrentViewState,
handleViewsRoutes,
resolveViewInteractResult,
type ViewsRouteContext,
} from "../api/views-routes.js";
type RoutedViewType = "gui" | "tui" | "xr";
const repoRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../../..",
);
const VIEW_MANIFESTS = discoverPluginViewManifestPaths();
function readManifest(path: string): string {
return readFileSync(resolve(repoRoot, path), "utf8");
}
function discoverPluginViewManifestPaths(): string[] {
const pluginRoot = resolve(repoRoot, "plugins");
const discovered: string[] = [];
const visit = (directory: string) => {
for (const entry of readdirSync(directory, { withFileTypes: true })) {
const fullPath = join(directory, entry.name);
const relativePath = relative(repoRoot, fullPath);
if (entry.isDirectory()) {
if (
["dist", "node_modules", "coverage"].includes(entry.name) ||
relativePath.includes(`${sep}test${sep}`) ||
relativePath.includes(`${sep}__tests__${sep}`)
) {
continue;
}
visit(fullPath);
continue;
}
if (!entry.isFile()) continue;
if (!/\.(ts|tsx)$/.test(entry.name)) continue;
if (
entry.name.endsWith(".d.ts") ||
entry.name.includes(".test.") ||
entry.name.includes(".spec.") ||
entry.name === "vite.config.views.ts"
) {
continue;
}
const source = readFileSync(fullPath, "utf8");
if (!source.includes("views:") || !source.includes("componentExport:")) {
continue;
}
if (viewObjects(source).length === 0) continue;
discovered.push(relativePath.split(sep).join("/"));
}
};
visit(pluginRoot);
return [...new Set(discovered)].sort();
}
function viewObjects(source: string): string[] {
const viewsStart = source.indexOf("views:");
if (viewsStart === -1) return [];
const arrayStart = source.indexOf("[", viewsStart);
if (arrayStart === -1) return [];
let depth = 0;
let arrayEnd = -1;
for (let index = arrayStart; index < source.length; index += 1) {
const char = source[index];
if (char === "[") depth += 1;
if (char === "]") depth -= 1;
if (depth === 0) {
arrayEnd = index;
break;
}
}
if (arrayEnd === -1) return [];
const viewsSource = source.slice(arrayStart + 1, arrayEnd);
const objects: string[] = [];
let objectStart = -1;
depth = 0;
for (let index = 0; index < viewsSource.length; index += 1) {
const char = viewsSource[index];
if (char === "{") {
if (depth === 0) objectStart = index;
depth += 1;
}
if (char === "}") {
depth -= 1;
if (depth === 0 && objectStart !== -1) {
objects.push(viewsSource.slice(objectStart, index + 1));
objectStart = -1;
}
}
}
return objects.filter(
(chunk) => chunk.includes("id:") && chunk.includes("componentExport:"),
);
}
function stringField(source: string, field: string): string | null {
const match = source.match(new RegExp(`${field}:\\s*["']([^"']+)["']`));
return match?.[1] ?? null;
}
/**
* The surfaces a single view object declares: the `modalities: [...]` array
* literal when present, otherwise the single `viewType` (default "gui").
*/
function viewObjectModalities(object: string): RoutedViewType[] {
const modalitiesMatch = object.match(/modalities:\s*\[([^\]]*)\]/);
if (modalitiesMatch) {
const mods = [...modalitiesMatch[1].matchAll(/["'](gui|tui|xr)["']/g)].map(
(m) => m[1] as RoutedViewType,
);
if (mods.length > 0) return mods;
}
return [(stringField(object, "viewType") ?? "gui") as RoutedViewType];
}
// viewDeclarations() only ever yields declarations whose componentExport is a
// present string (it filters out the rest), so narrow the core type — whose
// componentExport is optional for remote plugins — for these local consumers.
type CoveredView = ViewDeclaration & { componentExport: string };
function viewDeclarations(manifestPath: string): CoveredView[] {
return viewObjects(readManifest(manifestPath)).flatMap(
(object): CoveredView[] => {
const id = stringField(object, "id");
const label = stringField(object, "label");
const path = stringField(object, "path");
const bundlePath = stringField(object, "bundlePath");
const componentExport = stringField(object, "componentExport");
if (!id || !label || !bundlePath || !componentExport) return [];
const surface = object.includes('"agent-surface"')
? ({ capabilities: ["agent-surface"] } as const)
: undefined;
return viewObjectModalities(object).map((viewType) => ({
id,
label,
...(path === null ? {} : { path }),
...(surface ? { surface } : {}),
viewType,
bundlePath,
componentExport,
visibleInManager: true,
}));
},
);
}
function makeCtx(
method: string,
pathname: string,
broadcastWs?: (payload: object) => void,
body?: unknown,
json?: (res: http.ServerResponse, body: unknown) => void,
error?: (res: http.ServerResponse, message: string, status?: number) => void,
): ViewsRouteContext {
const url = new URL(`http://localhost${pathname}`);
const req = new EventEmitter() as http.IncomingMessage;
req.headers = {
"x-elizaos-client-id": "plugin-view-inventory-ratchet-client",
};
if (body !== undefined) {
const chunk = Buffer.from(JSON.stringify(body));
process.nextTick(() => {
req.emit("data", chunk);
req.emit("end");
});
} else if (method === "POST") {
process.nextTick(() => req.emit("end"));
}
return {
req,
res: {} as http.ServerResponse,
method,
pathname: url.pathname,
url,
json: json ?? (() => {}),
error: error ?? (() => {}),
broadcastWs,
broadcastWsToClientId: (_clientId, payload) => {
broadcastWs?.(payload);
return broadcastWs ? 1 : 0;
},
};
}
async function registerAllManifests(): Promise<{
pluginNames: string[];
views: Array<{ manifestPath: string; id: string; path?: string }>;
}> {
const pluginNames: string[] = [];
const views: Array<{ manifestPath: string; id: string; path?: string }> = [];
for (const manifestPath of VIEW_MANIFESTS) {
const declarations = viewDeclarations(manifestPath);
const pluginName = `test:${manifestPath}`;
pluginNames.push(pluginName);
await registerPluginViews(
{
name: pluginName,
description: `Test view manifest ${manifestPath}`,
actions: [],
views: declarations,
} satisfies Plugin,
undefined,
);
for (const declaration of declarations) {
if (declaration.viewType !== "gui") continue;
views.push({
manifestPath,
id: declaration.id,
path: declaration.path,
});
}
}
return { pluginNames, views };
}
describe("plugin view coverage", () => {
it("ships a GUI-only view inventory (no tui/xr declarations)", () => {
const nonGui: string[] = [];
for (const manifestPath of VIEW_MANIFESTS) {
for (const object of viewObjects(readManifest(manifestPath))) {
const id = stringField(object, "id") ?? "<unknown>";
for (const modality of viewObjectModalities(object)) {
if (modality !== "gui") {
nonGui.push(`${manifestPath}:${id}:${modality}`);
}
}
}
}
expect(nonGui).toEqual([]);
});
it("can route-switch every bundled plugin view in gui mode", async () => {
const { pluginNames, views } = await registerAllManifests();
try {
expect(views.length).toBeGreaterThan(0);
const failures: string[] = [];
for (const view of views) {
const broadcasts: object[] = [];
await handleViewsRoutes(
makeCtx(
"POST",
`/api/views/${encodeURIComponent(view.id)}/navigate?viewType=gui`,
(payload) => broadcasts.push(payload),
),
);
const event = broadcasts[0] as
| {
type?: string;
viewId?: string;
viewType?: string;
viewPath?: string | null;
}
| undefined;
if (
event?.type !== "shell:navigate:view" ||
event.viewId !== view.id ||
event.viewType !== "gui" ||
event.viewPath !== view.path
) {
failures.push(`${view.manifestPath}:gui:${view.id}`);
}
}
expect(failures).toEqual([]);
} finally {
for (const pluginName of pluginNames) unregisterPluginViews(pluginName);
clearCurrentViewState();
}
});
it("can dispatch standard interactions for every bundled plugin view in gui mode", async () => {
const { pluginNames, views } = await registerAllManifests();
try {
expect(views.length).toBeGreaterThan(0);
const failures: string[] = [];
for (const view of views) {
const broadcasts: object[] = [];
let resultBody: unknown = null;
let errorBody: { message: string; status?: number } | null = null;
await handleViewsRoutes(
makeCtx(
"POST",
`/api/views/${encodeURIComponent(view.id)}/interact?viewType=gui`,
(payload) => {
broadcasts.push(payload);
const event = payload as {
type?: string;
requestId?: string;
viewId?: string;
viewType?: string;
};
if (
event.type === "view:interact" &&
typeof event.requestId === "string"
) {
resolveViewInteractResult({
requestId: event.requestId,
success: true,
result: {
viewId: event.viewId,
viewType: event.viewType,
state: "ok",
},
});
}
},
{ capability: "get-state", timeoutMs: 1_000 },
(_res, body) => {
resultBody = body;
},
(_res, message, status) => {
errorBody = { message, status };
},
),
);
const event = broadcasts[0] as
| {
type?: string;
viewId?: string;
viewType?: string;
capability?: string;
}
| undefined;
const result = resultBody as {
success?: boolean;
result?: { viewId?: string; viewType?: string; state?: string };
} | null;
if (
errorBody ||
event?.type !== "view:interact" ||
event.viewId !== view.id ||
event.viewType !== "gui" ||
event.capability !== "get-state" ||
result?.success !== true ||
result.result?.viewId !== view.id ||
result.result?.viewType !== "gui"
) {
failures.push(`${view.manifestPath}:gui:${view.id}`);
}
}
expect(failures).toEqual([]);
} finally {
for (const pluginName of pluginNames) unregisterPluginViews(pluginName);
clearCurrentViewState();
}
});
// The viewType routing contract survives the tui/xr inventory removal:
// `getView(id, { viewType })` falls back to the gui ("default") declaration
// when the requested modality has no entry, so tui/xr requests against the
// gui-only inventory resolve to the gui view — the broadcast and JSON
// response carry viewType "gui". This is the designed degrade, not an error.
it("resolves tui/xr navigate requests against the gui-only inventory to the gui view", async () => {
const { pluginNames, views } = await registerAllManifests();
try {
const failures: string[] = [];
for (const viewType of ["tui", "xr"] as const) {
for (const view of views) {
const broadcasts: object[] = [];
let resultBody: unknown = null;
let errorBody: { message: string; status?: number } | null = null;
await handleViewsRoutes(
makeCtx(
"POST",
`/api/views/${encodeURIComponent(view.id)}/navigate?viewType=${viewType}`,
(payload) => broadcasts.push(payload),
undefined,
(_res, body) => {
resultBody = body;
},
(_res, message, status) => {
errorBody = { message, status };
},
),
);
const event = broadcasts[0] as
| { type?: string; viewId?: string; viewType?: string }
| undefined;
const result = resultBody as {
ok?: boolean;
viewId?: string;
viewType?: string;
} | null;
if (
errorBody ||
event?.type !== "shell:navigate:view" ||
event.viewId !== view.id ||
event.viewType !== "gui" ||
result?.ok !== true ||
result.viewType !== "gui"
) {
failures.push(`${view.manifestPath}:${viewType}:${view.id}`);
}
}
}
expect(failures).toEqual([]);
} finally {
for (const pluginName of pluginNames) unregisterPluginViews(pluginName);
clearCurrentViewState();
}
});
it("dispatches tui/xr interact requests against the gui-only inventory as the gui view", async () => {
const { pluginNames, views } = await registerAllManifests();
try {
const failures: string[] = [];
for (const viewType of ["tui", "xr"] as const) {
for (const view of views) {
const broadcasts: object[] = [];
let resultBody: unknown = null;
let errorBody: { message: string; status?: number } | null = null;
await handleViewsRoutes(
makeCtx(
"POST",
`/api/views/${encodeURIComponent(view.id)}/interact?viewType=${viewType}`,
(payload) => {
broadcasts.push(payload);
const event = payload as {
type?: string;
requestId?: string;
viewId?: string;
viewType?: string;
};
if (
event.type === "view:interact" &&
typeof event.requestId === "string"
) {
resolveViewInteractResult({
requestId: event.requestId,
success: true,
result: { viewId: event.viewId, viewType: event.viewType },
});
}
},
{ capability: "get-state", timeoutMs: 1_000 },
(_res, body) => {
resultBody = body;
},
(_res, message, status) => {
errorBody = { message, status };
},
),
);
const event = broadcasts[0] as
| { type?: string; viewId?: string; viewType?: string }
| undefined;
const result = resultBody as {
success?: boolean;
result?: { viewType?: string };
} | null;
// The dispatched frame carries the resolved entry's viewType ("gui"),
// never a fabricated tui/xr surface.
if (
errorBody ||
event?.type !== "view:interact" ||
event.viewId !== view.id ||
event.viewType !== "gui" ||
result?.success !== true ||
result.result?.viewType !== "gui"
) {
failures.push(`${view.manifestPath}:${viewType}:${view.id}`);
}
}
}
expect(failures).toEqual([]);
} finally {
for (const pluginName of pluginNames) unregisterPluginViews(pluginName);
clearCurrentViewState();
}
});
it("returns the designed 404 for tui/xr interact requests on unknown views", async () => {
const { pluginNames } = await registerAllManifests();
try {
for (const viewType of ["tui", "xr"] as const) {
let errorBody: { message: string; status?: number } | null = null;
let resultBody: unknown = null;
await handleViewsRoutes(
makeCtx(
"POST",
`/api/views/does-not-exist/interact?viewType=${viewType}`,
() => {},
{ capability: "get-state", timeoutMs: 1_000 },
(_res, body) => {
resultBody = body;
},
(_res, message, status) => {
errorBody = { message, status };
},
),
);
expect(resultBody).toBeNull();
expect(errorBody).toEqual({
message: 'View "does-not-exist" not found',
status: 404,
});
}
} finally {
for (const pluginName of pluginNames) unregisterPluginViews(pluginName);
clearCurrentViewState();
}
});
});
@@ -0,0 +1,120 @@
/**
* Keeps deterministic mock-eval plugin-view journeys in lockstep with the app
* GUI visual smoke matrix. The viewType union still includes future modalities,
* but this guard only tracks shipped GUI views.
*/
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import {
PLUGIN_VIEW_LLM_MOCK_CASES,
PLUGIN_VIEW_LLM_MOCK_JOURNEYS,
type PluginViewMockCase,
} from "./view-user-journeys.js";
const repoRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../../..",
);
function caseKey(view: Pick<PluginViewMockCase, "id" | "viewType" | "path">) {
return `${view.id}:${view.viewType}:${view.path}`;
}
function readVisualMatrixCases(): PluginViewMockCase[] {
const source = readFileSync(
resolve(repoRoot, "packages/app/test/ui-smoke/plugin-view-cases.ts"),
"utf8",
);
const match = source.match(
/const VIEW_CASES: ViewCase\[] = \(?\s*\[([\s\S]*?)\]\s*(?:satisfies[\s\S]*?)?\)?\s*\.map/,
);
expect(match?.[1], "VIEW_CASES declaration was not found").toBeTruthy();
const viewCasesSource = match?.[1] ?? "";
return Array.from(
viewCasesSource.matchAll(
/\["([^"]+)",\s*"gui",\s*"([^"]+)"(?:,\s*\{[^}]*\})?\]/g,
),
).flatMap((caseMatch) => {
const id = caseMatch[1];
const path = caseMatch[2];
if (!id || !path) return [];
return [{ id, viewType: "gui", path }];
});
}
function mockLlmViewPlanner(message: string): {
action: "show";
view: string;
viewType: "gui";
path: string;
} | null {
const lower = message.toLowerCase();
const exactPath = [...PLUGIN_VIEW_LLM_MOCK_CASES]
.sort((left, right) => right.path.length - left.path.length)
.find((view) => lower.includes(view.path.toLowerCase()));
if (!exactPath || exactPath.viewType !== "gui") return null;
return {
action: "show",
view: exactPath.id,
viewType: exactPath.viewType,
path: exactPath.path,
};
}
describe("plugin view LLM mock coverage", () => {
it("keeps mock LLM journeys in lockstep with the GUI visual smoke matrix", () => {
const visualCases = readVisualMatrixCases();
expect(new Set(visualCases.map(caseKey))).toEqual(
new Set(PLUGIN_VIEW_LLM_MOCK_CASES.map(caseKey)),
);
});
it("has one deterministic mock-eval journey for every shipped GUI view case", () => {
const visualCases = readVisualMatrixCases();
const journeyByKey = new Map(
PLUGIN_VIEW_LLM_MOCK_JOURNEYS.map((journey) => [
journey.id.replace(/^plugin-view-/, ""),
journey,
]),
);
expect(PLUGIN_VIEW_LLM_MOCK_JOURNEYS).toHaveLength(
PLUGIN_VIEW_LLM_MOCK_CASES.length,
);
for (const view of visualCases) {
const journey = journeyByKey.get(`${view.id}-${view.viewType}`);
expect(journey, `missing mock journey for ${caseKey(view)}`).toBeTruthy();
expect(journey?.userMessage).toContain(view.path);
expect(journey?.expectedBehavior).toContain(`"${view.id}"`);
expect(journey?.expectedBehavior).toContain(`"${view.viewType}"`);
expect(journey?.verificationCriteria.join("\n")).toContain(view.path);
}
});
it("routes every mock journey through the deterministic planner contract", () => {
for (const journey of PLUGIN_VIEW_LLM_MOCK_JOURNEYS) {
const expected = PLUGIN_VIEW_LLM_MOCK_CASES.find(
(view) =>
journey.expectedBehavior.includes(`"${view.id}"`) &&
journey.expectedBehavior.includes(`"${view.viewType}"`) &&
journey.expectedBehavior.includes(`"${view.path}"`),
);
const planned = mockLlmViewPlanner(journey.userMessage);
expect(expected, `missing case backing ${journey.id}`).toBeTruthy();
expect(planned, `mock planner failed ${journey.id}`).toEqual({
action: "show",
view: expected?.id,
viewType: expected?.viewType,
path: expected?.path,
});
}
});
});
@@ -0,0 +1,194 @@
/**
* Coverage ratchet: every converted plugin view must register at least one
* element with the agent surface (useAgentElement) so the floating pill can
* address it. Guards against a view regressing to an unaddressable surface.
*/
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const here = path.dirname(fileURLToPath(import.meta.url));
const PLUGINS_DIR = path.resolve(here, "../../../../plugins");
/** Plugins whose view has been wired to the agent surface. */
const CONVERTED_PLUGINS = [
"plugin-wallet-ui",
"app-model-tester",
"plugin-app-control",
"plugin-contacts",
"plugin-facewear",
"plugin-feed",
"plugin-hyperliquid",
"plugin-messages",
"plugin-phone",
"plugin-polymarket",
"plugin-screenshare",
"plugin-task-coordinator",
"plugin-training",
"plugin-trajectory-logger",
] as const;
/** Remaining views to convert; must stay empty for the ratchet to pass. */
const PENDING_PLUGINS: readonly string[] = [];
function walkTsx(dir: string): string[] {
const out: string[] = [];
let entries: string[];
try {
entries = readdirSync(dir);
} catch {
return out;
}
for (const entry of entries) {
const full = path.join(dir, entry);
const stat = statSync(full);
if (stat.isDirectory()) {
if (entry === "node_modules" || entry === "dist") continue;
out.push(...walkTsx(full));
} else if (entry.endsWith(".tsx") && !entry.endsWith(".test.tsx")) {
out.push(full);
}
}
return out;
}
function registersAgentElement(pluginDir: string): boolean {
const srcDir = path.join(PLUGINS_DIR, pluginDir, "src");
return walkTsx(srcDir).some((file) =>
readFileSync(file, "utf8").includes("useAgentElement"),
);
}
const UI_COMPONENTS_DIR = path.resolve(here, "../../../ui/src/components");
/**
* Builtin shell views, made controllable via ShellViewAgentSurface (rather than
* the bundle loader). Paths are relative to packages/ui/src/components. Each
* must wrap its body in the bridge. (chat is intentionally absent — it is the
* in-view-chat removal target, not a conversion.)
*/
const CONVERTED_BUILTIN_PAGES = [
"pages/SettingsView",
"pages/PluginsPageView",
"pages/TrajectoriesView",
"pages/MemoryViewerView",
"pages/DatabasePageView",
"pages/LogsView",
"pages/AutomationsFeed",
"character/CharacterEditor",
] as const;
function wrapsInShellBridge(pageFile: string): boolean {
const file = path.join(UI_COMPONENTS_DIR, `${pageFile}.tsx`);
try {
return readFileSync(file, "utf8").includes("ShellViewAgentSurface");
} catch {
return false;
}
}
/**
* Additional standalone shell views. Each is either wrapped in the bridge or —
* when it is a child rendered inside an already-wrapped parent — registers its
* controls into the ancestor registry via useAgentElement (controls-only mode).
*/
const CONVERTED_SHELL_PAGES = [
"pages/AppsPageView",
"pages/ElizaOsAppsView",
"pages/RelationshipsView",
"pages/RuntimeView",
"pages/SkillsView",
"pages/StreamView",
"pages/TasksPageView",
"pages/BrowserWorkspaceView",
"pages/SecretsView",
"pages/ReleaseCenterView",
"pages/TriggersView",
"pages/DocumentsView",
"pages/ConfigPageView",
] as const;
/**
* Sub-components rendered inside an already-wrapped parent surface. Each
* registers its interactive controls via useAgentElement (controls-only mode)
* so the agent can address every element of a view, not just the page shell.
* Their ancestor (SettingsView, CharacterEditor, PluginsPageView,
* DatabasePageView, AutomationsFeed, AppsPageView, RelationshipsView, …)
* provides the registry; these must keep at least one registered control.
*/
const CONVERTED_SUBCOMPONENTS = [
"character/CharacterEditorPanels",
"character/CharacterExperienceWorkspace",
"character/CharacterLearnedSkillsSection",
"pages/documents-upload",
"pages/TriggerForm",
"pages/PluginCard",
"pages/plugin-view-connectors",
"pages/plugin-view-dialogs",
"pages/plugin-view-modal",
"pages/plugin-view-sidebar",
"pages/RelationshipsGraphPanel",
"pages/relationships/RelationshipsPersonPanels",
"pages/skill-detail-panel",
"pages/skill-marketplace",
"pages/WorkflowGraphViewer",
"settings/AdvancedSection",
"settings/AppearanceSettingsSection",
"settings/AppsManagementSection",
"settings/DesktopWorkspaceSection",
"settings/LoadContentPackForm",
"settings/RemotePluginHostSection",
"settings/SecuritySettingsSection",
"settings/VaultInventoryPanel",
"settings/vault-tabs/LoginsTab",
"settings/vault-tabs/OverviewTab",
"settings/vault-tabs/RoutingTab",
"settings/VoiceConfigView",
"settings/VoiceProfileSection",
"settings/VoiceSection",
"settings/WalletKeysSection",
] as const;
function isAgentControllable(pageFile: string): boolean {
const file = path.join(UI_COMPONENTS_DIR, `${pageFile}.tsx`);
try {
const src = readFileSync(file, "utf8");
return (
src.includes("ShellViewAgentSurface") || src.includes("useAgentElement")
);
} catch {
return false;
}
}
describe("agent-surface view coverage", () => {
it.each(
CONVERTED_PLUGINS,
)("%s registers at least one agent-surface element", (plugin) => {
expect(registersAgentElement(plugin)).toBe(true);
});
it.each(
CONVERTED_BUILTIN_PAGES,
)("builtin %s is wrapped in the agent-surface bridge", (page) => {
expect(wrapsInShellBridge(page)).toBe(true);
});
it.each(
CONVERTED_SHELL_PAGES,
)("shell view %s is agent-controllable (bridge or registered controls)", (page) => {
expect(isAgentControllable(page)).toBe(true);
});
it.each(
CONVERTED_SUBCOMPONENTS,
)("sub-component %s registers controls into its parent surface", (file) => {
expect(isAgentControllable(file)).toBe(true);
});
it("has zero unconverted plugin views", () => {
expect(PENDING_PLUGINS).toHaveLength(0);
});
});
@@ -0,0 +1,687 @@
/**
* View bundle lifecycle tests.
*
* Verifies that plugins declaring `views` properly contribute to and clean up
* from a mock view registry on load/unload cycles. Tests use a lightweight
* in-process registry to avoid depending on the full views-registry service.
*/
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Plugin, ViewDeclaration } from "@elizaos/core";
import { describe, expect, it } from "vitest";
import {
getView,
listViews,
unregisterPluginViews,
} from "../api/views-registry.js";
import { installRuntimePluginLifecycle } from "../runtime/plugin-lifecycle.js";
import { createTestRuntime } from "./plugin-lifecycle-test-utils.ts";
const repoRoot = resolve(
dirname(fileURLToPath(import.meta.url)),
"../../../..",
);
const PLUGIN_VIEW_MANIFESTS = [
"plugins/plugin-contacts/src/plugin.ts",
"plugins/plugin-hyperliquid/src/plugin.ts",
"plugins/plugin-messages/src/plugin.ts",
"plugins/app-model-tester/src/plugin.ts",
"plugins/plugin-phone/src/plugin.ts",
"plugins/plugin-polymarket/src/plugin.ts",
"plugins/plugin-wallet-ui/src/plugin.ts",
"plugins/plugin-feed/src/index.ts",
"plugins/plugin-app-control/src/index.ts",
"plugins/plugin-screenshare/src/index.ts",
"plugins/plugin-task-coordinator/src/index.ts",
"plugins/plugin-trajectory-logger/src/index.ts",
"plugins/plugin-training/src/setup-routes.ts",
"plugins/plugin-facewear/src/index.ts",
] as const;
function readManifest(path: string): string {
return readFileSync(resolve(repoRoot, path), "utf8");
}
function viewObjects(source: string): string[] {
const viewsStart = source.indexOf("views:");
if (viewsStart === -1) return [];
const arrayStart = source.indexOf("[", viewsStart);
if (arrayStart === -1) return [];
let depth = 0;
let arrayEnd = -1;
for (let index = arrayStart; index < source.length; index += 1) {
const char = source[index];
if (char === "[") depth += 1;
if (char === "]") depth -= 1;
if (depth === 0) {
arrayEnd = index;
break;
}
}
if (arrayEnd === -1) return [];
const viewsSource = source.slice(arrayStart + 1, arrayEnd);
const objects: string[] = [];
let objectStart = -1;
depth = 0;
for (let index = 0; index < viewsSource.length; index += 1) {
const char = viewsSource[index];
if (char === "{") {
if (depth === 0) objectStart = index;
depth += 1;
}
if (char === "}") {
depth -= 1;
if (depth === 0 && objectStart !== -1) {
objects.push(viewsSource.slice(objectStart, index + 1));
objectStart = -1;
}
}
}
return objects.filter(
(chunk) => chunk.includes("id:") && chunk.includes("componentExport:"),
);
}
function stringField(source: string, field: string): string | null {
const match = source.match(new RegExp(`${field}:\\s*"([^"]+)"`));
return match?.[1] ?? null;
}
function productionViewDeclarations(manifestPath: string): ViewDeclaration[] {
return viewObjects(readManifest(manifestPath))
.map((object): ViewDeclaration | null => {
const id = stringField(object, "id");
const label = stringField(object, "label");
const path = stringField(object, "path");
const viewType = stringField(object, "viewType");
const bundlePath = stringField(object, "bundlePath");
const componentExport = stringField(object, "componentExport");
if (!id || !label || !bundlePath || !componentExport) return null;
return {
id,
label,
...(path === null ? {} : { path }),
...(viewType === "gui" || viewType === "tui" || viewType === "xr"
? { viewType }
: {}),
bundlePath,
componentExport,
} satisfies ViewDeclaration;
})
.filter((view): view is ViewDeclaration => view !== null);
}
/**
* Minimal view registry that mirrors the contract used by the real
* views-registry service: register on plugin load, remove on plugin unload.
*/
class MockViewRegistry {
private entries = new Map<string, ViewDeclaration & { pluginName: string }>();
register(pluginName: string, view: ViewDeclaration): void {
this.entries.set(view.id, { ...view, pluginName });
}
unregisterByPlugin(pluginName: string): void {
for (const [id, entry] of this.entries) {
if (entry.pluginName === pluginName) {
this.entries.delete(id);
}
}
}
has(viewId: string): boolean {
return this.entries.has(viewId);
}
getAll(): Array<ViewDeclaration & { pluginName: string }> {
return [...this.entries.values()];
}
size(): number {
return this.entries.size;
}
}
function makeViewPlugin(
pluginName: string,
views: ViewDeclaration[],
registry: MockViewRegistry,
): Plugin {
return {
name: pluginName,
description: `Plugin contributing views: ${views.map((v) => v.id).join(", ")}`,
init: async () => {
for (const view of views) {
registry.register(pluginName, view);
}
},
dispose: async () => {
registry.unregisterByPlugin(pluginName);
},
views,
};
}
describe("view registry — register on load, remove on unload", () => {
it("registering a plugin with views adds them to the view registry", async () => {
const registry = new MockViewRegistry();
const views: ViewDeclaration[] = [
{
id: "wallet.inventory",
label: "Wallet Inventory",
description: "User token inventory",
path: "/wallet",
},
];
const plugin = makeViewPlugin("wallet-plugin", views, registry);
const runtime = createTestRuntime();
await runtime.registerPlugin(plugin);
expect(registry.has("wallet.inventory")).toBe(true);
expect(registry.size()).toBe(1);
});
it("unregistering a plugin removes its views from the registry", async () => {
const registry = new MockViewRegistry();
const views: ViewDeclaration[] = [
{
id: "market.chart",
label: "Market Chart",
path: "/market",
},
];
const plugin = makeViewPlugin("market-plugin", views, registry);
const runtime = createTestRuntime();
await runtime.registerPlugin(plugin);
expect(registry.has("market.chart")).toBe(true);
await runtime.unloadPlugin("market-plugin");
expect(registry.has("market.chart")).toBe(false);
expect(registry.size()).toBe(0);
});
it("view registry does not retain stale entries after multiple load/unload cycles", async () => {
const registry = new MockViewRegistry();
const views: ViewDeclaration[] = [
{ id: "cycle.view", label: "Cycle View", path: "/cycle" },
];
const plugin = makeViewPlugin("cycle-plugin", views, registry);
const runtime = createTestRuntime();
const cycles = 5;
for (let i = 0; i < cycles; i++) {
await runtime.registerPlugin(plugin);
expect(registry.size()).toBe(1);
await runtime.unloadPlugin("cycle-plugin");
expect(registry.size()).toBe(0);
expect(registry.has("cycle.view")).toBe(false);
}
});
it("two plugins with different views coexist; unloading one does not affect the other", async () => {
const registry = new MockViewRegistry();
const pluginA = makeViewPlugin(
"plugin-a",
[{ id: "view.alpha", label: "Alpha", path: "/alpha" }],
registry,
);
const pluginB = makeViewPlugin(
"plugin-b",
[{ id: "view.beta", label: "Beta", path: "/beta" }],
registry,
);
const runtime = createTestRuntime();
await runtime.registerPlugin(pluginA);
await runtime.registerPlugin(pluginB);
expect(registry.has("view.alpha")).toBe(true);
expect(registry.has("view.beta")).toBe(true);
await runtime.unloadPlugin("plugin-a");
expect(registry.has("view.alpha")).toBe(false);
expect(registry.has("view.beta")).toBe(true);
});
it("reloading a plugin after unload re-registers views without duplicates", async () => {
const registry = new MockViewRegistry();
const views: ViewDeclaration[] = [
{ id: "reload.view", label: "Reload View", path: "/reload" },
];
const plugin = makeViewPlugin("reload-view-plugin", views, registry);
const runtime = createTestRuntime();
await runtime.registerPlugin(plugin);
await runtime.unloadPlugin("reload-view-plugin");
await runtime.registerPlugin(plugin);
// Should be registered exactly once, not twice
expect(registry.size()).toBe(1);
expect(registry.has("reload.view")).toBe(true);
});
});
describe("view bundle plugin — views field propagation through Plugin interface", () => {
it("a plugin with views declared is registered and unloaded cleanly by the runtime", async () => {
const runtime = createTestRuntime();
const viewPlugin: Plugin = {
name: "view-bundle-plugin",
description: "Plugin with view declarations",
views: [
{
id: "vb.dashboard",
label: "Dashboard",
bundlePath: "dist/views/dashboard.js",
path: "/dashboard",
},
],
actions: [
{
name: "OPEN_DASHBOARD",
description: "opens the dashboard view",
examples: [],
similes: [],
validate: async () => true,
handler: async () => ({ success: true, data: { opened: true } }),
},
],
};
const baselineActions = runtime.actions.length;
await runtime.registerPlugin(viewPlugin);
expect(runtime.actions.some((a) => a.name === "OPEN_DASHBOARD")).toBe(true);
await runtime.unloadPlugin("view-bundle-plugin");
expect(runtime.actions.some((a) => a.name === "OPEN_DASHBOARD")).toBe(
false,
);
expect(runtime.actions.length).toBe(baselineActions);
});
});
describe("agent runtime view sync — real view registry", () => {
it("loads and unloads every production view manifest without stale registry entries", async () => {
const failures: string[] = [];
for (const manifestPath of PLUGIN_VIEW_MANIFESTS) {
const views = productionViewDeclarations(manifestPath);
const pluginName = `production-lifecycle:${manifestPath}`;
const runtime = createTestRuntime();
installRuntimePluginLifecycle(runtime);
const plugin: Plugin = {
name: pluginName,
description: `Production lifecycle coverage for ${manifestPath}`,
views,
};
try {
await runtime.registerPlugin(plugin);
for (const view of views) {
const viewType = view.viewType ?? "gui";
const entry = getView(view.id, { viewType });
if (
entry?.pluginName !== pluginName ||
entry.viewType !== viewType ||
entry.componentExport !== view.componentExport
) {
failures.push(
`${manifestPath}:missing-after-load:${viewType}:${view.id}`,
);
}
}
await runtime.unloadPlugin(pluginName);
for (const view of views) {
const viewType = view.viewType ?? "gui";
if (getView(view.id, { viewType }) !== undefined) {
failures.push(
`${manifestPath}:stale-after-unload:${viewType}:${view.id}`,
);
}
}
const stale = listViews({ developerMode: true }).filter(
(view) => view.pluginName === pluginName,
);
if (stale.length > 0) {
failures.push(
`${manifestPath}:stale-plugin-entries:${stale
.map((view) => `${view.viewType}:${view.id}`)
.join(",")}`,
);
}
} finally {
unregisterPluginViews(pluginName);
}
}
expect(failures).toEqual([]);
});
it("does not retain real registry entries across repeated runtime load/unload cycles", async () => {
const pluginName = "runtime-view-cycle-plugin";
const viewIdPrefix = "runtime-cycle.";
const runtime = createTestRuntime();
installRuntimePluginLifecycle(runtime);
const plugin: Plugin = {
name: pluginName,
description: "Runtime repeated cycle view sync test plugin",
views: [
{
id: "runtime-cycle.primary",
label: "Runtime Cycle Primary",
path: "/runtime-cycle/primary",
bundlePath: "dist/views/primary.js",
},
{
id: "runtime-cycle.secondary",
label: "Runtime Cycle Secondary",
path: "/runtime-cycle/secondary",
bundlePath: "dist/views/secondary.js",
},
],
};
const cycleViews = () =>
listViews({ developerMode: true }).filter((view) =>
view.id.startsWith(viewIdPrefix),
);
try {
for (let cycle = 0; cycle < 8; cycle += 1) {
await runtime.registerPlugin(plugin);
expect(cycleViews()).toHaveLength(2);
expect(getView("runtime-cycle.primary")).toMatchObject({
id: "runtime-cycle.primary",
pluginName,
});
expect(getView("runtime-cycle.secondary")).toMatchObject({
id: "runtime-cycle.secondary",
pluginName,
});
await runtime.unloadPlugin(pluginName);
expect(getView("runtime-cycle.primary")).toBeUndefined();
expect(getView("runtime-cycle.secondary")).toBeUndefined();
expect(cycleViews()).toHaveLength(0);
}
} finally {
unregisterPluginViews(pluginName);
}
});
it("keeps real view registry bounded when lifecycle install is repeated across register/unload cycles", async () => {
const pluginName = "runtime-view-repeat-install-plugin";
const viewIdPrefix = "runtime-repeat.";
const runtime = createTestRuntime();
const plugin: Plugin = {
name: pluginName,
description: "Runtime repeated install view sync test plugin",
views: [
{
id: "runtime-repeat.alpha",
label: "Runtime Repeat Alpha",
path: "/runtime-repeat/alpha",
bundlePath: "dist/views/alpha.js",
},
{
id: "runtime-repeat.beta",
label: "Runtime Repeat Beta",
path: "/runtime-repeat/beta",
bundlePath: "dist/views/beta.js",
},
],
};
const viewIds = plugin.views?.map((view) => view.id) ?? [];
const repeatViews = () =>
listViews({ developerMode: true }).filter((view) =>
view.id.startsWith(viewIdPrefix),
);
try {
for (let cycle = 0; cycle < 5; cycle += 1) {
installRuntimePluginLifecycle(runtime);
await runtime.registerPlugin(plugin);
for (const id of viewIds) {
expect(getView(id)).toMatchObject({ id, pluginName });
}
expect(repeatViews()).toHaveLength(viewIds.length);
await runtime.unloadPlugin(pluginName);
for (const id of viewIds) {
expect(getView(id)).toBeUndefined();
}
expect(repeatViews()).toHaveLength(0);
}
} finally {
unregisterPluginViews(pluginName);
}
});
it("registers, unloads, and reloads plugin views through runtime lifecycle hooks", async () => {
const pluginName = "runtime-view-sync-plugin";
const runtime = createTestRuntime();
installRuntimePluginLifecycle(runtime);
const initialPlugin: Plugin = {
name: pluginName,
description: "Runtime view sync integration test plugin",
views: [
{
id: "runtime-sync.primary",
label: "Runtime Sync Primary",
path: "/runtime-sync/primary",
bundlePath: "dist/views/primary.js",
},
{
id: "runtime-sync.secondary",
label: "Runtime Sync Secondary",
path: "/runtime-sync/secondary",
bundlePath: "dist/views/secondary.js",
},
],
};
const reloadedPlugin: Plugin = {
...initialPlugin,
views: [
{
id: "runtime-sync.primary",
label: "Runtime Sync Primary Reloaded",
path: "/runtime-sync/primary-reloaded",
bundlePath: "dist/views/primary-reloaded.js",
},
{
id: "runtime-sync.tertiary",
label: "Runtime Sync Tertiary",
path: "/runtime-sync/tertiary",
bundlePath: "dist/views/tertiary.js",
},
],
};
try {
await runtime.registerPlugin(initialPlugin);
expect(getView("runtime-sync.primary")).toMatchObject({
id: "runtime-sync.primary",
pluginName,
path: "/runtime-sync/primary",
});
expect(getView("runtime-sync.secondary")).toMatchObject({
id: "runtime-sync.secondary",
pluginName,
});
await runtime.reloadPlugin(reloadedPlugin);
expect(getView("runtime-sync.secondary")).toBeUndefined();
expect(getView("runtime-sync.primary")).toMatchObject({
id: "runtime-sync.primary",
pluginName,
label: "Runtime Sync Primary Reloaded",
path: "/runtime-sync/primary-reloaded",
});
expect(getView("runtime-sync.tertiary")).toMatchObject({
id: "runtime-sync.tertiary",
pluginName,
});
await runtime.unloadPlugin(pluginName);
expect(getView("runtime-sync.primary")).toBeUndefined();
expect(getView("runtime-sync.tertiary")).toBeUndefined();
} finally {
unregisterPluginViews(pluginName);
}
});
it("cleans up and reloads every viewType variant for a shared logical view id", async () => {
const pluginName = "runtime-view-variant-plugin";
const viewId = "runtime-variant.dashboard";
const runtime = createTestRuntime();
installRuntimePluginLifecycle(runtime);
const initialPlugin: Plugin = {
name: pluginName,
description: "Runtime view variant lifecycle test plugin",
views: [
{
id: viewId,
label: "Runtime Variant GUI",
viewType: "gui",
path: "/runtime-variant",
bundlePath: "dist/views/gui.js",
},
{
id: viewId,
label: "Runtime Variant TUI",
viewType: "tui",
path: "/runtime-variant/tui",
bundlePath: "dist/views/tui.js",
},
{
id: viewId,
label: "Runtime Variant XR",
viewType: "xr",
path: "/runtime-variant",
bundlePath: "dist/views/xr.js",
},
],
};
const reloadedPlugin: Plugin = {
...initialPlugin,
views: [
{
id: viewId,
label: "Runtime Variant GUI Reloaded",
viewType: "gui",
path: "/runtime-variant/reloaded",
bundlePath: "dist/views/gui-reloaded.js",
},
{
id: viewId,
label: "Runtime Variant TUI Reloaded",
viewType: "tui",
path: "/runtime-variant/reloaded/tui",
bundlePath: "dist/views/tui-reloaded.js",
},
{
id: viewId,
label: "Runtime Variant XR Reloaded",
viewType: "xr",
path: "/runtime-variant/reloaded",
bundlePath: "dist/views/xr-reloaded.js",
},
],
};
const expectVariant = (
viewType: "gui" | "tui" | "xr",
label: string,
viewPath: string,
) => {
expect(getView(viewId, { viewType })).toMatchObject({
id: viewId,
pluginName,
viewType,
label,
path: viewPath,
});
expect(
listViews({ developerMode: true, viewType }).filter(
(view) => view.id === viewId,
),
).toHaveLength(1);
};
const expectNoVariants = () => {
for (const viewType of ["gui", "tui", "xr"] as const) {
expect(getView(viewId, { viewType })).toBeUndefined();
expect(
listViews({ developerMode: true, viewType }).filter(
(view) => view.id === viewId,
),
).toHaveLength(0);
}
};
try {
await runtime.registerPlugin(initialPlugin);
expectVariant("gui", "Runtime Variant GUI", "/runtime-variant");
expectVariant("tui", "Runtime Variant TUI", "/runtime-variant/tui");
expectVariant("xr", "Runtime Variant XR", "/runtime-variant");
await runtime.reloadPlugin(reloadedPlugin);
expectVariant(
"gui",
"Runtime Variant GUI Reloaded",
"/runtime-variant/reloaded",
);
expectVariant(
"tui",
"Runtime Variant TUI Reloaded",
"/runtime-variant/reloaded/tui",
);
expectVariant(
"xr",
"Runtime Variant XR Reloaded",
"/runtime-variant/reloaded",
);
await runtime.unloadPlugin(pluginName);
expectNoVariants();
} finally {
unregisterPluginViews(pluginName);
}
});
});
@@ -0,0 +1,634 @@
/**
* LLM-evaluated view journey tests.
*
* Uses an LLM judge to score agent responses against the view user journey
* scenarios defined in `view-user-journeys.ts`. Tests are skipped when no
* supported model API key is available in the environment.
*
* Supported judge providers (checked in order):
* 1. Cerebras — CEREBRAS_API_KEY (default model: cerebras/gemma-4-31b)
* 2. Anthropic — ANTHROPIC_API_KEY (default model: claude-haiku-4-5-20251001)
*
* Override the model via VIEW_EVAL_MODEL env var.
*
* Running the full suite:
* CEREBRAS_API_KEY=... bun test packages/agent/src/__tests__/view-llm-eval.test.ts
*
* This test file is excluded from the standard CI run by the vitest config's
* `exclude` patterns (*.live.test.ts, *.real.test.ts). Rename with those
* suffixes to exclude from CI, or run directly when credentials are available.
*/
import { DEFAULT_CEREBRAS_TEXT_MODEL } from "@elizaos/core";
import { describe, expect, it } from "vitest";
import {
countViewJourneyScenarios,
getScenarioById,
getScenariosByTag,
VIEW_USER_JOURNEYS,
type ViewJourneyScenario,
} from "./view-user-journeys.js";
// ---------------------------------------------------------------------------
// Credential detection
// ---------------------------------------------------------------------------
const hasCerebras = Boolean(process.env.CEREBRAS_API_KEY);
const hasAnthropic = Boolean(process.env.ANTHROPIC_API_KEY);
const hasAnyCredential = hasCerebras || hasAnthropic;
// ---------------------------------------------------------------------------
// Judge configuration
// ---------------------------------------------------------------------------
type JudgeProvider = "cerebras" | "anthropic";
function detectProvider(): JudgeProvider {
if (hasCerebras) return "cerebras";
return "anthropic";
}
function defaultModel(provider: JudgeProvider): string {
if (provider === "cerebras") return DEFAULT_CEREBRAS_TEXT_MODEL;
return "claude-haiku-4-5-20251001";
}
const JUDGE_PROVIDER = detectProvider();
const JUDGE_MODEL = process.env.VIEW_EVAL_MODEL ?? defaultModel(JUDGE_PROVIDER);
const CEREBRAS_MIN_REQUEST_INTERVAL_MS = Number.parseInt(
process.env.CEREBRAS_MIN_REQUEST_INTERVAL_MS ?? "2500",
10,
);
const CEREBRAS_MAX_RETRIES = Number.parseInt(
process.env.CEREBRAS_MAX_RETRIES ?? "5",
10,
);
const LIVE_SINGLE_SCENARIO_TIMEOUT_MS = 180_000;
const LIVE_BATCH_SCENARIO_TIMEOUT_MS = 600_000;
const VIEW_EVAL_MAX_SCENARIOS = Number.parseInt(
process.env.VIEW_EVAL_MAX_SCENARIOS ?? "10",
10,
);
// ---------------------------------------------------------------------------
// Evaluation types
// ---------------------------------------------------------------------------
interface EvalResult {
scenarioId: string;
score: number; // 010
navigationCorrect: boolean | null; // null = not applicable
pass: boolean;
reasoning: string;
}
// ---------------------------------------------------------------------------
// Deterministic agent — simulates the views system responding to user messages.
//
// In a full integration test this would call a live agent runtime; here we
// use a deterministic responder that produces plausible responses so the LLM
// judge has something to evaluate in credentialed smoke runs.
// ---------------------------------------------------------------------------
function deterministicAgentResponse(userMessage: string): string {
const lower = userMessage.toLowerCase();
if (
lower.includes("show me all") ||
lower.includes("list") ||
lower.includes("what views")
) {
return (
"Here are the available views:\n" +
"- **Wallet** — Manage your crypto assets and wallet\n" +
"- **Trading** — Buy and sell tokens on DEX markets\n" +
"- **Chat** — Conversation interface with the agent\n" +
"- **Settings** — Configure the assistant and connected apps"
);
}
if (
lower.includes("configure") ||
lower.includes("configuration") ||
lower.includes("account")
) {
return "Open Settings from the sidebar, then choose Account or Connected Accounts to configure your account and connection details.";
}
if (lower === "go home" || lower.includes("go home") || lower === "home") {
return "Navigating to the main Chat view (home).";
}
if (lower.includes("show apps") || lower.includes("open apps")) {
return "Opening the View Manager so you can browse all available views.";
}
if (lower.includes("open the wallet") || lower.includes("wallet")) {
return "I've opened the Wallet view for you. You can now manage your crypto assets.";
}
if (
lower.includes("go to settings") ||
lower === "settings" ||
lower.includes("open settings")
) {
return "Navigating to Settings now.";
}
if (lower.includes("open chat") || lower.includes("open the chat")) {
return "Switching to the Chat view.";
}
if (lower.includes("trading")) {
return "Opening the Trading Dashboard.";
}
if (lower.includes("dashboard")) {
return "There are multiple dashboard-style views. Did you mean Trading, Wallet, or the View Manager?";
}
if (lower.includes("view manager") || lower.includes("grid")) {
return "Opening the View Manager so you can see all available panels.";
}
if (lower.includes("crypto") || lower.includes("finance")) {
return "For crypto management, you have the Wallet and Trading views available.";
}
if (lower.includes("close")) {
return "The current view has been closed.";
}
if (lower.includes("go back")) {
return "Going back to the previous view.";
}
if (lower.includes("dev log") || lower.includes("developer")) {
return "The Dev Logs view is only available in Developer Mode. Enable it in Settings → Developer Options.";
}
if (lower.includes("balance")) {
return "Opening the Wallet view and checking your balance. You currently hold 1.23 ETH and 500 USDC.";
}
if (lower.includes("inventory")) {
return "I don't have an 'inventory' view. The available views are Wallet, Trading, Chat, and Settings. Did you mean one of those?";
}
if (lower.includes("pin")) {
return "The Wallet view has been pinned as a tab on your desktop.";
}
if (lower.includes("install")) {
return "Installing the weather plugin… Done! A new Weather view is now available. You can access it from the View Manager.";
}
if (
lower.includes("click") ||
lower.includes("press") ||
lower.includes("button")
) {
return "I've clicked the Send button in the Wallet view for you.";
}
if (lower.includes("refresh")) {
return "The Wallet view has been refreshed with the latest data.";
}
if (
lower.includes("fill") ||
lower.includes("recipient") ||
lower.includes("address")
) {
return "I've filled in the recipient address field in the wallet Send form.";
}
if (lower.includes("send funds") || lower.includes("transfer")) {
return "To send funds, I'll need the recipient address and amount. Please confirm and I'll initiate the transfer in the Wallet view.";
}
if (
lower.includes("what can you do") ||
lower.includes("capabilities") ||
lower.includes("help me")
) {
return "I can navigate to views, search for capabilities, interact with views on your behalf, and install new plugins. Try saying 'show me all views' to explore what's available.";
}
return "I can help you navigate to a view. The available views are: Wallet, Trading, Chat, and Settings.";
}
// ---------------------------------------------------------------------------
// LLM judge
// ---------------------------------------------------------------------------
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
let cerebrasQueue: Promise<void> = Promise.resolve();
let lastCerebrasRequestAt = 0;
async function withCerebrasRateLimit<T>(task: () => Promise<T>): Promise<T> {
const previous = cerebrasQueue;
let release: () => void = () => {};
cerebrasQueue = new Promise((resolve) => {
release = resolve;
});
await previous;
try {
const now = Date.now();
const elapsed = now - lastCerebrasRequestAt;
if (elapsed < CEREBRAS_MIN_REQUEST_INTERVAL_MS) {
await sleep(CEREBRAS_MIN_REQUEST_INTERVAL_MS - elapsed);
}
lastCerebrasRequestAt = Date.now();
return await task();
} finally {
release();
}
}
function retryAfterMs(response: Response, attempt: number): number {
const retryAfter = response.headers.get("retry-after");
if (retryAfter) {
const seconds = Number.parseFloat(retryAfter);
if (Number.isFinite(seconds)) {
return Math.max(1000, seconds * 1000);
}
}
return Math.min(45_000, 5000 * 2 ** attempt);
}
async function callCerebrasJudge(prompt: string): Promise<string> {
const baseUrl = process.env.CEREBRAS_BASE_URL ?? "https://api.cerebras.ai/v1";
for (let attempt = 0; attempt <= CEREBRAS_MAX_RETRIES; attempt++) {
const response = await withCerebrasRateLimit(() =>
fetch(`${baseUrl}/chat/completions`, {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.CEREBRAS_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: JUDGE_MODEL,
messages: [{ role: "user", content: prompt }],
max_tokens: 512,
temperature: 0,
}),
}),
);
if (response.ok) {
const data = (await response.json()) as {
choices: { message: { content: string } }[];
};
return data.choices[0]?.message?.content ?? "";
}
const text = await response.text();
if (response.status === 429 && attempt < CEREBRAS_MAX_RETRIES) {
await sleep(retryAfterMs(response, attempt));
continue;
}
throw new Error(`Cerebras API error ${response.status}: ${text}`);
}
throw new Error("Cerebras judge exhausted retries");
}
async function callAnthropicJudge(prompt: string): Promise<string> {
const apiKey = process.env.ANTHROPIC_API_KEY;
if (!apiKey) throw new Error("ANTHROPIC_API_KEY is required");
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"x-api-key": apiKey,
"anthropic-version": "2023-06-01",
"Content-Type": "application/json",
},
body: JSON.stringify({
model: JUDGE_MODEL,
max_tokens: 512,
messages: [{ role: "user", content: prompt }],
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(`Anthropic API error ${response.status}: ${text}`);
}
const data = (await response.json()) as {
content: { type: string; text: string }[];
};
return data.content.find((c) => c.type === "text")?.text ?? "";
}
async function callJudge(prompt: string): Promise<string> {
if (JUDGE_PROVIDER === "cerebras") return callCerebrasJudge(prompt);
return callAnthropicJudge(prompt);
}
function buildJudgePrompt(
scenario: ViewJourneyScenario,
agentResponse: string,
): string {
return `You are evaluating an AI assistant's response to a user request about a views navigation system.
USER MESSAGE: "${scenario.userMessage}"
EXPECTED BEHAVIOR: ${scenario.expectedBehavior}
VERIFICATION CRITERIA:
${scenario.verificationCriteria.map((c, i) => `${i + 1}. ${c}`).join("\n")}
AGENT RESPONSE:
"${agentResponse}"
Evaluate the agent response. Reply with valid JSON only — no prose, no markdown fences:
{
"score": <integer 0-10>,
"navigationCorrect": <true|false|null>,
"pass": <true if score >= 7>,
"reasoning": "<one sentence>"
}
score 0 = completely wrong or unhelpful
score 7 = adequately correct
score 10 = perfect response
navigationCorrect = true if the agent navigated or would navigate to the right view, false if wrong, null if navigation is not applicable for this scenario.`;
}
async function evaluateScenario(
scenario: ViewJourneyScenario,
): Promise<EvalResult> {
const agentResponse = deterministicAgentResponse(scenario.userMessage);
const judgePrompt = buildJudgePrompt(scenario, agentResponse);
const rawJudgeResponse = await callJudge(judgePrompt);
let parsed: Partial<EvalResult>;
try {
parsed = JSON.parse(rawJudgeResponse) as Partial<EvalResult>;
} catch {
// If the judge returned unparseable output, treat as low score.
parsed = {
score: 0,
navigationCorrect: null,
pass: false,
reasoning: `Judge returned unparseable response: ${rawJudgeResponse.slice(0, 200)}`,
};
}
return {
scenarioId: scenario.id,
score: parsed.score ?? 0,
navigationCorrect: parsed.navigationCorrect ?? null,
pass: parsed.pass ?? (parsed.score ?? 0) >= 7,
reasoning: parsed.reasoning ?? "",
};
}
function capLiveScenarios(
scenarios: ViewJourneyScenario[],
): ViewJourneyScenario[] {
if (
!Number.isFinite(VIEW_EVAL_MAX_SCENARIOS) ||
VIEW_EVAL_MAX_SCENARIOS <= 0
) {
return scenarios;
}
return scenarios.slice(0, VIEW_EVAL_MAX_SCENARIOS);
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe.skipIf(!hasAnyCredential)(
`View LLM evaluation (judge: ${JUDGE_PROVIDER}/${JUDGE_MODEL})`,
() => {
// ── Smoke test: single scenario ──────────────────────────────────────
it(
'evaluates "show me all views" with score >= 7',
async () => {
const scenario = getScenarioById("show-all-views");
const result = await evaluateScenario(scenario);
expect(result.score).toBeGreaterThanOrEqual(7);
expect(result.pass).toBe(true);
},
LIVE_SINGLE_SCENARIO_TIMEOUT_MS,
);
it(
'evaluates "open wallet" with correct navigation',
async () => {
const scenario = getScenarioById("open-wallet");
const result = await evaluateScenario(scenario);
expect(result.score).toBeGreaterThanOrEqual(7);
// navigationCorrect may be null if the judge finds it not applicable,
// but it should not be false for a navigation scenario.
expect(result.navigationCorrect).not.toBe(false);
},
LIVE_SINGLE_SCENARIO_TIMEOUT_MS,
);
// ── Discovery scenarios ──────────────────────────────────────────────
it(
"discovery scenarios score >= 7 on average",
async () => {
const scenarios = capLiveScenarios(getScenariosByTag("discovery"));
const results = await Promise.all(scenarios.map(evaluateScenario));
const total = results.reduce((sum, r) => sum + r.score, 0);
const average = total / results.length;
// Surface failures for debugging
const failures = results.filter((r) => !r.pass);
if (failures.length > 0) {
console.error(
"Failing discovery scenarios:",
failures.map((f) => `${f.scenarioId}: ${f.reasoning}`),
);
}
expect(average).toBeGreaterThanOrEqual(7);
},
LIVE_BATCH_SCENARIO_TIMEOUT_MS,
);
// ── Navigation scenarios ─────────────────────────────────────────────
it(
"navigation scenarios have correct navigation direction",
async () => {
const scenarios = capLiveScenarios(getScenariosByTag("navigation"));
const results = await Promise.all(scenarios.map(evaluateScenario));
const withNavAssertion = results.filter(
(r) => r.navigationCorrect !== null,
);
const correct = withNavAssertion.filter(
(r) => r.navigationCorrect === true,
);
// At least 80% of applicable navigation scenarios should be correct.
if (withNavAssertion.length > 0) {
expect(
correct.length / withNavAssertion.length,
).toBeGreaterThanOrEqual(0.8);
}
},
LIVE_BATCH_SCENARIO_TIMEOUT_MS,
);
// ── Error handling scenarios ─────────────────────────────────────────
it(
"error-handling scenarios respond helpfully without crashing",
async () => {
const scenarios = capLiveScenarios(getScenariosByTag("error-handling"));
const results = await Promise.all(scenarios.map(evaluateScenario));
const failures = results.filter((r) => r.score < 5);
expect(failures).toHaveLength(0);
},
LIVE_BATCH_SCENARIO_TIMEOUT_MS,
);
// ── Full suite summary (informational) ───────────────────────────────
it(
"full journey suite: at least 80% of scenarios score >= 7",
async () => {
// Run a representative prefix by default to avoid excessive API cost.
const scenarios = capLiveScenarios(VIEW_USER_JOURNEYS);
const results = await Promise.all(scenarios.map(evaluateScenario));
const passing = results.filter((r) => r.pass);
const passRate = passing.length / results.length;
const failures = results.filter((r) => !r.pass);
if (failures.length > 0) {
console.error(
"Failing scenarios:",
failures.map(
(f) => `${f.scenarioId} (score=${f.score}): ${f.reasoning}`,
),
);
}
expect(passRate).toBeGreaterThanOrEqual(0.8);
},
LIVE_BATCH_SCENARIO_TIMEOUT_MS,
);
},
);
// ---------------------------------------------------------------------------
// Deterministic scenario library tests (no credentials required)
// ---------------------------------------------------------------------------
describe("view-user-journeys scenario library", () => {
it("contains at least 20 scenarios", () => {
expect(VIEW_USER_JOURNEYS.length).toBeGreaterThanOrEqual(20);
});
it("expands the curated base set by exactly 10x", () => {
expect(countViewJourneyScenarios()).toEqual({
existing: 34,
added: 340,
total: 374,
multiplierAdded: 10,
});
});
it("all scenario ids are unique", () => {
const ids = VIEW_USER_JOURNEYS.map((s) => s.id);
const unique = new Set(ids);
expect(unique.size).toBe(ids.length);
});
it("all scenarios have required fields", () => {
for (const s of VIEW_USER_JOURNEYS) {
expect(s.id, `scenario ${s.id} missing id`).toBeTruthy();
expect(
s.description,
`scenario ${s.id} missing description`,
).toBeTruthy();
expect(
s.userMessage,
`scenario ${s.id} missing userMessage`,
).toBeTruthy();
expect(
s.expectedBehavior,
`scenario ${s.id} missing expectedBehavior`,
).toBeTruthy();
expect(
s.verificationCriteria.length,
`scenario ${s.id} has no verification criteria`,
).toBeGreaterThan(0);
expect(s.tags.length, `scenario ${s.id} has no tags`).toBeGreaterThan(0);
}
});
it("getScenarioById returns the correct scenario", () => {
const s = getScenarioById("show-all-views");
expect(s.id).toBe("show-all-views");
expect(s.userMessage).toBe("show me all views");
});
it("getScenarioById throws for unknown id", () => {
expect(() => getScenarioById("nonexistent-id")).toThrow();
});
it("getScenariosByTag returns only matching scenarios", () => {
const navScenarios = getScenariosByTag("navigation");
expect(navScenarios.length).toBeGreaterThan(0);
for (const s of navScenarios) {
expect(s.tags).toContain("navigation");
}
});
it("getScenariosByTag with multiple tags returns union", () => {
const results = getScenariosByTag("discovery", "error-handling");
for (const s of results) {
const hasMatch = s.tags.some((t) =>
["discovery", "error-handling"].includes(t),
);
expect(hasMatch).toBe(true);
}
});
it("all tags in use are from a known vocabulary", () => {
const KNOWN_TAGS = new Set([
"discovery",
"navigation",
"view-manager",
"search",
"error-handling",
"permissions",
"capabilities",
"plugin-install",
"desktop",
"voice",
"interaction",
"multi-turn",
"e2e",
]);
for (const s of VIEW_USER_JOURNEYS) {
for (const tag of s.tags) {
expect(
KNOWN_TAGS.has(tag),
`Unknown tag "${tag}" in scenario "${s.id}"`,
).toBe(true);
}
}
});
});
@@ -0,0 +1,429 @@
/**
* View registry memory lifecycle tests.
*
* Exercises the real `registerPluginViews` / `unregisterPluginViews` functions
* from `views-registry.ts` and verifies:
* - the module-level registry Map stays bounded across repeated cycles,
* - views disappear from `listViews()` and `getView()` after unregister,
* - multiple concurrent plugins coexist and clean up independently,
* - WeakRef-held entries become collectable after removal (GC-gated),
* - no EventEmitter listener accumulation occurs across load/unload cycles.
*/
import { EventEmitter } from "node:events";
import type { Plugin, ViewDeclaration } from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
getView,
listViews,
registerPluginViews,
unregisterPluginViews,
} from "../api/views-registry.js";
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Build a minimal Plugin with N views. Each view id is unique per plugin. */
function makePlugin(
pluginName: string,
viewCount: number,
extra?: Partial<ViewDeclaration>,
): Plugin {
const views: ViewDeclaration[] = Array.from(
{ length: viewCount },
(_, i) => ({
id: `${pluginName}.view${i}`,
label: `${pluginName} View ${i}`,
path: `/${pluginName}/view${i}`,
...extra,
}),
);
return { name: pluginName, description: `Test plugin ${pluginName}`, views };
}
function requirePluginViews(plugin: Plugin): ViewDeclaration[] {
if (!plugin.views) throw new Error(`Expected ${plugin.name} test views`);
return plugin.views;
}
/** Collect all view ids currently in the registry that start with `prefix`. */
function viewsWithPrefix(prefix: string): string[] {
return listViews({ developerMode: true })
.map((e) => e.id)
.filter((id) => id.startsWith(prefix));
}
// ---------------------------------------------------------------------------
// Cleanup between tests: unregister everything we registered so tests are
// independent even when running in the same module-level registry.
// ---------------------------------------------------------------------------
const registeredPluginNames: string[] = [];
beforeEach(() => {
registeredPluginNames.length = 0;
});
afterEach(() => {
for (const name of registeredPluginNames) {
unregisterPluginViews(name);
}
registeredPluginNames.length = 0;
});
async function register(plugin: Plugin): Promise<void> {
await registerPluginViews(plugin);
if (!registeredPluginNames.includes(plugin.name)) {
registeredPluginNames.push(plugin.name);
}
}
function unregister(pluginName: string): void {
unregisterPluginViews(pluginName);
const idx = registeredPluginNames.indexOf(pluginName);
if (idx !== -1) registeredPluginNames.splice(idx, 1);
}
// ---------------------------------------------------------------------------
// 1. Repeated register/unregister cycles — registry Map stays bounded
// ---------------------------------------------------------------------------
describe("repeated register/unregister cycles", () => {
it("registry does not accumulate entries across 10 cycles (3 views per plugin)", async () => {
const plugin = makePlugin("cycle-plugin", 3);
for (let i = 0; i < 10; i++) {
await register(plugin);
const after = viewsWithPrefix("cycle-plugin.");
expect(after).toHaveLength(3);
unregister("cycle-plugin");
const cleared = viewsWithPrefix("cycle-plugin.");
expect(cleared).toHaveLength(0);
}
});
it("re-registering after unregister yields exactly the original view ids", async () => {
const plugin = makePlugin("bounded-plugin", 3);
const expectedIds = requirePluginViews(plugin)
.map((v) => v.id)
.sort();
for (let i = 0; i < 10; i++) {
await register(plugin);
const ids = viewsWithPrefix("bounded-plugin.").sort();
expect(ids).toEqual(expectedIds);
unregister("bounded-plugin");
}
});
});
// ---------------------------------------------------------------------------
// 2. Module cache isolation — views absent from listViews after unregister
// ---------------------------------------------------------------------------
describe("module cache isolation", () => {
it("unregistered plugin views are absent from listViews()", async () => {
const plugin = makePlugin("isolation-plugin", 2);
await register(plugin);
expect(viewsWithPrefix("isolation-plugin.")).toHaveLength(2);
unregister("isolation-plugin");
const allIds = listViews({ developerMode: true }).map((e) => e.id);
for (const view of requirePluginViews(plugin)) {
expect(allIds).not.toContain(view.id);
}
});
it("views from other plugins remain after one plugin is unregistered", async () => {
const pA = makePlugin("iso-plugin-a", 2);
const pB = makePlugin("iso-plugin-b", 2);
await register(pA);
await register(pB);
unregister("iso-plugin-a");
expect(viewsWithPrefix("iso-plugin-a.")).toHaveLength(0);
expect(viewsWithPrefix("iso-plugin-b.")).toHaveLength(2);
});
});
// ---------------------------------------------------------------------------
// 3. Bundle URL cleanup — getView() returns undefined after unregister
// ---------------------------------------------------------------------------
describe("bundle URL cleanup", () => {
it("getView(id) returns undefined for all views after unregisterPluginViews", async () => {
const plugin = makePlugin("bundle-plugin", 2, {
bundlePath: "dist/views/main.js",
});
await register(plugin);
for (const view of requirePluginViews(plugin)) {
const entry = getView(view.id);
// bundleUrl is present when bundlePath is set (no real pluginDir, so
// available=false, but bundleUrl is still assigned from the path).
expect(entry).toBeDefined();
expect(entry?.bundleUrl).toBeDefined();
}
unregister("bundle-plugin");
for (const view of requirePluginViews(plugin)) {
expect(getView(view.id)).toBeUndefined();
}
});
});
// ---------------------------------------------------------------------------
// 4. Multiple plugins simultaneously — count verification
// ---------------------------------------------------------------------------
describe("multiple plugins simultaneously", () => {
it("5 plugins × 2 views = 10 plugin views; unregistering all returns to baseline", async () => {
const plugins = Array.from({ length: 5 }, (_, i) =>
makePlugin(`multi-plugin-${i}`, 2),
);
const baselineIds = new Set(
listViews({ developerMode: true }).map((e) => e.id),
);
for (const p of plugins) {
await register(p);
}
const afterRegistration = listViews({ developerMode: true });
const pluginViewCount = afterRegistration.filter((e) =>
e.id.startsWith("multi-plugin-"),
).length;
expect(pluginViewCount).toBe(10);
for (const p of plugins) {
unregister(p.name);
}
const afterUnregister = listViews({ developerMode: true }).map((e) => e.id);
// All multi-plugin views gone
expect(
afterUnregister.filter((id) => id.startsWith("multi-plugin-")),
).toHaveLength(0);
// Baseline views (builtins) still present
for (const id of baselineIds) {
expect(afterUnregister).toContain(id);
}
});
});
// ---------------------------------------------------------------------------
// 5. WeakRef / FinalizationRegistry — entries become collectable after unregister
// ---------------------------------------------------------------------------
describe("WeakRef collectability after unregister", () => {
it("a WeakRef to a removed entry's value becomes undefined after GC (skipped when global.gc unavailable)", async () => {
const gcAvailable =
typeof (globalThis as Record<string, unknown>).gc === "function";
if (!gcAvailable) {
// Cannot force GC — document the skip explicitly and pass.
console.info(
"[view-memory-lifecycle] Skipping WeakRef GC test: global.gc() not available. " +
"Run vitest with --expose-gc to enable this check.",
);
return;
}
const plugin = makePlugin("weakref-plugin", 1);
await register(plugin);
const viewId = requirePluginViews(plugin)[0]?.id;
if (!viewId) {
throw new Error("Expected test plugin to register a view");
}
const entry = getView(viewId);
expect(entry).toBeDefined();
if (!entry) {
throw new Error("Expected registry entry");
}
// Hold a WeakRef to the entry object. After the plugin is unregistered the
// registry Map drops its reference; if no other strong refs exist the entry
// becomes eligible for collection.
// NOTE: `entry` is a local variable that is a strong reference — we must
// copy the ref and then null out all our local references to make the
// object truly unreachable.
const weakEntry = new WeakRef(entry);
unregister("weakref-plugin");
// The local `entry` variable still holds a strong reference, so we cannot
// verify collection yet. We verify the registry no longer has it:
expect(getView(viewId)).toBeUndefined();
// After the registry deletes its reference, and we release ours, the object
// is eligible. We can only observe this after GC:
// (Cast to avoid TS error — we already verified gc is available above.)
const gcFn = (globalThis as { gc?: () => void }).gc;
gcFn?.();
gcFn?.(); // two passes in case first GC is minor
// With `entry` still in scope the object is technically reachable; this
// assertion verifies that the *registry* released its reference (already
// proven above) and we can at least observe the WeakRef was still live
// before GC because the local variable kept it alive.
// This test's primary value is confirming getView returns undefined and the
// WeakRef can be constructed — full collection evidence requires a scope
// boundary which vitest cannot easily enforce per-assertion.
expect(weakEntry.deref()).toBeDefined(); // local var still alive
// If we could null out `entry` here TypeScript would let us drop the ref.
// The meaningful invariant — registry released the entry — is proven above.
});
});
// ---------------------------------------------------------------------------
// 6. No event listener leaks across plugin load/unload cycles
// ---------------------------------------------------------------------------
describe("no EventEmitter listener accumulation", () => {
it("EventEmitter listener count does not grow across 10 register/unregister cycles", async () => {
// We track listener accumulation on a standalone emitter that mirrors the
// pattern a plugin might use: registering one listener per load and
// removing it on unload.
const emitter = new EventEmitter();
const EVENT = "view:registered";
const listenerFns: Array<() => void> = [];
async function loadCycle(): Promise<void> {
const fn = () => {};
listenerFns.push(fn);
emitter.on(EVENT, fn);
}
function unloadCycle(): void {
const fn = listenerFns.pop();
if (fn) emitter.off(EVENT, fn);
}
const baseline = emitter.listenerCount(EVENT);
let remainingCycles = 10;
while (remainingCycles > 0) {
remainingCycles -= 1;
await loadCycle();
unloadCycle();
}
// Each load adds one listener and each unload removes it — net zero.
expect(emitter.listenerCount(EVENT)).toBe(baseline);
expect(listenerFns).toHaveLength(0);
});
it("leaking listener pattern is detectable: count grows when off() is omitted", () => {
const emitter = new EventEmitter();
const EVENT = "view:leak";
emitter.setMaxListeners(50);
const baseline = emitter.listenerCount(EVENT);
const cycles = 10;
for (let i = 0; i < cycles; i++) {
// Intentionally omit off() to simulate a leak
emitter.on(EVENT, () => {});
}
// Confirms our detection approach works: without cleanup, count grows.
expect(emitter.listenerCount(EVENT)).toBe(baseline + cycles);
// Clean up so no warnings are emitted after the test
emitter.removeAllListeners(EVENT);
});
it("view registry register/unregister cycles do not leak listeners on a shared emitter", async () => {
// Attach a listener to track registry mutations via EventEmitter,
// then confirm the count is stable across cycles.
const emitter = new EventEmitter();
const EVENT = "view:change";
// Simulate what a plugin host might do: register a listener when a plugin
// view is registered, unregister on unload.
let count = 0;
const sharedListener = (): void => {
count++;
};
const cycles = 10;
const plugin = makePlugin("emitter-cycle-plugin", 2);
for (let i = 0; i < cycles; i++) {
emitter.on(EVENT, sharedListener);
await register(plugin);
emitter.emit(EVENT);
unregister("emitter-cycle-plugin");
emitter.off(EVENT, sharedListener);
}
// Listener was added and removed each cycle — net zero.
expect(emitter.listenerCount(EVENT)).toBe(0);
// Listener fired exactly once per cycle.
expect(count).toBe(cycles);
});
});
// ---------------------------------------------------------------------------
// 7. Idempotent unregister — double unregister is safe
// ---------------------------------------------------------------------------
describe("idempotent unregister", () => {
it("calling unregisterPluginViews twice does not throw and leaves registry clean", async () => {
const plugin = makePlugin("idempotent-plugin", 2);
await register(plugin);
expect(viewsWithPrefix("idempotent-plugin.")).toHaveLength(2);
unregister("idempotent-plugin");
expect(viewsWithPrefix("idempotent-plugin.")).toHaveLength(0);
// Second call must not throw.
expect(() => unregisterPluginViews("idempotent-plugin")).not.toThrow();
expect(viewsWithPrefix("idempotent-plugin.")).toHaveLength(0);
});
});
// ---------------------------------------------------------------------------
// 8. Plugin with no views — registerPluginViews is a no-op
// ---------------------------------------------------------------------------
describe("plugin with no views", () => {
it("registerPluginViews with an empty views array does not add entries", async () => {
const plugin: Plugin = {
name: "no-views-plugin",
description: "Plugin declaring no views",
views: [],
};
const before = listViews({ developerMode: true }).length;
await registerPluginViews(plugin);
const after = listViews({ developerMode: true }).length;
expect(after).toBe(before);
});
it("registerPluginViews with views field absent does not add entries", async () => {
const plugin: Plugin = {
name: "absent-views-plugin",
description: "Plugin with no views field",
};
const before = listViews({ developerMode: true }).length;
await registerPluginViews(plugin);
const after = listViews({ developerMode: true }).length;
expect(after).toBe(before);
});
});
@@ -0,0 +1,802 @@
/**
* View user journey scenario library.
*
* A curated collection of realistic user intents for the views system.
* These scenarios are used for:
* 1. Manual exploratory testing against a running agent.
* 2. LLM-in-the-loop automated evaluation (see view-llm-eval.test.ts).
* 3. Documentation of expected agent behaviors.
*
* Each entry is self-contained: it describes the user message, the expected
* high-level behavior, and machine-checkable verification criteria that an
* LLM judge or a deterministic assertion can evaluate.
*/
export interface ViewJourneyScenario {
/** Stable identifier for tooling and reporting. */
id: string;
/** One-line description of what the scenario tests. */
description: string;
/** The literal message the user sends to the agent. */
userMessage: string;
/** Prose description of what a correct agent response looks like. */
expectedBehavior: string;
/**
* Machine-checkable criteria. An LLM judge or assertion can score each.
* String criteria are evaluated as semantic checks against the agent response.
*/
verificationCriteria: string[];
/** Tags for grouping and filtering (e.g. "navigation", "discovery", "error"). */
tags: string[];
}
export interface PluginViewMockCase {
id: string;
viewType: "gui" | "tui" | "xr";
path: string;
}
export const PLUGIN_VIEW_LLM_MOCK_CASES: PluginViewMockCase[] = [
// Auto-mirrors the GUI visual smoke matrix. Kept in exact lockstep by
// plugin-view-llm-mock-coverage.test.ts; PLUGIN_VIEW_LLM_MOCK_JOURNEYS
// derives one journey per case below.
{ id: "birdclaw", viewType: "gui", path: "/birdclaw" },
{ id: "cloud", viewType: "gui", path: "/cloud" },
{ id: "contacts", viewType: "gui", path: "/contacts" },
{ id: "hyperliquid", viewType: "gui", path: "/hyperliquid" },
{ id: "focus", viewType: "gui", path: "/focus" },
{ id: "calendar", viewType: "gui", path: "/calendar" },
{ id: "documents", viewType: "gui", path: "/documents" },
{ id: "finances", viewType: "gui", path: "/finances" },
{ id: "goals", viewType: "gui", path: "/goals" },
{ id: "health", viewType: "gui", path: "/health" },
{ id: "inbox", viewType: "gui", path: "/inbox" },
{
id: "lifeops-live-test",
viewType: "gui",
path: "/lifeops-live-test",
},
{ id: "relationships", viewType: "gui", path: "/relationships" },
{ id: "todos", viewType: "gui", path: "/todos" },
{ id: "messages", viewType: "gui", path: "/messages" },
{ id: "model-tester", viewType: "gui", path: "/model-tester" },
{ id: "phone", viewType: "gui", path: "/phone" },
{ id: "polymarket", viewType: "gui", path: "/polymarket" },
{ id: "wallet", viewType: "gui", path: "/wallet" },
{ id: "vector-browser", viewType: "gui", path: "/vector-browser" },
{ id: "feed", viewType: "gui", path: "/feed" },
{ id: "views-manager", viewType: "gui", path: "/views" },
{ id: "screenshare", viewType: "gui", path: "/screenshare" },
{ id: "task-coordinator", viewType: "gui", path: "/task-coordinator" },
{ id: "orchestrator", viewType: "gui", path: "/orchestrator" },
{ id: "cockpit", viewType: "gui", path: "/cockpit" },
{ id: "trajectory-logger", viewType: "gui", path: "/trajectory-logger" },
{ id: "training", viewType: "gui", path: "/apps/fine-tuning" },
];
const formatPluginViewLabel = (id: string) =>
id.replaceAll("-", " ").replace(/\b\w/g, (char) => char.toUpperCase());
export const PLUGIN_VIEW_LLM_MOCK_JOURNEYS: ViewJourneyScenario[] =
PLUGIN_VIEW_LLM_MOCK_CASES.map((view) => {
const label = formatPluginViewLabel(view.id);
const surface =
view.viewType === "tui"
? "terminal TUI"
: view.viewType === "xr"
? "spatial XR"
: "visual GUI";
return {
id: `plugin-view-${view.id}-${view.viewType}`,
description: `Mock LLM route coverage for the ${label} ${surface}`,
userMessage: `Open the ${surface} for ${label} at ${view.path}`,
expectedBehavior: `Agent routes to view id "${view.id}" with viewType "${view.viewType}" and path "${view.path}".`,
verificationCriteria: [
`response or tool call selects view id "${view.id}"`,
`response or tool call selects viewType "${view.viewType}"`,
`response or tool call resolves path "${view.path}"`,
"response confirms the navigation without exposing bundle internals",
],
tags: ["mock-eval", "navigation", "plugin-view", view.viewType],
};
});
export const BASE_VIEW_USER_JOURNEYS: ViewJourneyScenario[] = [
// ── Discovery ────────────────────────────────────────────────────────────
{
id: "show-all-views",
description: "User asks to see all available views",
userMessage: "show me all views",
expectedBehavior:
"Agent lists the available views with names and brief descriptions, formatted readably",
verificationCriteria: [
"response contains at least one view name",
"response is formatted in a readable list or prose",
"response does not include internal implementation details like bundle paths",
],
tags: ["discovery"],
},
{
id: "what-views-are-available",
description: "User asks what views exist using different phrasing",
userMessage: "what views are available?",
expectedBehavior: "Agent enumerates available views by name",
verificationCriteria: [
"response contains view names",
"response answers the question without asking for clarification",
],
tags: ["discovery"],
},
{
id: "list-everything",
description: "User asks for a list of everything they can open",
userMessage: "what can I open?",
expectedBehavior: "Agent lists openable views or panels in the UI",
verificationCriteria: [
"response mentions at least one named view or panel",
"response is helpful and not evasive",
],
tags: ["discovery"],
},
// ── Navigation: open a specific view ─────────────────────────────────────
{
id: "open-wallet",
description: "User asks to open the wallet view",
userMessage: "open the wallet",
expectedBehavior:
"Agent navigates to or opens the wallet view and confirms the action",
verificationCriteria: [
"response mentions wallet",
"response confirms navigation or opening action",
"response does not ask the user to navigate manually",
],
tags: ["navigation"],
},
{
id: "go-to-settings",
description: "User asks to go to settings",
userMessage: "go to settings",
expectedBehavior: "Agent navigates to the settings view",
verificationCriteria: [
"response confirms navigation to settings",
"response does not error or express inability",
],
tags: ["navigation"],
},
{
id: "open-chat",
description: "User asks to open the chat interface",
userMessage: "open chat",
expectedBehavior: "Agent opens or focuses the chat view",
verificationCriteria: [
"response references chat",
"response confirms the action",
],
tags: ["navigation"],
},
{
id: "show-trading-dashboard",
description: "User asks to open the trading dashboard by name",
userMessage: "show me the trading dashboard",
expectedBehavior: "Agent opens the trading dashboard view",
verificationCriteria: [
"response mentions trading",
"response confirms navigation or opening",
],
tags: ["navigation"],
},
{
id: "switch-between-views",
description: "User asks to switch from one view to another",
userMessage: "switch to the wallet view",
expectedBehavior:
"Agent navigates to the wallet view from whatever is currently open",
verificationCriteria: [
"response confirms switch or navigation",
"response mentions wallet",
],
tags: ["navigation"],
},
// ── View manager ──────────────────────────────────────────────────────────
{
id: "open-view-manager",
description: "User asks to open the view manager grid",
userMessage: "open the view manager",
expectedBehavior:
"Agent opens the view manager panel showing all available views as a grid",
verificationCriteria: [
"response confirms opening the view manager",
"response does not show an error",
],
tags: ["navigation", "view-manager"],
},
{
id: "show-views-grid",
description:
"User asks for a grid or gallery of views using alternate phrasing",
userMessage: "show me all my panels in a grid",
expectedBehavior:
"Agent opens the view manager or lists views in a structured format",
verificationCriteria: [
"response lists or displays available views",
"response is structured and scannable",
],
tags: ["discovery", "view-manager"],
},
// ── Search / capability-based discovery ──────────────────────────────────
{
id: "search-views-by-capability",
description: "User searches for views by what they can do (crypto/finance)",
userMessage: "find views for managing my crypto",
expectedBehavior:
"Agent returns views tagged with finance or crypto (wallet, trading, etc.)",
verificationCriteria: [
"response mentions wallet or trading or crypto-related view",
"response does not suggest completely unrelated views like settings",
],
tags: ["discovery", "search"],
},
{
id: "search-views-by-topic",
description: "User asks for views related to communication",
userMessage: "what views are there for messaging or chatting?",
expectedBehavior:
"Agent surfaces the chat view or other communication-related views",
verificationCriteria: [
"response mentions chat or messaging view",
"response is relevant to communication",
],
tags: ["discovery", "search"],
},
{
id: "find-configuration-views",
description: "User asks how to configure or set up something",
userMessage: "where can I configure my account?",
expectedBehavior:
"Agent points the user toward settings or configuration views",
verificationCriteria: [
"response mentions settings or configuration view",
"response gives a clear path to configuration",
],
tags: ["discovery", "search"],
},
// ── Close / dismiss ───────────────────────────────────────────────────────
{
id: "close-current-view",
description: "User asks to close the current view",
userMessage: "close the current view",
expectedBehavior:
"Agent closes the active view or confirms it has been dismissed",
verificationCriteria: [
"response confirms closure or dismissal",
"response does not open a different view instead",
],
tags: ["navigation"],
},
{
id: "go-back",
description: "User asks to go back to the previous view",
userMessage: "go back",
expectedBehavior: "Agent navigates back or returns to the previous view",
verificationCriteria: [
"response acknowledges the back navigation request",
"response does not open the view manager or a specific unrelated view",
],
tags: ["navigation"],
},
// ── Error / edge cases ────────────────────────────────────────────────────
{
id: "view-not-found",
description: "User asks to open a view that does not exist",
userMessage: "open the inventory view",
expectedBehavior:
"Agent tells the user no such view exists and offers alternatives",
verificationCriteria: [
"response does not claim success for a nonexistent view",
"response is helpful: either offers alternatives or explains what views exist",
],
tags: ["error-handling"],
},
{
id: "ambiguous-view-name",
description: "User uses an ambiguous name that could match multiple views",
userMessage: "open the dashboard",
expectedBehavior:
"Agent either resolves to the most likely view or asks which dashboard the user means",
verificationCriteria: [
"response does not silently open the wrong view",
"response either clarifies or confirms the specific view being opened",
],
tags: ["error-handling"],
},
{
id: "developer-view-not-visible",
description: "Regular user asks to open a developer-only view",
userMessage: "open the dev logs",
expectedBehavior:
"Agent reports the view is unavailable or requires developer mode, or does not expose it",
verificationCriteria: [
"response does not open a developer-only view to a regular user",
"response handles the request gracefully without a stack trace or raw error",
],
tags: ["error-handling", "permissions"],
},
// ── View with capabilities ─────────────────────────────────────────────
{
id: "view-with-agent-capability",
description:
"User asks the agent to interact with a view that declares capabilities",
userMessage: "check my wallet balance",
expectedBehavior:
"Agent opens or focuses the wallet view and uses the check-balance capability, then reports the result",
verificationCriteria: [
"response includes balance information or confirms it is checking",
"response does not leave the user without an answer",
],
tags: ["capabilities"],
},
{
id: "install-plugin-via-agent",
description: "User asks agent to install a plugin that adds a new view",
userMessage: "install the weather plugin",
expectedBehavior:
"Agent installs the plugin and confirms the new view is now available",
verificationCriteria: [
"response confirms installation or explains any failure",
"response mentions the new view that the plugin provides",
],
tags: ["plugin-install"],
},
// ── Desktop / pinning ─────────────────────────────────────────────────────
{
id: "pin-view-as-tab",
description: "User asks to pin a view as a desktop tab",
userMessage: "pin the wallet view as a tab",
expectedBehavior: "Agent pins the wallet view as a persistent desktop tab",
verificationCriteria: [
"response confirms the tab has been pinned",
"response mentions wallet",
],
tags: ["navigation", "desktop"],
},
// ── Voice command scenarios ───────────────────────────────────────────────
{
id: "voice-open-wallet",
description: "User speaks a short voice command to open the wallet",
userMessage: "open wallet",
expectedBehavior:
"Agent recognizes the short voice transcription as a navigation intent and opens the wallet view",
verificationCriteria: [
"response confirms the wallet view is being opened",
"response does not ask the user to rephrase or type a longer command",
"response handles the terse phrasing gracefully without treating it as ambiguous",
"response does not open an unrelated view",
],
tags: ["voice", "navigation"],
},
{
id: "voice-show-settings",
description:
"User speaks a single-word voice command to navigate to settings",
userMessage: "settings",
expectedBehavior:
"Agent interprets the single-word utterance as a navigation request and opens the settings view",
verificationCriteria: [
"response confirms navigation to settings",
"response does not request more context before acting",
"response does not treat the single word as an error or unknown command",
"response does not open a different view",
],
tags: ["voice", "navigation"],
},
{
id: "voice-search-views",
description:
"User speaks a natural-language voice query to find a trading view",
userMessage: "find me something for trading",
expectedBehavior:
"Agent surfaces the trading dashboard or a relevant finance view matching the spoken intent",
verificationCriteria: [
"response mentions a trading or finance-related view",
"response does not return completely unrelated views",
"response is actionable: it either opens the view or presents a navigable option",
"response acknowledges the search intent rather than asking for exact view names",
],
tags: ["voice", "search", "discovery"],
},
{
id: "voice-ambiguous-navigation",
description:
"User says 'go home', which could map to chat or the main view",
userMessage: "go home",
expectedBehavior:
"Agent navigates to the primary home or chat view, or disambiguates between home and chat with a brief clarification",
verificationCriteria: [
"response navigates to a plausible home-equivalent view (chat, main, or similar)",
"if ambiguous, response asks a single focused clarifying question rather than listing all views",
"response does not open an unrelated view such as settings or wallet",
"response does not return an error or claim no view matches",
],
tags: ["voice", "navigation", "error-handling"],
},
{
id: "voice-view-manager",
description:
"User says 'show apps', a legacy voice phrase that should open the view manager",
userMessage: "show apps",
expectedBehavior:
"Agent maps the legacy 'show apps' phrasing to the view manager and opens it",
verificationCriteria: [
"response opens or confirms opening the view manager",
"response does not reject the phrasing as unrecognized",
"response does not open a single specific app view instead of the manager",
"response handles the legacy phrasing transparently without requiring rephrasing",
],
tags: ["voice", "navigation", "view-manager"],
},
// ── View interaction scenarios ────────────────────────────────────────────
{
id: "agent-clicks-in-view",
description: "User asks the agent to click a button inside the wallet view",
userMessage: "click the send button in the wallet view",
expectedBehavior:
"Agent uses the wallet view's declared capability to trigger the send action, or navigates to the send flow and confirms",
verificationCriteria: [
"response confirms the send button was activated or the send flow was initiated",
"response references the wallet view context",
"response does not claim inability to interact with views",
"response does not simply describe the button without acting",
],
tags: ["interaction", "capabilities"],
},
{
id: "agent-reads-view-state",
description:
"User asks the agent to read live state from the open wallet view",
userMessage: "what's my balance in the wallet view?",
expectedBehavior:
"Agent reads the current balance from the wallet view's state and reports it to the user",
verificationCriteria: [
"response includes a balance value or confirms it is fetching the balance",
"response does not return a static or hardcoded placeholder value",
"response references the wallet view as the source of the information",
"response does not ask the user to open the wallet first if it is already open",
],
tags: ["interaction", "capabilities"],
},
{
id: "agent-refreshes-view",
description: "User asks the agent to refresh the wallet view",
userMessage: "refresh the wallet",
expectedBehavior:
"Agent triggers a refresh of the wallet view and confirms the action",
verificationCriteria: [
"response confirms the wallet view has been refreshed or is refreshing",
"response does not close and reopen the view as a substitute for refresh",
"response does not navigate away from the wallet",
"response is concise and does not require follow-up from the user",
],
tags: ["interaction", "navigation"],
},
{
id: "agent-fills-form-in-view",
description:
"User asks the agent to fill in a recipient address in the wallet send form",
userMessage:
"fill in the recipient address with 0xAbCd1234 in the wallet send form",
expectedBehavior:
"Agent locates the recipient address field in the wallet send form and populates it with the provided address",
verificationCriteria: [
"response confirms the recipient address field has been filled",
"response includes or echoes the address to confirm correctness",
"response does not submit the form without explicit user confirmation",
"response does not navigate away from the send form",
"response handles the address value faithfully without truncating or modifying it",
],
tags: ["interaction", "capabilities"],
},
// ── Multi-turn / context scenarios ───────────────────────────────────────
{
id: "open-then-interact",
description:
"User first opens wallet, then in a follow-up asks to send funds",
userMessage: "send some funds",
expectedBehavior:
"Agent retains context that the wallet is open and initiates the send flow without requiring the user to restate the view",
verificationCriteria: [
"response initiates the send flow or asks for send details (amount, recipient)",
"response does not ask the user to open the wallet again",
"response demonstrates awareness that the wallet view is the current context",
"response does not open a different view before responding",
],
tags: ["multi-turn", "interaction"],
},
{
id: "switch-views-mid-task",
description:
"User switches from wallet to settings mid-task and then returns",
userMessage: "actually, take me back to the wallet",
expectedBehavior:
"Agent navigates back to the wallet view, restoring the previous context",
verificationCriteria: [
"response confirms navigation back to the wallet view",
"response does not lose prior wallet context (e.g., in-progress send form state)",
"response does not open an unrelated view",
"response handles the implicit 'back' intent without requiring exact view name",
],
tags: ["multi-turn", "navigation"],
},
// ── E2E journey scenarios ─────────────────────────────────────────────────
{
id: "complete-voice-journey",
description:
"Full end-to-end flow: user speaks, agent opens view, reads state, and confirms with user",
userMessage: "open my wallet and tell me my balance",
expectedBehavior:
"Agent opens the wallet view, reads the current balance from its state, and reports it back with a confirmation",
verificationCriteria: [
"response confirms the wallet view was opened",
"response includes or requests the balance value from the view",
"response delivers the balance to the user in the same turn or the immediately following turn",
"response does not require the user to manually navigate or read the UI themselves",
"response is coherent across the open and read steps without contradicting itself",
],
tags: ["e2e", "voice", "interaction"],
},
{
id: "first-run-to-view",
description:
"New user sees the chat interface, asks what the agent can do, and agent opens the view manager",
userMessage: "what can you do?",
expectedBehavior:
"Agent explains its capabilities at a high level and proactively opens the view manager to show available views",
verificationCriteria: [
"response describes at least two agent capabilities (e.g., navigation, reading state, search)",
"response opens or offers to open the view manager to show available views",
"response is welcoming and oriented toward a new user",
"response does not overwhelm with technical jargon or internal system details",
"response invites the user to pick a view or ask a follow-up question",
],
tags: ["e2e", "discovery", "view-manager"],
},
];
const VIEW_JOURNEY_EDGE_VARIANTS: Array<{
suffix: string;
description: string;
mutateMessage: (message: string) => string;
criteria: string[];
tags: string[];
}> = [
{
suffix: "voice-terse",
description: "terse voice-command phrasing",
mutateMessage: (message) =>
message
.replace(/^show me /i, "show ")
.replace(/^what views are available\??$/i, "views?")
.replace(/^open the /i, "open ")
.replace(/^go to /i, "")
.replace(/[?.!]$/g, ""),
criteria: [
"response handles terse or voice-like phrasing without asking for a full sentence",
"response preserves the original intent",
],
tags: ["voice"],
},
{
suffix: "polite",
description: "polite natural-language request",
mutateMessage: (message) =>
`could you please ${message.replace(/[?.!]$/g, "")}?`,
criteria: [
"response does not mistake politeness for uncertainty",
"response acts on the embedded request",
],
tags: [],
},
{
suffix: "urgent",
description: "urgent user request",
mutateMessage: (message) => `quick, ${message.replace(/[?.!]$/g, "")}`,
criteria: [
"response remains concise under urgency",
"response does not skip required confirmation or safety handling",
],
tags: [],
},
{
suffix: "followup",
description: "follow-up after prior context",
mutateMessage: (message) =>
`following up from the last thing we were doing, ${message.replace(/[?.!]$/g, "")}`,
criteria: [
"response uses the latest request rather than stale prior context",
"response remains coherent as a follow-up",
],
tags: ["multi-turn"],
},
{
suffix: "mobile-typo",
description: "mobile-style casual punctuation",
mutateMessage: (message) =>
`${message.toLowerCase().replace(/[?.!]$/g, "")} pls`,
criteria: [
"response tolerates casual mobile phrasing",
"response does not require the user to retype the request",
],
tags: [],
},
{
suffix: "quoted",
description: "request embedded in quoted text",
mutateMessage: (message) => `do this exact request: "${message}"`,
criteria: [
"response follows the quoted request",
"response does not include quotation-wrapper implementation details",
],
tags: [],
},
{
suffix: "negative-space",
description: "request with explicit non-goal",
mutateMessage: (message) =>
`${message.replace(/[?.!]$/g, "")}; don't show me raw debug info`,
criteria: [
"response does not expose raw debug info",
"response still fulfills the main view request",
],
tags: [],
},
{
suffix: "legacy-app-wording",
description: "legacy apps/panels wording",
mutateMessage: (message) =>
message
.replace(/\bviews\b/gi, "apps")
.replace(/\bview\b/gi, "app")
.replace(/\bpanels\b/gi, "apps")
.replace(/\bpanel\b/gi, "app"),
criteria: [
"response maps legacy app/panel wording onto the view system",
"response does not reject the request as using the wrong term",
],
tags: ["view-manager"],
},
{
suffix: "accessibility",
description: "accessibility-oriented phrasing",
mutateMessage: (message) =>
`I'm using voice control, ${message.replace(/[?.!]$/g, "")}`,
criteria: [
"response accommodates voice-control context",
"response avoids instructions that require manual navigation when action is possible",
],
tags: ["voice"],
},
{
suffix: "compound-context",
description: "compound sentence with extra context",
mutateMessage: (message) =>
`I'm in the middle of something and need the UI to keep up: ${message}`,
criteria: [
"response extracts the actionable view intent from surrounding context",
"response does not over-focus on the filler context",
],
tags: ["e2e"],
},
];
function expandViewJourneyScenarios(
base: readonly ViewJourneyScenario[],
): ViewJourneyScenario[] {
return base.flatMap((scenario) =>
VIEW_JOURNEY_EDGE_VARIANTS.map((variant) => ({
...scenario,
id: `${scenario.id}--${variant.suffix}`,
description: `${scenario.description} (${variant.description})`,
userMessage: variant.mutateMessage(scenario.userMessage),
expectedBehavior: scenario.expectedBehavior,
verificationCriteria: [
...scenario.verificationCriteria,
...variant.criteria,
],
tags: Array.from(new Set([...scenario.tags, ...variant.tags])),
})),
);
}
export const EXPANDED_VIEW_USER_JOURNEYS: ViewJourneyScenario[] =
expandViewJourneyScenarios(BASE_VIEW_USER_JOURNEYS);
if (
EXPANDED_VIEW_USER_JOURNEYS.length !==
BASE_VIEW_USER_JOURNEYS.length * 10
) {
throw new Error(
`view journey expansion must add exactly 10x (${BASE_VIEW_USER_JOURNEYS.length * 10}); got ${EXPANDED_VIEW_USER_JOURNEYS.length}`,
);
}
export const VIEW_USER_JOURNEYS: ViewJourneyScenario[] = [
...BASE_VIEW_USER_JOURNEYS,
...EXPANDED_VIEW_USER_JOURNEYS,
];
export function countViewJourneyScenarios(): {
existing: number;
added: number;
total: number;
multiplierAdded: number;
} {
return {
existing: BASE_VIEW_USER_JOURNEYS.length,
added: EXPANDED_VIEW_USER_JOURNEYS.length,
total: VIEW_USER_JOURNEYS.length,
multiplierAdded:
EXPANDED_VIEW_USER_JOURNEYS.length / BASE_VIEW_USER_JOURNEYS.length,
};
}
/**
* Returns all scenarios matching any of the given tags.
*/
export function getScenariosByTag(...tags: string[]): ViewJourneyScenario[] {
return VIEW_USER_JOURNEYS.filter((s) => s.tags.some((t) => tags.includes(t)));
}
/**
* Returns the scenario with the given id, or throws if not found.
*/
export function getScenarioById(id: string): ViewJourneyScenario {
const scenario = VIEW_USER_JOURNEYS.find((s) => s.id === id);
if (!scenario) {
throw new Error(`No view journey scenario with id "${id}"`);
}
return scenario;
}
@@ -0,0 +1,416 @@
/**
* Views action scenario tests.
*
* Tests user-intent scenarios against the views registry directly — no live
* LLM required. The registry module is reset between tests via module-level
* isolation so each case starts with a clean slate.
*/
import type { Plugin } from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
// ---------------------------------------------------------------------------
// Inline registry isolation helpers
// ---------------------------------------------------------------------------
// The registry is a module-level Map in views-registry.ts. We reset it
// between tests by importing the register/unregister functions directly and
// unregistering what we registered rather than mocking the module, so the
// real implementation is exercised.
import {
getView,
listViews,
registerPluginViews,
unregisterPluginViews,
} from "../api/views-registry.js";
// ---------------------------------------------------------------------------
// Fixture builders
// ---------------------------------------------------------------------------
function makePlugin(
name: string,
views: Plugin["views"],
extra: Partial<Plugin> = {},
): Plugin {
return {
name,
description: `Test plugin ${name}`,
actions: [],
views,
...extra,
};
}
const WALLET_VIEW = {
id: "wallet.inventory",
label: "Wallet",
description: "Manage your crypto wallet and assets",
icon: "Wallet",
path: "/wallet",
order: 10,
tags: ["finance", "crypto"],
};
const TRADING_VIEW = {
id: "trading.dashboard",
label: "Trading",
description: "Buy and sell tokens on DEX markets",
icon: "TrendingUp",
path: "/trading",
order: 20,
tags: ["finance", "crypto", "dex"],
};
const CHAT_VIEW = {
id: "chat.main",
label: "Chat",
description: "Conversation interface with the agent",
icon: "MessageSquare",
path: "/chat",
order: 1,
tags: ["communication"],
};
const SETTINGS_VIEW = {
id: "settings.main",
label: "Settings",
description: "Configure the assistant and connected apps",
icon: "Settings",
path: "/settings",
order: 99,
tags: ["configuration"],
};
const DEV_VIEW = {
id: "dev.logs",
label: "Dev Logs",
description: "Structured log viewer for developers",
icon: "Terminal",
path: "/dev/logs",
order: 200,
developerOnly: true,
tags: ["developer"],
};
// ---------------------------------------------------------------------------
// Setup / teardown
// ---------------------------------------------------------------------------
const TEST_PLUGIN_NAMES = [
"test-wallet-plugin",
"test-trading-plugin",
"test-chat-plugin",
"test-settings-plugin",
"test-dev-plugin",
];
beforeEach(async () => {
vi.spyOn(console, "info").mockImplementation(() => {});
});
afterEach(() => {
// Clean up all views registered by these tests.
for (const name of TEST_PLUGIN_NAMES) {
unregisterPluginViews(name);
}
vi.restoreAllMocks();
});
// ---------------------------------------------------------------------------
// Scenario: "show me all views" → returns view list
// ---------------------------------------------------------------------------
describe('scenario: "show me all views"', () => {
it("returns all registered views with label and description", async () => {
await registerPluginViews(
makePlugin("test-wallet-plugin", [WALLET_VIEW]),
undefined,
);
await registerPluginViews(
makePlugin("test-chat-plugin", [CHAT_VIEW]),
undefined,
);
const views = listViews();
expect(views.length).toBeGreaterThanOrEqual(2);
const ids = views.map((v) => v.id);
expect(ids).toContain("wallet.inventory");
expect(ids).toContain("chat.main");
for (const view of views) {
expect(view.label).toBeTruthy();
// description is optional on the type but our fixtures set it
expect(view.id).toBeTruthy();
}
});
it("returns views sorted by order field ascending", async () => {
await registerPluginViews(
makePlugin("test-settings-plugin", [SETTINGS_VIEW]),
undefined,
);
await registerPluginViews(
makePlugin("test-wallet-plugin", [WALLET_VIEW]),
undefined,
);
await registerPluginViews(
makePlugin("test-chat-plugin", [CHAT_VIEW]),
undefined,
);
const views = listViews();
const orders = views
.filter((v) =>
["chat.main", "wallet.inventory", "settings.main"].includes(v.id),
)
.map((v) => v.order ?? 100);
for (let i = 1; i < orders.length; i++) {
expect(orders[i]).toBeGreaterThanOrEqual(orders[i - 1]!);
}
});
});
// ---------------------------------------------------------------------------
// Scenario: "open wallet" → wallet view is accessible by id
// ---------------------------------------------------------------------------
describe('scenario: "open wallet"', () => {
it("wallet view can be retrieved by its stable id after registration", async () => {
await registerPluginViews(
makePlugin("test-wallet-plugin", [WALLET_VIEW]),
undefined,
);
const view = getView("wallet.inventory");
expect(view).toBeDefined();
expect(view?.label).toBe("Wallet");
expect(view?.path).toBe("/wallet");
expect(view?.pluginName).toBe("test-wallet-plugin");
});
it("looking up an unknown view id returns undefined", () => {
const view = getView("nonexistent.view");
expect(view).toBeUndefined();
});
});
// ---------------------------------------------------------------------------
// Scenario: "find views for trading" → returns trading-tagged views
// ---------------------------------------------------------------------------
describe('scenario: "find views for trading / crypto"', () => {
it("views tagged with finance/crypto appear in the registry", async () => {
await registerPluginViews(
makePlugin("test-wallet-plugin", [WALLET_VIEW]),
undefined,
);
await registerPluginViews(
makePlugin("test-trading-plugin", [TRADING_VIEW]),
undefined,
);
await registerPluginViews(
makePlugin("test-chat-plugin", [CHAT_VIEW]),
undefined,
);
const views = listViews();
const financeViews = views.filter((v) =>
v.tags?.some((t) => ["finance", "crypto", "dex"].includes(t)),
);
expect(financeViews.length).toBeGreaterThanOrEqual(2);
const financeIds = financeViews.map((v) => v.id);
expect(financeIds).toContain("wallet.inventory");
expect(financeIds).toContain("trading.dashboard");
// chat should not be in finance-tagged results
expect(financeIds).not.toContain("chat.main");
});
it("returns views matching a tag when filtered by the caller", async () => {
await registerPluginViews(
makePlugin("test-wallet-plugin", [WALLET_VIEW]),
undefined,
);
await registerPluginViews(
makePlugin("test-trading-plugin", [TRADING_VIEW]),
undefined,
);
const views = listViews();
const cryptoViews = views.filter((v) => v.tags?.includes("crypto"));
expect(cryptoViews.length).toBe(2);
});
});
// ---------------------------------------------------------------------------
// Scenario: developer-only view filtering
// ---------------------------------------------------------------------------
describe('scenario: "open view manager" — developer-only views hidden', () => {
it("developer-only view is excluded from normal listing", async () => {
await registerPluginViews(
makePlugin("test-dev-plugin", [DEV_VIEW]),
undefined,
);
await registerPluginViews(
makePlugin("test-chat-plugin", [CHAT_VIEW]),
undefined,
);
const normalViews = listViews({ developerMode: false });
const devView = normalViews.find((v) => v.id === "dev.logs");
expect(devView).toBeUndefined();
});
it("developer-only view appears when developerMode=true", async () => {
await registerPluginViews(
makePlugin("test-dev-plugin", [DEV_VIEW]),
undefined,
);
const devViews = listViews({ developerMode: true });
const devView = devViews.find((v) => v.id === "dev.logs");
expect(devView).toBeDefined();
expect(devView?.developerOnly).toBe(true);
});
});
// ---------------------------------------------------------------------------
// Scenario: plugin unregistration removes views
// ---------------------------------------------------------------------------
describe('scenario: "close / uninstall plugin removes its views"', () => {
it("unregistering a plugin removes all its views from the list", async () => {
await registerPluginViews(
makePlugin("test-wallet-plugin", [WALLET_VIEW]),
undefined,
);
await registerPluginViews(
makePlugin("test-chat-plugin", [CHAT_VIEW]),
undefined,
);
// Confirm both are present.
expect(listViews().map((v) => v.id)).toContain("wallet.inventory");
expect(listViews().map((v) => v.id)).toContain("chat.main");
unregisterPluginViews("test-wallet-plugin");
const afterUnregister = listViews().map((v) => v.id);
expect(afterUnregister).not.toContain("wallet.inventory");
expect(afterUnregister).toContain("chat.main");
});
it("unregistering an unknown plugin name is a no-op", () => {
expect(() =>
unregisterPluginViews("plugin-that-never-existed"),
).not.toThrow();
});
});
// ---------------------------------------------------------------------------
// Scenario: re-registering a plugin updates its views
// ---------------------------------------------------------------------------
describe("scenario: plugin view update on re-registration", () => {
it("re-registering the same plugin id overwrites existing view entry", async () => {
await registerPluginViews(
makePlugin("test-wallet-plugin", [WALLET_VIEW]),
undefined,
);
const first = getView("wallet.inventory");
expect(first?.label).toBe("Wallet");
const updated = { ...WALLET_VIEW, label: "My Wallet Updated" };
await registerPluginViews(
makePlugin("test-wallet-plugin", [updated]),
undefined,
);
const second = getView("wallet.inventory");
expect(second?.label).toBe("My Wallet Updated");
});
});
// ---------------------------------------------------------------------------
// Scenario: plugin with no views is a no-op
// ---------------------------------------------------------------------------
describe("scenario: plugin with no views declared", () => {
it("registerPluginViews with empty views array is a no-op", async () => {
const countBefore = listViews({ developerMode: true }).length;
await registerPluginViews(makePlugin("test-wallet-plugin", []), undefined);
const countAfter = listViews({ developerMode: true }).length;
expect(countAfter).toBe(countBefore);
});
it("registerPluginViews with undefined views is a no-op", async () => {
const countBefore = listViews({ developerMode: true }).length;
await registerPluginViews(
makePlugin("test-wallet-plugin", undefined),
undefined,
);
const countAfter = listViews({ developerMode: true }).length;
expect(countAfter).toBe(countBefore);
});
});
// ---------------------------------------------------------------------------
// Scenario: view metadata is fully preserved
// ---------------------------------------------------------------------------
describe("scenario: view metadata integrity", () => {
it("all declared fields are preserved in the registry entry", async () => {
const richView = {
id: "wallet.inventory",
label: "Wallet",
description: "Manage your crypto wallet and assets",
icon: "Wallet",
path: "/wallet",
order: 10,
tags: ["finance", "crypto"],
componentExport: "WalletView",
desktopTabEnabled: true,
visibleInManager: true,
capabilities: [
{
id: "check-balance",
description: "Read the current token balances",
},
],
};
await registerPluginViews(
makePlugin("test-wallet-plugin", [richView]),
undefined,
);
const entry = getView("wallet.inventory");
expect(entry).toBeDefined();
expect(entry?.description).toBe(richView.description);
expect(entry?.icon).toBe("Wallet");
expect(entry?.tags).toEqual(["finance", "crypto"]);
expect(entry?.capabilities).toHaveLength(1);
expect(entry?.capabilities?.[0]?.id).toBe("check-balance");
expect(entry?.componentExport).toBe("WalletView");
});
it("entry includes pluginName, available, and loadedAt metadata", async () => {
const before = Date.now();
await registerPluginViews(
makePlugin("test-wallet-plugin", [WALLET_VIEW]),
undefined,
);
const after = Date.now();
const entry = getView("wallet.inventory");
expect(entry?.pluginName).toBe("test-wallet-plugin");
expect(entry?.available).toBe(false); // no pluginDir → no bundle on disk
expect(entry?.loadedAt).toBeGreaterThanOrEqual(before);
expect(entry?.loadedAt).toBeLessThanOrEqual(after);
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,48 @@
/**
* Unit-tests `isWalletContextAugmentationIntent`, the heuristic gate deciding
* whether an incoming prompt warrants injecting wallet / on-chain context. Pins
* that it fires on explicit wallet or on-chain phrasing and stays quiet on
* lookalike developer vocabulary (token/address/send/approve/transfer used
* non-financially). Pure function; deterministic, no runtime.
*/
import { describe, expect, it } from "vitest";
import { isWalletContextAugmentationIntent } from "../api/server-helpers.ts";
describe("isWalletContextAugmentationIntent", () => {
it("does not trigger wallet context on common developer words", () => {
for (const prompt of [
"paste the API_TOKEN into the env file",
"send a request to the local server",
"Sol shipped the connector fix",
"what is the address of this HTTP endpoint?",
"tokenize this TypeScript string",
"buy groceries after the meeting",
"approve the PR once CI is green",
"transfer the files into the new folder",
"send a request to the local server address",
]) {
expect(isWalletContextAugmentationIntent(prompt), prompt).toBe(false);
}
});
it("triggers wallet context for explicit wallet or on-chain requests", () => {
for (const prompt of [
"what is my wallet address?",
"check my onchain balance",
"send 1 bnb to this wallet",
"swap eth for sol",
"approve the token spend",
"buy bnb onchain",
"send 5 USDC to 0x1234567890123456789012345678901234567890",
"transfer 1 USDT to 0x1234567890123456789012345678901234567890",
"what is my USDC balance",
"approve USDC spend",
"bridge USDC to Solana",
"show my token balance",
"what funds are in my crypto wallet?",
]) {
expect(isWalletContextAugmentationIntent(prompt), prompt).toBe(true);
}
});
});
@@ -0,0 +1,170 @@
/**
* Exercises `compactConversationAction` (validate + handler) against hand-built
* in-memory runtime doubles: gating below the minimum-history threshold,
* persisting a room ledger + compaction point for an existing session, and
* windowing only the oldest fetched page in very long paged sessions.
* Deterministic — `useModel` returns a canned ledger JSON and the room is a
* plain object; no live model or database.
*/
import { describe, expect, it } from "vitest";
import { compactConversationAction } from "./compact-conversation.ts";
const ROOM_ID = "33333333-3333-4333-8333-333333333333";
const AGENT_ID = "44444444-4444-4444-8444-444444444444";
const USER_ID = "55555555-5555-4555-8555-555555555555";
function makeRuntime(messageCount: number) {
const room = {
id: ROOM_ID,
source: "test",
type: "DM",
metadata: {},
};
const memories = Array.from({ length: messageCount }, (_, index) => ({
id: `mem-${index}`,
entityId: index % 2 === 0 ? USER_ID : AGENT_ID,
roomId: ROOM_ID,
createdAt: 1_700_000_000_000 + index * 1000,
content: {
text:
index === 0
? "remember parcel code LIME-4421"
: `conversation turn ${index}`,
},
}));
return {
agentId: AGENT_ID,
useModel: async () =>
JSON.stringify({
state: {
facts: ["parcel code LIME-4421"],
decisions: [],
pending_actions: [],
forbidden_behaviors: [],
entities: { parcel: "LIME-4421" },
},
ledger: [{ index: 1, note: "user said parcel code LIME-4421" }],
}),
countMemories: async () => messageCount,
getMemories: async () => memories,
getRoom: async () => room,
updateRoom: async (nextRoom: typeof room) => {
Object.assign(room, nextRoom);
},
_room: room,
};
}
function makePagedRuntime(messageCount: number) {
const room = {
id: ROOM_ID,
source: "test",
type: "DM",
metadata: {},
};
const memories = Array.from({ length: messageCount }, (_, index) => ({
id: `mem-${index}`,
entityId: index % 2 === 0 ? USER_ID : AGENT_ID,
roomId: ROOM_ID,
createdAt: 1_700_000_000_000 + index * 1000,
content: {
text:
index === 0
? "remember parcel code LIME-4421"
: `conversation turn ${index}`,
},
}));
return {
agentId: AGENT_ID,
useModel: async () =>
JSON.stringify({
state: {
facts: ["parcel code LIME-4421"],
decisions: [],
pending_actions: [],
forbidden_behaviors: [],
entities: { parcel: "LIME-4421" },
},
ledger: [{ index: 1, note: "user said parcel code LIME-4421" }],
}),
countMemories: async () => messageCount,
getMemories: async (params: { offset?: number; limit?: number }) => {
const offset = params.offset ?? 0;
const limit = params.limit ?? memories.length;
return memories.slice(offset, offset + limit);
},
getRoom: async () => room,
updateRoom: async (nextRoom: typeof room) => {
Object.assign(room, nextRoom);
},
_room: room,
_memories: memories,
};
}
describe("compactConversationAction", () => {
it("is unavailable for new sessions with too little history", async () => {
const runtime = makeRuntime(2);
const message = {
entityId: USER_ID,
roomId: ROOM_ID,
content: { text: "compact this conversation" },
};
await expect(
compactConversationAction.validate(runtime as never, message as never),
).resolves.toBe(false);
});
it("persists a room ledger and compaction point for existing sessions", async () => {
const runtime = makeRuntime(20);
const message = {
entityId: USER_ID,
roomId: ROOM_ID,
content: { text: "compact this conversation" },
};
const result = await compactConversationAction.handler(
runtime as never,
message as never,
undefined,
undefined,
);
expect(result?.success).toBe(true);
expect(result?.values?.compacted).toBe(true);
const metadata = runtime._room.metadata as Record<string, unknown>;
expect(metadata.lastCompactionAt).toBeTypeOf("number");
expect(metadata.compactionHistory).toHaveLength(1);
expect(
(metadata.conversationCompaction as Record<string, unknown>).priorLedger,
).toContain("LIME-4421");
});
it("compacts the oldest fetched window in very long sessions", async () => {
const runtime = makePagedRuntime(5050);
const message = {
entityId: USER_ID,
roomId: ROOM_ID,
content: { text: "compact this conversation" },
};
const result = await compactConversationAction.handler(
runtime as never,
message as never,
undefined,
undefined,
);
expect(result?.success).toBe(true);
const metadata = runtime._room.metadata as Record<string, unknown>;
const history = metadata.compactionHistory as Array<
Record<string, unknown>
>;
expect(history[0]?.loadedMessageCount).toBe(5000);
expect(history[0]?.completeMessageWindow).toBe(false);
expect(metadata.lastCompactionAt).toBe(
runtime._memories[4991].createdAt + 1,
);
});
});
@@ -0,0 +1,360 @@
/**
* The owner-only `COMPACT_CONVERSATION` Eliza action. On an explicit request it
* loads the room's message history (paged, capped, synthetic-artifact-filtered),
* finds a safe compaction boundary above the preserved tail, runs the
* hybrid-ledger compactor to roll older turns into a persisted room ledger, and
* records a compaction point plus a history entry so the pruned turns drop out
* of later prompt context. Refuses (validate returns false) until enough prior
* history exists to be worth compacting.
*/
import type {
Action,
ActionResult,
HandlerOptions,
IAgentRuntime,
Memory,
} from "@elizaos/core";
import { isSyntheticConversationArtifactMemory } from "@elizaos/core";
import {
compactors,
findSafeCompactionBoundary,
} from "../runtime/conversation-compactor.ts";
import type {
CompactorMessage,
CompactorModelCall,
} from "../runtime/conversation-compactor.types.ts";
import {
approxCountTokens,
countTranscriptTokens,
} from "../runtime/conversation-compactor.types.ts";
import {
getConversationCompactionLedger,
type StrategyName,
setConversationCompactionLedger,
} from "../runtime/conversation-compactor-runtime.ts";
const DEFAULT_TARGET_TOKENS = 1200;
const DEFAULT_PRESERVE_TAIL = 8;
const MIN_COMPACTABLE_MESSAGES = 6;
const MAX_MESSAGES_TO_READ = 5000;
const MESSAGE_FETCH_PAGE_SIZE = 250;
const STRATEGY: StrategyName = "hybrid-ledger";
export const COMPACT_CONVERSATION_ACTION_NAME = "COMPACT_CONVERSATION";
type CompactParams = {
targetTokens: unknown;
preserveTailMessages: unknown;
};
function parseOptionalBoundedInteger(
name: string,
value: unknown,
fallback: number,
min: number,
max: number,
): { ok: true; value: number } | { ok: false; reason: string } {
if (value === undefined) {
return { ok: true, value: fallback };
}
if (typeof value !== "number" || !Number.isInteger(value)) {
return {
ok: false,
reason: `${name} must be an integer between ${min} and ${max}.`,
};
}
if (value < min || value > max) {
return {
ok: false,
reason: `${name} must be between ${min} and ${max}.`,
};
}
return { ok: true, value };
}
function getText(memory: Memory): string {
return typeof memory.content.text === "string" ? memory.content.text : "";
}
function memoryToCompactorMessage(
runtime: IAgentRuntime,
memory: Memory,
): CompactorMessage | null {
const content = getText(memory).trim();
if (!content) return null;
return {
role: memory.entityId === runtime.agentId ? "assistant" : "user",
content,
...(memory.createdAt ? { timestamp: memory.createdAt } : {}),
};
}
function buildActionCompactorModelCall(
runtime: IAgentRuntime,
): CompactorModelCall {
return async ({ systemPrompt, messages, maxOutputTokens }) => {
const result = await runtime.useModel("TEXT_LARGE", {
system: systemPrompt,
prompt: messages.map((m) => m.content).join("\n"),
...(maxOutputTokens !== undefined ? { maxTokens: maxOutputTokens } : {}),
});
if (typeof result === "string") return result;
if (result && typeof result === "object") {
const record = result as Record<string, unknown>;
for (const key of ["text", "content", "reasoning", "message"]) {
const value = record[key];
if (typeof value === "string" && value.trim().length > 0) {
return value;
}
}
}
return result == null ? "" : JSON.stringify(result);
};
}
async function countRoomMessages(
runtime: IAgentRuntime,
message: Memory,
): Promise<number> {
try {
return await runtime.countMemories({
roomIds: [message.roomId],
tableName: "messages",
unique: false,
});
} catch (error) {
const reason = error instanceof Error ? error.message : String(error);
throw new Error(
`Failed to count messages for room ${String(message.roomId)}: ${reason}`,
{ cause: error },
);
}
}
async function getCompactableMessages(
runtime: IAgentRuntime,
message: Memory,
): Promise<Memory[]> {
const total = await countRoomMessages(runtime, message);
const limit = Math.max(
MESSAGE_FETCH_PAGE_SIZE,
Math.min(total || MAX_MESSAGES_TO_READ, MAX_MESSAGES_TO_READ),
);
const memories: Memory[] = [];
for (let offset = 0; offset < limit; offset += MESSAGE_FETCH_PAGE_SIZE) {
const page = await runtime.getMemories({
tableName: "messages",
roomId: message.roomId,
limit: Math.min(MESSAGE_FETCH_PAGE_SIZE, limit - offset),
offset,
unique: false,
orderBy: "createdAt",
orderDirection: "asc",
});
if (page.length === 0) break;
memories.push(...page);
if (page.length < MESSAGE_FETCH_PAGE_SIZE) break;
}
return memories
.filter(
(memory) =>
getText(memory).trim().length > 0 &&
!isSyntheticConversationArtifactMemory(memory),
)
.sort((a, b) => (a.createdAt ?? 0) - (b.createdAt ?? 0));
}
export const compactConversationAction: Action = {
name: COMPACT_CONVERSATION_ACTION_NAME,
contexts: ["general", "memory", "messaging", "agent_internal"],
roleGate: { minRole: "OWNER" },
similes: [
"COMPACT_HISTORY",
"SUMMARIZE_CONVERSATION_MEMORY",
"ROLL_UP_CONVERSATION",
"COMPRESS_CONVERSATION",
],
description:
"Compact the current existing conversation into a persisted hybrid ledger and prune old raw turns from subsequent prompt context. Use only when the user explicitly asks to compact this session/history.",
descriptionCompressed:
"owner-only manual conversation compaction for existing sessions",
suppressPostActionContinuation: true,
validate: async (runtime, message) => {
if (!message.roomId) return false;
const count = await countRoomMessages(runtime, message);
return count >= MIN_COMPACTABLE_MESSAGES;
},
handler: async (runtime, message, _state, options): Promise<ActionResult> => {
const params =
((options as HandlerOptions | undefined)?.parameters as
| Partial<CompactParams>
| undefined) ?? {};
const targetTokensResult = parseOptionalBoundedInteger(
"targetTokens",
params.targetTokens,
DEFAULT_TARGET_TOKENS,
256,
4096,
);
if (!targetTokensResult.ok) {
return {
success: false,
text: targetTokensResult.reason,
values: { compacted: false, reason: "invalid-parameters" },
data: {
actionName: "COMPACT_CONVERSATION",
reason: "invalid-parameters",
},
};
}
const preserveTailMessagesResult = parseOptionalBoundedInteger(
"preserveTailMessages",
params.preserveTailMessages,
DEFAULT_PRESERVE_TAIL,
2,
24,
);
if (!preserveTailMessagesResult.ok) {
return {
success: false,
text: preserveTailMessagesResult.reason,
values: { compacted: false, reason: "invalid-parameters" },
data: {
actionName: "COMPACT_CONVERSATION",
reason: "invalid-parameters",
},
};
}
const targetTokens = targetTokensResult.value;
const preserveTailMessages = preserveTailMessagesResult.value;
const memories = await getCompactableMessages(runtime, message);
if (memories.length < MIN_COMPACTABLE_MESSAGES) {
return {
success: false,
text: "There is not enough prior conversation to compact yet.",
values: { compacted: false, reason: "new-session" },
data: { actionName: "COMPACT_CONVERSATION", reason: "new-session" },
};
}
const messages = memories
.map((memory) => memoryToCompactorMessage(runtime, memory))
.filter((entry): entry is CompactorMessage => entry !== null);
const boundary = findSafeCompactionBoundary(messages, preserveTailMessages);
if (boundary <= 0) {
return {
success: false,
text: "Only the recent tail is available, so there is nothing safe to compact.",
values: { compacted: false, reason: "tail-only" },
data: { actionName: "COMPACT_CONVERSATION", reason: "tail-only" },
};
}
const priorLedger = await getConversationCompactionLedger(
runtime as never,
String(message.roomId),
);
const artifact = await compactors[STRATEGY].compact(
{
messages,
metadata: {
conversationKey: String(message.roomId),
...(priorLedger ? { priorLedger } : {}),
},
},
{
targetTokens,
preserveTailMessages,
callModel: buildActionCompactorModelCall(runtime),
countTokens: approxCountTokens,
},
);
const renderedLedger = artifact.stats.extra?.renderedLedger;
if (
typeof renderedLedger !== "string" ||
renderedLedger.trim().length === 0
) {
return {
success: false,
text: "Compaction did not produce a usable ledger.",
values: { compacted: false, reason: "empty-ledger" },
data: { actionName: "COMPACT_CONVERSATION", reason: "empty-ledger" },
};
}
const compactedThrough =
memories[Math.min(boundary - 1, memories.length - 1)];
const lastCompactionAt = (compactedThrough?.createdAt ?? Date.now()) + 1;
const totalRoomMessages = await countRoomMessages(runtime, message);
await setConversationCompactionLedger(
runtime as never,
String(message.roomId),
renderedLedger,
{
strategy: STRATEGY,
source: "manual-action",
lastCompactionAt,
historyEntry: {
at: new Date().toISOString(),
strategy: STRATEGY,
compactedMessageCount: boundary,
preservedTailMessages: messages.length - boundary,
loadedMessageCount: messages.length,
completeMessageWindow: memories.length >= totalRoomMessages,
originalTokens: countTranscriptTokens(
{ messages },
approxCountTokens,
),
compactedTokens: artifact.stats.compactedTokens,
},
},
);
return {
success: true,
text: `Compacted ${boundary} older message(s); preserved the latest ${messages.length - boundary}.`,
values: {
compacted: true,
compactedMessageCount: boundary,
preservedTailMessages: messages.length - boundary,
},
data: {
actionName: "COMPACT_CONVERSATION",
strategy: STRATEGY,
roomId: message.roomId,
lastCompactionAt,
stats: artifact.stats,
},
};
},
parameters: [
{
name: "targetTokens",
description:
"Optional compact ledger target token budget. Defaults to 1200.",
required: false,
schema: { type: "number" as const, minimum: 256, maximum: 4096 },
},
{
name: "preserveTailMessages",
description:
"Optional number of newest messages to keep verbatim. Defaults to 8.",
required: false,
schema: { type: "number" as const, minimum: 2, maximum: 24 },
},
],
examples: [
[
{
name: "{{name1}}",
content: { text: "Compact this conversation history now." },
},
{
name: "{{agentName}}",
content: {
text: "Compacted older messages and kept the latest turns.",
action: "COMPACT_CONVERSATION",
},
},
],
],
};
@@ -0,0 +1,145 @@
/**
* Exercises `connectAccountAction` (validate + handler): matching
* add / connect-another-account intents while rejecting lookalikes (bare
* add/connect verbs or account references without an add intent), and stamping
* the `accountConnect` provider offer — Claude subscription, OpenAI Codex, or
* both — onto the callback Content and the returned ActionResult. Deterministic:
* empty runtime/state, no OAuth flow or network.
*/
import type {
ActionResult,
Content,
HandlerCallback,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { describe, expect, it } from "vitest";
import { connectAccountAction } from "./connect-account.ts";
/** Shape of the `accountConnect` field the action stamps on Content / result. */
interface AccountConnectPayload {
providers: string[];
reason?: string;
}
/** Read the (dynamically-typed) `accountConnect` field off a Content object. */
function readAccountConnect(
content: Content | undefined,
): AccountConnectPayload | undefined {
return content?.accountConnect as AccountConnectPayload | undefined;
}
const runtime = {} as IAgentRuntime;
const state = {} as State;
function messageWith(text: string): Memory {
return { content: { text } } as unknown as Memory;
}
async function runValidate(text: string): Promise<boolean> {
return (
(await connectAccountAction.validate?.(
runtime,
messageWith(text),
state,
)) ?? false
);
}
async function runHandler(text: string): Promise<{
callbacks: Content[];
result: ActionResult;
}> {
const callbacks: Content[] = [];
const callback: HandlerCallback = async (content) => {
callbacks.push(content);
return [];
};
const result = await connectAccountAction.handler(
runtime,
messageWith(text),
state,
undefined,
callback,
);
if (!result || typeof result !== "object") {
throw new Error("CONNECT_ACCOUNT handler returned no ActionResult");
}
return { callbacks, result };
}
describe("CONNECT_ACCOUNT validate", () => {
it("matches add/connect-another-account intents", async () => {
const intents = [
"add another claude account",
"connect a second codex account",
"log into another account",
"add an account",
"sign in with a different account",
"I want to link a new anthropic account",
"hook up another openai account please",
];
for (const text of intents) {
expect(await runValidate(text)).toBe(true);
}
});
it("rejects unrelated text (no add-account intent)", async () => {
const nonIntents = [
"what's the weather today",
"add a reminder for tomorrow", // add verb, but no account anchor
"connect me to the wifi", // connect verb, but no account anchor
"delete my claude account", // account anchor, but not an add intent
"which account am I using", // account anchor, no add verb
"",
" ",
];
for (const text of nonIntents) {
expect(await runValidate(text)).toBe(false);
}
});
});
describe("CONNECT_ACCOUNT handler", () => {
it("offers only Claude when the user names claude", async () => {
const { callbacks, result } = await runHandler(
"add another claude account",
);
expect(result.success).toBe(true);
expect(readAccountConnect(callbacks.at(0))).toEqual({
providers: ["anthropic-subscription"],
reason: expect.stringContaining("Claude Subscription"),
});
// The structured field is also mirrored on the ActionResult data.
expect(
(result.data as { accountConnect?: AccountConnectPayload })
?.accountConnect?.providers,
).toEqual(["anthropic-subscription"]);
});
it("offers only Codex when the user names codex", async () => {
const { callbacks } = await runHandler("connect a second codex account");
expect(readAccountConnect(callbacks.at(0))).toEqual({
providers: ["openai-codex"],
reason: expect.any(String),
});
});
it("offers both providers when unspecified", async () => {
const { callbacks, result } = await runHandler("log into another account");
expect(readAccountConnect(callbacks.at(0))?.providers).toEqual([
"anthropic-subscription",
"openai-codex",
]);
expect(result.success).toBe(true);
});
it("returns a friendly reply telling the user to pick a provider", async () => {
const { callbacks } = await runHandler("add an account");
const cb = callbacks.at(0);
expect(typeof cb?.text).toBe("string");
expect(cb?.text).toMatch(/pick a provider/i);
expect(cb?.action).toBe("CONNECT_ACCOUNT");
});
});
@@ -0,0 +1,256 @@
/**
* CONNECT_ACCOUNT — surface the in-chat "add another account" entry point.
*
* When the user asks to add / connect / log into another AI provider account
* ("add another claude account", "connect a second codex account", "sign in
* with a different account"), this action emits an `accountConnect` structured
* field on the assistant turn. The chat renderer turns that into the
* `AccountConnectBlock` (per-provider count + "Add account") which opens the
* existing, already-audited `AddAccountDialog` OAuth / API-key flow. The action
* does NOT create or manage credentials itself — it only offers the shortcut.
*
* Provider ids are sourced from SUBSCRIPTION_PROVIDER_METADATA; only providers
* whose availability is "available" are offered. Claude → "anthropic-subscription",
* Codex → "openai-codex". When the user doesn't name a provider, both are offered.
*/
import {
SUBSCRIPTION_PROVIDER_METADATA,
type SubscriptionProvider,
} from "@elizaos/auth/types";
import type {
Action,
ActionExample,
ActionResult,
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
const CONNECT_ACCOUNT = "CONNECT_ACCOUNT";
/**
* Subscription providers that can be connected in-chat, in offer order. Only
* providers whose availability is "available" and whose add-account surface is
* an OAuth login are offered here — the two we can drive end-to-end from the
* chat block (Claude subscription + OpenAI Codex).
*/
const CONNECTABLE_PROVIDERS = [
"anthropic-subscription",
"openai-codex",
] as const satisfies readonly SubscriptionProvider[];
type ConnectableProvider = (typeof CONNECTABLE_PROVIDERS)[number];
/** Words that signal an intent to ADD/CONNECT a new account (not switch/remove). */
const ADD_INTENT_TERMS = [
"add",
"connect",
"link",
"sign in",
"sign-in",
"signin",
"log in",
"log-in",
"login",
"hook up",
"attach",
"authorize",
"authorise",
];
/** Words that anchor the intent to an account/provider login (not e.g. a contact). */
const ACCOUNT_ANCHOR_TERMS = [
"account",
"claude",
"anthropic",
"codex",
"openai",
"subscription",
"provider",
];
function normalize(text: string): string {
return text.toLowerCase();
}
/**
* True when the message expresses intent to add/connect another provider
* account. Requires both an add-style verb AND an account/provider anchor so
* unrelated "add a reminder" / "connect to the wifi" text does not match.
*/
export function messageWantsAccountConnect(text: string): boolean {
const normalized = normalize(text);
const hasAddVerb = ADD_INTENT_TERMS.some((term) => normalized.includes(term));
if (!hasAddVerb) return false;
const hasAnchor = ACCOUNT_ANCHOR_TERMS.some((term) =>
normalized.includes(term),
);
return hasAnchor;
}
/** Resolve which connectable providers the user meant from their text. */
export function resolveRequestedProviders(text: string): ConnectableProvider[] {
const normalized = normalize(text);
const mentionsClaude =
normalized.includes("claude") || normalized.includes("anthropic");
const mentionsCodex =
normalized.includes("codex") || normalized.includes("openai");
const available = CONNECTABLE_PROVIDERS.filter(
(id) => SUBSCRIPTION_PROVIDER_METADATA[id].availability === "available",
);
if (mentionsClaude && !mentionsCodex) {
return available.filter((id) => id === "anthropic-subscription");
}
if (mentionsCodex && !mentionsClaude) {
return available.filter((id) => id === "openai-codex");
}
// Unspecified (or both named) — offer every available connectable provider.
return available;
}
function providerDisplayName(id: ConnectableProvider): string {
return SUBSCRIPTION_PROVIDER_METADATA[id].displayName;
}
export const connectAccountAction: Action = {
name: CONNECT_ACCOUNT,
contexts: ["settings", "messaging"],
similes: [
"ADD_ACCOUNT",
"ADD_PROVIDER_ACCOUNT",
"CONNECT_PROVIDER_ACCOUNT",
"LINK_ACCOUNT",
"SIGN_IN_ANOTHER_ACCOUNT",
"LOG_IN_ANOTHER_ACCOUNT",
"ADD_CLAUDE_ACCOUNT",
"ADD_CODEX_ACCOUNT",
],
description:
"Offer to connect ANOTHER AI provider account (Claude / OpenAI Codex) from " +
"chat. Use when the user asks to add, connect, or sign into an additional " +
"provider account. Emits an inline entry point that opens the account-add " +
"flow; it does not create credentials directly.",
validate: async (
_runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
): Promise<boolean> => {
const text =
typeof message.content.text === "string" ? message.content.text : "";
if (!text.trim()) return false;
return messageWantsAccountConnect(text);
},
handler: async (
_runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const text =
typeof message.content.text === "string" ? message.content.text : "";
const providers = resolveRequestedProviders(text);
if (providers.length === 0) {
const errorText =
"No connectable provider accounts are available right now.";
if (callback) {
await callback({ text: errorText, action: CONNECT_ACCOUNT });
}
return {
success: false,
text: errorText,
values: { success: false, error: "NO_CONNECTABLE_PROVIDERS" },
data: {
actionName: CONNECT_ACCOUNT,
error: "NO_CONNECTABLE_PROVIDERS",
},
};
}
const names = providers.map(providerDisplayName);
const reason =
providers.length === 1
? `You asked to connect another ${names[0]} account.`
: "You asked to connect another provider account.";
const replyText =
providers.length === 1
? `Sure — pick "Add account" below to sign into another ${names[0]} account.`
: `Sure — pick a provider below (${names.join(" or ")}) to sign into another account.`;
// The `accountConnect` field on the Content is what the SSE / conversation
// serializer carries to the client, where MessageContent swaps in the
// AccountConnectBlock. Text is the fallback for surfaces that don't render
// the structured block.
if (callback) {
await callback({
text: replyText,
action: CONNECT_ACCOUNT,
accountConnect: { providers, reason },
});
}
logger.info(
{ providers },
"[CONNECT_ACCOUNT] Offered in-chat account connect",
);
return {
success: true,
text: replyText,
values: { success: true, providersStr: providers.join(",") },
data: {
actionName: CONNECT_ACCOUNT,
accountConnect: { providers, reason },
},
};
},
examples: [
[
{
name: "{{name1}}",
content: { text: "add another claude account" },
},
{
name: "{{agentName}}",
content: {
text: 'Sure — pick "Add account" below to sign into another Claude Subscription account.',
action: CONNECT_ACCOUNT,
},
},
],
[
{
name: "{{name1}}",
content: { text: "I want to connect a second codex account" },
},
{
name: "{{agentName}}",
content: {
text: 'Sure — pick "Add account" below to sign into another OpenAI Codex account.',
action: CONNECT_ACCOUNT,
},
},
],
[
{
name: "{{name1}}",
content: { text: "log into another account" },
},
{
name: "{{agentName}}",
content: {
text: "Sure — pick a provider below to sign into another account.",
action: CONNECT_ACCOUNT,
},
},
],
] as ActionExample[][],
};
@@ -0,0 +1,359 @@
/**
* Connector resolver — shared utilities for resolving contacts to platform
* handles via the Rolodex (relationships graph) and finding conversations
* with a person across all connected platforms.
*
* Used by MESSAGE and POST connector operations.
*
* @module actions/connector-resolver
*/
import type {
IAgentRuntime,
Memory,
RelationshipsGraphService,
RelationshipsPersonSummary,
Room,
Service,
UUID,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import { formatSpeakerLabel } from "../shared/conversation-format.ts";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Resolved contact with platform-specific routing information. */
export type ResolvedContact = {
person: RelationshipsPersonSummary;
/** Best platform to reach this person (based on preference + availability). */
recommendedPlatform: string | null;
/** All platforms where both the contact exists AND we have a running connector. */
reachablePlatforms: string[];
/** Map of platform → entity ID for direct addressing. */
platformEntityIds: Map<string, UUID>;
};
/** A conversation thread with a person on a specific platform. */
export type PersonConversation = {
platform: string;
roomId: UUID;
roomName: string;
messages: Memory[];
messageCount: number;
lastMessageAt: string | null;
};
/** Summary of all conversations with a person across platforms. */
export type CrossPlatformConversationView = {
person: RelationshipsPersonSummary;
conversations: PersonConversation[];
totalMessages: number;
};
// ---------------------------------------------------------------------------
// Service access
// ---------------------------------------------------------------------------
export async function getGraphService(
runtime: IAgentRuntime,
): Promise<RelationshipsGraphService | null> {
return runtime.getService<Service & RelationshipsGraphService>(
"relationships",
);
}
/** Returns the set of connector source names that have active send handlers. */
export function getActiveConnectors(runtime: IAgentRuntime): Set<string> {
const active = new Set<string>();
for (const connector of runtime.getMessageConnectors()) {
if (typeof connector.source === "string" && connector.source.trim()) {
active.add(connector.source);
}
}
return active;
}
// ---------------------------------------------------------------------------
// Contact resolution
// ---------------------------------------------------------------------------
/**
* Resolve a person name to a contact with platform routing info.
*
* Uses the Rolodex (relationships graph) for lookup — no regex, no string
* matching. The Rolodex handles fuzzy name/alias matching internally.
*
* @returns null if no match found, or a ResolvedContact with the best
* person match and their reachable platforms.
*/
export async function resolveContact(
runtime: IAgentRuntime,
name: string,
preferredPlatform?: string,
): Promise<ResolvedContact | null> {
const graphService = await getGraphService(runtime);
if (!graphService) {
logger.warn("[connector-resolver] Relationships service not available");
return null;
}
const snapshot = await graphService.getGraphSnapshot({
search: name.trim(),
limit: 5,
});
if (!snapshot || snapshot.people.length === 0) {
return null;
}
// Take the best match (first result — the graph service ranks by relevance)
const person = snapshot.people[0];
if (!person) {
return null;
}
const activeConnectors = getActiveConnectors(runtime);
// Build a map of platform → entity ID for each reachable identity
const platformEntityIds = new Map<string, UUID>();
for (const identity of person.identities) {
for (const handle of identity.handles) {
if (activeConnectors.has(handle.platform)) {
// Use the identity's entity ID for routing
if (!platformEntityIds.has(handle.platform)) {
platformEntityIds.set(handle.platform, identity.entityId as UUID);
}
}
}
}
const reachablePlatforms = [...platformEntityIds.keys()];
// Pick recommended platform
let recommendedPlatform: string | null = null;
if (preferredPlatform && reachablePlatforms.includes(preferredPlatform)) {
recommendedPlatform = preferredPlatform;
} else if (
person.preferredCommunicationChannel &&
reachablePlatforms.includes(person.preferredCommunicationChannel)
) {
recommendedPlatform = person.preferredCommunicationChannel;
} else if (reachablePlatforms.length > 0) {
// Pick the first reachable platform (the graph service orders by interaction recency)
const fallbackPlatform = reachablePlatforms[0];
if (fallbackPlatform) {
recommendedPlatform = fallbackPlatform;
}
}
return {
person,
recommendedPlatform,
reachablePlatforms,
platformEntityIds,
};
}
/**
* Resolve a person name and return ALL matching contacts (for disambiguation).
*/
export async function resolveContactCandidates(
runtime: IAgentRuntime,
name: string,
limit = 5,
): Promise<RelationshipsPersonSummary[]> {
const graphService = await getGraphService(runtime);
if (!graphService) return [];
const snapshot = await graphService.getGraphSnapshot({
search: name.trim(),
limit,
});
return snapshot.people;
}
// ---------------------------------------------------------------------------
// Cross-platform conversation view
// ---------------------------------------------------------------------------
/**
* Get all conversations with a specific person across all platforms.
*
* This is the "conversation handoff" view — shows recent messages from
* every platform where the agent has interacted with this person, so
* the user can see full context before deciding where to respond.
*/
export async function getPersonConversations(
runtime: IAgentRuntime,
person: RelationshipsPersonSummary,
opts?: { limitPerPlatform?: number },
): Promise<CrossPlatformConversationView> {
const limitPerPlatform = opts?.limitPerPlatform ?? 15;
const conversations: PersonConversation[] = [];
let totalMessages = 0;
// Collect all entity IDs for this person (across all platforms)
const entityIds = new Set<string>();
entityIds.add(person.primaryEntityId);
for (const id of person.memberEntityIds) {
entityIds.add(id);
}
// Find all rooms where this person participates
const seenRooms = new Set<string>();
for (const entityId of entityIds) {
try {
const roomIds = await runtime.getRoomsForParticipant(entityId as UUID);
for (const roomId of roomIds) {
if (seenRooms.has(roomId)) continue;
seenRooms.add(roomId);
const room = await runtime.getRoom(roomId);
if (!room) continue;
const platform = room.source;
// Get recent messages from this room
const rawMemories = (await runtime.getMemories({
tableName: "messages",
roomId: room.id,
limit: limitPerPlatform,
orderBy: "createdAt" as const,
orderDirection: "desc" as const,
} as Parameters<typeof runtime.getMemories>[0])) as Memory[];
if (rawMemories.length === 0) continue;
// Reverse for chronological order
const messages = rawMemories.reverse();
const lastMsg = messages[messages.length - 1];
const lastMessageAt = lastMsg?.createdAt
? new Date(lastMsg.createdAt).toISOString()
: null;
conversations.push({
platform,
roomId: room.id as UUID,
roomName: room.name ?? `Room ${room.id.slice(0, 8)}`,
messages,
messageCount: messages.length,
lastMessageAt,
});
totalMessages += messages.length;
}
} catch (err) {
logger.debug(
`[connector-resolver] Error fetching rooms for entity ${entityId}:`,
err instanceof Error ? err.message : String(err),
);
}
}
// Sort conversations by most recent activity first
conversations.sort((a, b) => {
if (!a.lastMessageAt && !b.lastMessageAt) return 0;
if (!a.lastMessageAt) return 1;
if (!b.lastMessageAt) return -1;
return b.lastMessageAt.localeCompare(a.lastMessageAt);
});
return { person, conversations, totalMessages };
}
// ---------------------------------------------------------------------------
// Formatting helpers
// ---------------------------------------------------------------------------
/** Format a cross-platform conversation view as human-readable text. */
export function formatConversationView(
runtime: IAgentRuntime,
view: CrossPlatformConversationView,
): string {
const sections: string[] = [];
sections.push(
`Conversations with ${view.person.displayName}` +
`${view.conversations.length} thread(s), ` +
`${view.totalMessages} messages across ` +
`${[...new Set(view.conversations.map((c) => c.platform))].join(", ")}`,
);
sections.push("═".repeat(60));
for (const convo of view.conversations) {
sections.push(
`\n▸ [${convo.platform}] ${convo.roomName}` +
(convo.lastMessageAt
? ` — last activity: ${convo.lastMessageAt.slice(0, 19)}`
: ""),
);
sections.push("─".repeat(40));
for (const [index, mem] of convo.messages.entries()) {
if (!mem) {
continue;
}
const speaker = formatSpeakerLabel(runtime, mem);
const ts = mem.createdAt
? new Date(mem.createdAt).toISOString().slice(0, 19)
: "";
const text = (mem.content.text ?? "").slice(0, 500);
sections.push(
`${String(index + 1).padStart(3, " ")} | ${ts} ${speaker}: ${text}`,
);
}
}
return sections.join("\n");
}
/**
* Format a list of resolved contacts for disambiguation display.
*/
export function formatContactCandidates(
candidates: RelationshipsPersonSummary[],
): string {
const lines: string[] = [];
for (const [index, p] of candidates.entries()) {
if (!p) {
continue;
}
const platforms = p.platforms.join(", ") || "no platforms";
const aliases =
p.aliases.length > 0 ? ` (aka ${p.aliases.slice(0, 2).join(", ")})` : "";
lines.push(
`${index + 1}. ${p.displayName}${aliases}${platforms} — entityId: ${p.primaryEntityId}`,
);
}
return lines.join("\n");
}
/**
* Find the room ID for a DM/conversation between the agent and an entity
* on a specific platform.
*/
export async function findRoomForEntity(
runtime: IAgentRuntime,
entityId: UUID,
platform: string,
): Promise<Room | null> {
try {
const roomIds = await runtime.getRoomsForParticipant(entityId);
for (const roomId of roomIds) {
const room = await runtime.getRoom(roomId);
if (!room) continue;
if (room.source.toLowerCase() === platform.toLowerCase()) {
return room;
}
}
} catch {
// Entity may not be in any rooms yet
}
return null;
}
@@ -0,0 +1,216 @@
/**
* CONTACT link/merge owner-authority guard: an ADMIN-rank sender must not be
* able to create a confirmed identity link that touches an owner-equivalent
* entity (configured canonical owner, deterministic fallback admin entity, or
* the message world's owner) — that link would make role resolution treat them
* as OWNER everywhere, bypassing canModifyRole's "ADMIN never grants OWNER"
* rule. Authorization resolves through the REAL core role machinery against a
* deterministic runtime stand-in; only the relationships graph service is a
* recording stub.
*/
import type { IAgentRuntime, Memory, UUID } from "@elizaos/core";
import { stringToUuid } from "@elizaos/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { contactAction } from "./contact.ts";
const AGENT_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" as UUID;
const OWNER_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" as UUID;
const ADMIN_ID = "cccccccc-cccc-cccc-cccc-cccccccccccc" as UUID;
const OTHER_A = "11111111-1111-1111-1111-111111111111" as UUID;
const OTHER_B = "22222222-2222-2222-2222-222222222222" as UUID;
const ROOM_ID = "dddddddd-dddd-dddd-dddd-dddddddddddd" as UUID;
const CANDIDATE_ID = "99999999-9999-9999-9999-999999999999" as UUID;
type GraphStub = {
getGraphSnapshot: ReturnType<typeof vi.fn>;
getPersonDetail: ReturnType<typeof vi.fn>;
getCandidateMerges: ReturnType<typeof vi.fn>;
acceptMerge: ReturnType<typeof vi.fn>;
rejectMerge: ReturnType<typeof vi.fn>;
proposeMerge: ReturnType<typeof vi.fn>;
};
function makeGraphStub(candidatePair?: {
entityA: UUID;
entityB: UUID;
}): GraphStub {
return {
getGraphSnapshot: vi.fn(async () => ({ people: [] })),
getPersonDetail: vi.fn(async () => null),
getCandidateMerges: vi.fn(async () =>
candidatePair
? [
{
id: CANDIDATE_ID,
entityA: candidatePair.entityA,
entityB: candidatePair.entityB,
confidence: 1,
evidence: {},
status: "pending" as const,
proposedAt: new Date().toISOString(),
},
]
: [],
),
acceptMerge: vi.fn(async () => undefined),
rejectMerge: vi.fn(async () => undefined),
proposeMerge: vi.fn(async () => CANDIDATE_ID),
};
}
function makeRuntime(graph: GraphStub): IAgentRuntime {
const runtime = {
agentId: AGENT_ID,
character: { name: "Eliza" },
getSetting: (key: string) =>
key === "ELIZA_ADMIN_ENTITY_ID" ? OWNER_ID : undefined,
getService: (type: string) => (type === "relationships" ? graph : null),
getRoom: async () => null,
getEntityById: async () => null,
getRelationships: async () => [],
reportError: vi.fn(),
registerSearchCategory: () => undefined,
};
return runtime as unknown as IAgentRuntime;
}
function makeMessage(entityId: UUID, text = "link them"): Memory {
return {
id: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" as UUID,
entityId,
roomId: ROOM_ID,
content: { text, source: "client_chat" },
} as Memory;
}
async function runContact(
runtime: IAgentRuntime,
message: Memory,
parameters: Record<string, unknown>,
) {
const result = await contactAction.handler(runtime, message, undefined, {
parameters,
} as never);
if (!result) {
throw new Error("handler returned no result");
}
return result;
}
describe("CONTACT link — owner identity-link guard", () => {
let graph: GraphStub;
let runtime: IAgentRuntime;
beforeEach(() => {
graph = makeGraphStub();
runtime = makeRuntime(graph);
});
it("denies a non-owner sender linking their entity to the configured owner", async () => {
const result = await runContact(runtime, makeMessage(ADMIN_ID), {
action: "link",
entityA: ADMIN_ID,
entityB: OWNER_ID,
confirmation: true,
});
expect(result.success).toBe(false);
expect(result.values).toMatchObject({ error: "OWNER_LINK_FORBIDDEN" });
expect(graph.proposeMerge).not.toHaveBeenCalled();
expect(graph.acceptMerge).not.toHaveBeenCalled();
});
it("denies a non-owner sender linking to the deterministic fallback admin entity", async () => {
const fallbackOwner = stringToUuid("Eliza-admin-entity") as UUID;
const result = await runContact(runtime, makeMessage(ADMIN_ID), {
action: "link",
entityA: fallbackOwner,
entityB: ADMIN_ID,
confirmation: true,
});
expect(result.success).toBe(false);
expect(result.values).toMatchObject({ error: "OWNER_LINK_FORBIDDEN" });
expect(graph.proposeMerge).not.toHaveBeenCalled();
});
it("allows the canonical owner to link their own connector entity", async () => {
const result = await runContact(runtime, makeMessage(OWNER_ID), {
action: "link",
entityA: OWNER_ID,
entityB: OTHER_A,
confirmation: true,
});
expect(result.success).toBe(true);
expect(graph.proposeMerge).toHaveBeenCalledWith(
OWNER_ID,
OTHER_A,
expect.anything(),
);
expect(graph.acceptMerge).toHaveBeenCalledWith(CANDIDATE_ID);
});
it("allows a non-owner sender to link two unrelated entities", async () => {
const result = await runContact(runtime, makeMessage(ADMIN_ID), {
action: "link",
entityA: OTHER_A,
entityB: OTHER_B,
confirmation: true,
});
expect(result.success).toBe(true);
expect(graph.proposeMerge).toHaveBeenCalledWith(
OTHER_A,
OTHER_B,
expect.anything(),
);
});
});
describe("CONTACT merge — owner identity-link guard", () => {
it("denies a non-owner sender accepting a merge candidate that touches the owner", async () => {
const graph = makeGraphStub({ entityA: ADMIN_ID, entityB: OWNER_ID });
const runtime = makeRuntime(graph);
const result = await runContact(runtime, makeMessage(ADMIN_ID), {
op: "merge",
candidateId: CANDIDATE_ID,
action: "accept",
});
expect(result.success).toBe(false);
expect(result.values).toMatchObject({ error: "OWNER_LINK_FORBIDDEN" });
expect(graph.acceptMerge).not.toHaveBeenCalled();
});
it("allows a non-owner sender to accept a merge between unrelated entities", async () => {
const graph = makeGraphStub({ entityA: OTHER_A, entityB: OTHER_B });
const runtime = makeRuntime(graph);
const result = await runContact(runtime, makeMessage(ADMIN_ID), {
op: "merge",
candidateId: CANDIDATE_ID,
action: "accept",
});
expect(result.success).toBe(true);
expect(graph.acceptMerge).toHaveBeenCalledWith(CANDIDATE_ID);
});
it("allows the owner to accept a merge candidate that touches the owner", async () => {
const graph = makeGraphStub({ entityA: ADMIN_ID, entityB: OWNER_ID });
const runtime = makeRuntime(graph);
const result = await runContact(runtime, makeMessage(OWNER_ID), {
op: "merge",
candidateId: CANDIDATE_ID,
action: "accept",
});
expect(result.success).toBe(true);
expect(graph.acceptMerge).toHaveBeenCalledWith(CANDIDATE_ID);
});
it("rejecting a candidate needs no owner authority", async () => {
const graph = makeGraphStub({ entityA: ADMIN_ID, entityB: OWNER_ID });
const runtime = makeRuntime(graph);
const result = await runContact(runtime, makeMessage(ADMIN_ID), {
op: "merge",
candidateId: CANDIDATE_ID,
action: "reject",
});
expect(result.success).toBe(true);
expect(graph.rejectMerge).toHaveBeenCalledWith(CANDIDATE_ID);
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,287 @@
/**
* Central lexicon mapping each context-signal key (affirmative, negative, the
* lifeops_* family, calendar, gmail, web_search, send_message, and so on) to
* its localized strong/weak keyword terms and a per-signal context-window limit.
* `resolveContextSignalSpec` / `getContextSignalTerms` resolve a signal to
* concrete terms for the requested character locale, drawing the raw phrase
* lists from `@elizaos/shared`'s validation-keyword registry. Consumed by the
* providers and action validators that decide when to widen or narrow the
* context pulled into a prompt.
*/
import {
type CharacterLanguage,
getValidationKeywordTerms,
normalizeCharacterLanguage,
} from "@elizaos/shared";
export type ContextSignalKey =
| "affirmative"
| "calendar"
| "draft_edit"
| "gmail"
| "link_entity"
| "lifeops"
| "lifeops_cadence"
| "lifeops_complete"
| "lifeops_delete"
| "lifeops_escalation"
| "lifeops_goal"
| "lifeops_overview"
| "lifeops_phone"
| "lifeops_reminder_pref"
| "lifeops_review"
| "lifeops_skip"
| "lifeops_snooze"
| "lifeops_update"
| "negative"
| "read_channel"
| "read_messages"
| "search_conversations"
| "search_entity"
| "send_message"
| "stream_control"
| "temporal_followup"
| "temporal_next"
| "web_search";
export type ContextSignalStrength = "strong" | "weak";
type ContextSignalSpec = {
contextLimit?: number;
keywordKeys: {
strong: string;
weak?: string;
};
};
export type ResolvedContextSignalSpec = {
locale: CharacterLanguage;
contextLimit: number;
strongTerms: string[];
weakTerms: string[];
};
const DEFAULT_CONTEXT_LIMIT = 8;
const CONTEXT_SIGNAL_SPECS: Record<ContextSignalKey, ContextSignalSpec> = {
affirmative: {
contextLimit: 4,
keywordKeys: {
strong: "contextSignal.affirmative.strong",
},
},
draft_edit: {
contextLimit: 4,
keywordKeys: {
strong: "contextSignal.draft_edit.strong",
},
},
negative: {
contextLimit: 4,
keywordKeys: {
strong: "contextSignal.negative.strong",
},
},
temporal_followup: {
contextLimit: 6,
keywordKeys: {
strong: "contextSignal.temporal_followup.strong",
},
},
temporal_next: {
contextLimit: 6,
keywordKeys: {
strong: "contextSignal.temporal_next.strong",
},
},
gmail: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.gmail.strong",
weak: "contextSignal.gmail.weak",
},
},
lifeops: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops.strong",
weak: "contextSignal.lifeops.weak",
},
},
lifeops_cadence: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_cadence.strong",
},
},
lifeops_complete: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_complete.strong",
},
},
lifeops_delete: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_delete.strong",
},
},
lifeops_overview: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_overview.strong",
},
},
lifeops_reminder_pref: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_reminder_pref.strong",
},
},
lifeops_skip: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_skip.strong",
},
},
lifeops_snooze: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_snooze.strong",
},
},
lifeops_escalation: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_escalation.strong",
},
},
lifeops_goal: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_goal.strong",
},
},
lifeops_phone: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_phone.strong",
},
},
lifeops_review: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_review.strong",
},
},
lifeops_update: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.lifeops_update.strong",
},
},
link_entity: {
contextLimit: 8,
keywordKeys: {
strong: "contextSignal.link_entity.strong",
},
},
calendar: {
contextLimit: 12,
keywordKeys: {
strong: "contextSignal.calendar.strong",
weak: "contextSignal.calendar.weak",
},
},
web_search: {
contextLimit: 6,
keywordKeys: {
strong: "contextSignal.web_search.strong",
weak: "contextSignal.web_search.weak",
},
},
send_message: {
keywordKeys: {
strong: "contextSignal.send_message.strong",
weak: "contextSignal.send_message.weak",
},
},
search_conversations: {
keywordKeys: {
strong: "contextSignal.search_conversations.strong",
weak: "contextSignal.search_conversations.weak",
},
},
read_channel: {
keywordKeys: {
strong: "contextSignal.read_channel.strong",
weak: "contextSignal.read_channel.weak",
},
},
read_messages: {
keywordKeys: {
strong: "contextSignal.read_messages.strong",
weak: "contextSignal.read_messages.weak",
},
},
stream_control: {
keywordKeys: {
strong: "contextSignal.stream_control.strong",
weak: "contextSignal.stream_control.weak",
},
},
search_entity: {
keywordKeys: {
strong: "contextSignal.search_entity.strong",
weak: "contextSignal.search_entity.weak",
},
},
};
export function resolveContextSignalSpec(
key: ContextSignalKey,
localeInput?: unknown,
options?: {
includeAllLocales?: boolean;
},
): ResolvedContextSignalSpec {
const locale = normalizeCharacterLanguage(localeInput);
const spec = CONTEXT_SIGNAL_SPECS[key];
const includeAllLocales = options?.includeAllLocales ?? false;
return {
locale,
contextLimit: spec.contextLimit ?? DEFAULT_CONTEXT_LIMIT,
strongTerms: getValidationKeywordTerms(spec.keywordKeys.strong, {
includeAllLocales,
locale,
}),
weakTerms: spec.keywordKeys.weak
? getValidationKeywordTerms(spec.keywordKeys.weak, {
includeAllLocales,
locale,
})
: [],
};
}
export function getContextSignalTerms(
key: ContextSignalKey,
strength: ContextSignalStrength,
options?: {
includeAllLocales?: boolean;
locale?: unknown;
},
): string[] {
const spec = CONTEXT_SIGNAL_SPECS[key];
const keywordKey =
strength === "strong" ? spec.keywordKeys.strong : spec.keywordKeys.weak;
if (!keywordKey) {
return [];
}
return getValidationKeywordTerms(keywordKey, {
includeAllLocales: options?.includeAllLocales ?? false,
locale: options?.locale,
});
}
@@ -0,0 +1,295 @@
/**
* Shared context-signal validation helpers for action `validate()` functions.
*
* Actions that are only relevant when the recent conversation mentions certain
* keywords can use these helpers to avoid bloating the LLM action context on
* every turn.
*
* A single keyword match (strong or weak) is always enough to activate.
* False positives are cheap (extra action in the LLM menu); false negatives
* are expensive (action unavailable when needed).
*
* @module actions/context-signal
*/
import {
type AgentContext,
getActiveRoutingContextsForTurn,
type Memory,
type State,
} from "@elizaos/core";
import {
collectKeywordTermMatches,
textIncludesKeywordTerm,
} from "@elizaos/shared";
import {
type ContextSignalKey,
getContextSignalTerms,
resolveContextSignalSpec,
} from "./context-signal-lexicon.ts";
import {
recentConversationTexts as collectRecentConversationTexts,
recentConversationTextsFromState,
} from "./recent-conversation-texts.ts";
export { collectKeywordTermMatches, textIncludesKeywordTerm };
type ContextSignalRuntimeLike = {
getSetting?: (key: string) => unknown;
character?: unknown;
};
function resolveContextSignalLocale(
runtime: ContextSignalRuntimeLike | null,
state: State | undefined,
localeOverride?: unknown,
): string | undefined {
if (typeof localeOverride === "string" && localeOverride.trim().length > 0) {
return localeOverride;
}
const stateRecord =
state && typeof state === "object"
? (state as Record<string, unknown>)
: undefined;
const values =
stateRecord?.values && typeof stateRecord.values === "object"
? (stateRecord.values as Record<string, unknown>)
: undefined;
const config =
stateRecord?.config && typeof stateRecord.config === "object"
? (stateRecord.config as Record<string, unknown>)
: undefined;
const ui =
config?.ui && typeof config.ui === "object"
? (config.ui as Record<string, unknown>)
: undefined;
const runtimeCharacter =
runtime?.character && typeof runtime.character === "object"
? (runtime.character as Record<string, unknown>)
: undefined;
const runtimeSettings =
runtimeCharacter?.settings && typeof runtimeCharacter.settings === "object"
? (runtimeCharacter.settings as Record<string, unknown>)
: undefined;
const runtimeUi =
runtimeSettings?.ui && typeof runtimeSettings.ui === "object"
? (runtimeSettings.ui as Record<string, unknown>)
: undefined;
return [
values?.preferredLanguage,
values?.language,
stateRecord?.preferredLanguage,
ui?.language,
runtimeUi?.language,
runtimeSettings?.language,
runtime?.getSetting?.("preferredLanguage"),
runtime?.getSetting?.("language"),
runtime?.getSetting?.("ui.language"),
].find(
(candidate): candidate is string =>
typeof candidate === "string" && candidate.trim().length > 0,
);
}
export function collectKeywordTermMatchesForKey(
texts: string[],
key: ContextSignalKey,
options?: {
includeAllLocales?: boolean;
locale?: unknown;
strength?: "strong" | "weak";
},
): Set<string> {
const strength = options?.strength ?? "strong";
return collectKeywordTermMatches(
texts,
getContextSignalTerms(key, strength, options),
);
}
// ── Public API ───────────────────────────────────────────────────────────
export function messageText(message: Memory): string {
const content = message.content;
if (!content) return "";
if (typeof content === "string") return content;
return typeof content.text === "string" ? content.text : "";
}
/**
* Fast synchronous signal check using only `state` (no DB round-trip).
* Returns true if ANY strong or weak term matches in the current message
* or recent conversation.
*/
export function hasContextSignalSync(
message: Memory,
state: State | undefined,
strongTerms: readonly string[],
weakTerms: readonly string[] = [],
contextLimit = 8,
): boolean {
const texts = [
...recentConversationTextsFromState(state, contextLimit),
messageText(message).trim(),
].filter((t) => t.length > 0);
if (texts.length === 0) return false;
if (
strongTerms.length > 0 &&
collectKeywordTermMatches(texts, strongTerms).size > 0
) {
return true;
}
if (
weakTerms.length > 0 &&
collectKeywordTermMatches(texts, weakTerms).size > 0
) {
return true;
}
return false;
}
export function hasContextSignalSyncForKey(
message: Memory,
state: State | undefined,
key: ContextSignalKey,
options?: {
contextLimit?: number;
includeAllLocales?: boolean;
locale?: unknown;
},
): boolean {
const locale = resolveContextSignalLocale(null, state, options?.locale);
const spec = resolveContextSignalSpec(key, locale, {
includeAllLocales: options?.includeAllLocales ?? true,
});
return hasContextSignalSync(
message,
state,
spec.strongTerms,
spec.weakTerms,
options?.contextLimit ?? spec.contextLimit,
);
}
export function hasSelectedActionContext(
message: Memory,
state: State | undefined,
actionContexts: readonly AgentContext[],
): boolean {
const actionContextIds = actionContexts
.map((context) => `${context}`.toLowerCase())
.filter((context) => context !== "general" && !context.startsWith("page"));
if (actionContextIds.length === 0) {
return false;
}
const activeContexts = new Set(
getActiveRoutingContextsForTurn(state, message)
.map((context) => `${context}`.toLowerCase())
.filter(
(context) => context !== "general" && !context.startsWith("page"),
),
);
return actionContextIds.some((context) => activeContexts.has(context));
}
export function hasSelectedContextOrSignalSync(
message: Memory,
state: State | undefined,
actionContexts: readonly AgentContext[],
strongTerms: readonly string[],
weakTerms: readonly string[] = [],
contextLimit = 8,
): boolean {
if (hasSelectedActionContext(message, state, actionContexts)) {
return true;
}
return hasContextSignalSync(
message,
state,
strongTerms,
weakTerms,
contextLimit,
);
}
/**
* Full async signal check with DB memory fallback.
* Returns true if ANY strong or weak term matches.
*/
export async function hasContextSignal(
runtime: Parameters<typeof collectRecentConversationTexts>[0]["runtime"],
message: Memory,
state: State | undefined,
strongTerms: readonly string[],
weakTerms: readonly string[] = [],
contextLimit = 8,
): Promise<boolean> {
const stateTexts = recentConversationTextsFromState(state, contextLimit);
let texts: string[];
if (stateTexts.length >= contextLimit) {
texts = stateTexts;
} else {
texts = await collectRecentConversationTexts({
runtime,
message,
state,
limit: contextLimit,
});
}
texts = [...texts, messageText(message).trim()].filter((t) => t.length > 0);
if (texts.length === 0) return false;
if (
strongTerms.length > 0 &&
collectKeywordTermMatches(texts, strongTerms).size > 0
) {
return true;
}
if (
weakTerms.length > 0 &&
collectKeywordTermMatches(texts, weakTerms).size > 0
) {
return true;
}
return false;
}
export async function hasContextSignalForKey(
runtime: Parameters<typeof collectRecentConversationTexts>[0]["runtime"],
message: Memory,
state: State | undefined,
key: ContextSignalKey,
options?: {
contextLimit?: number;
includeAllLocales?: boolean;
locale?: unknown;
},
): Promise<boolean> {
const locale = resolveContextSignalLocale(
runtime as ContextSignalRuntimeLike,
state,
options?.locale,
);
const spec = resolveContextSignalSpec(key, locale, {
includeAllLocales: options?.includeAllLocales ?? true,
});
return hasContextSignal(
runtime,
message,
state,
spec.strongTerms,
spec.weakTerms,
options?.contextLimit ?? spec.contextLimit,
);
}
@@ -0,0 +1,59 @@
/**
* Regression coverage for the prompt-reachable DATABASE action read-only guard.
* These tests keep model-shaped SQL inputs on the same linear sanitizer path as
* the dashboard API guard without importing the full action runtime graph.
*/
import { describe, expect, it } from "vitest";
import { checkReadOnly } from "../security/sql-readonly-guard.ts";
describe("DATABASE action read-only guard", () => {
it("collapses block-comment-split mutation keywords", () => {
expect(checkReadOnly("DE/* */LETE FROM memories")).toMatchObject({
ok: false,
reason:
'"DELETE" is a mutation keyword. Set allowWrites:true to execute mutations.',
});
});
it("ignores mutation keywords inside closed dollar-quoted strings", () => {
expect(checkReadOnly("SELECT $$DELETE FROM memories$$")).toEqual({
ok: true,
});
});
it("blocks dangerous functions outside dollar-quoted strings", () => {
expect(checkReadOnly("SELECT pg_sleep(10)")).toMatchObject({
ok: false,
reason:
'"PG_SLEEP" is a dangerous function. Set allowWrites:true to execute.',
});
});
it("blocks unicode-escaped identifiers that can hide dangerous functions", () => {
expect(checkReadOnly("SELECT U&\"s\\0065tval\"('seq', 2)")).toMatchObject({
ok: false,
reason:
'Unicode-escaped identifiers (U&"...") are not allowed in read-only mode: they can hide a dangerous function name from the guard.',
});
});
it("stays fast on unterminated block comments from model-generated SQL", () => {
const sql = `SELECT 1 /*${" /*".repeat(200_000)}`;
const start = performance.now();
const result = checkReadOnly(sql);
const elapsed = performance.now() - start;
expect(result).toEqual({ ok: true });
expect(elapsed).toBeLessThan(1000);
});
it("stays fast on unterminated dollar quotes from model-generated SQL", () => {
const sql = `SELECT $tag$${" $tag2$".repeat(200_000)}`;
const start = performance.now();
const result = checkReadOnly(sql);
const elapsed = performance.now() - start;
expect(result).toEqual({ ok: true });
expect(elapsed).toBeLessThan(1000);
});
});
@@ -0,0 +1,66 @@
/**
* Covers the DATABASE action's read-only SQL guard on adversarial input. The
* guard receives model-produced SQL, so comment and dollar-quote stripping must
* be linear and must leave malformed SQL visible to the mutation scanner.
*/
import { describe, expect, it } from "vitest";
import { checkReadOnly } from "../security/sql-readonly-guard.ts";
describe("DATABASE read-only SQL guard", () => {
it("rejects mutation keywords split by block comments", () => {
const result = checkReadOnly("DE/* invisible */LETE FROM memories");
expect(result).toEqual({
ok: false,
reason:
'"DELETE" is a mutation keyword. Set allowWrites:true to execute mutations.',
});
});
it("rejects mutation keywords in unterminated dollar-quoted input quickly", () => {
const sql = `${Array.from({ length: 50_000 }, (_, i) => `$tag${i}$x`).join(
"",
)} DELETE FROM memories`;
const start = performance.now();
const result = checkReadOnly(sql);
const elapsed = performance.now() - start;
expect(result).toMatchObject({ ok: false });
if (!result.ok) {
expect(result.reason).toContain('"DELETE"');
}
expect(elapsed).toBeLessThan(1000);
});
it("blocks dangerous functions hidden behind unicode-escaped identifiers", () => {
// `U&"s\0065tval"` decodes to setval() at PG parse time — a sequence write
// that a literal-name scan of the raw text misses. Confirmed executable in
// PGlite before this guard. Every dangerous function is reachable this way.
for (const sql of [
`SELECT U&"s\\0065tval"('s', 999)`,
`SELECT U&"pg_sl\\0065ep"(0.15)`,
`SELECT U&"pg_wr\\0069te_file"('/tmp/x', 'data')`,
`SELECT u&"lo_exp\\006Frt"(1, '/tmp/x')`, // lower-case u& is valid too
]) {
const result = checkReadOnly(sql);
expect(result, sql).toMatchObject({ ok: false });
if (!result.ok) {
expect(result.reason).toContain("Unicode-escaped identifiers");
}
}
});
it("still allows ordinary read-only queries (no false positives)", () => {
// A stray `U&` that is not the unicode-escape identifier syntax (space
// before the quote, or inside a string literal) must not trip the guard.
for (const sql of [
"SELECT id, name FROM users WHERE active = true LIMIT 10",
`SELECT "user" AS u FROM accounts`, // plain quoted identifier
`SELECT 'contains U&\\" text' AS note`, // U&" inside a string literal
]) {
expect(checkReadOnly(sql), sql).toEqual({ ok: true });
}
});
});
@@ -0,0 +1,74 @@
/**
* Covers the DATABASE action's `search_vectors` op: it must embed the text query
* and pass only the resulting vector (never the raw query) into `searchMemories`.
* Deterministic — runtime, embedding model, and memory search are vi.fn stubs.
*/
import type { ActionResult, IAgentRuntime, Memory } from "@elizaos/core";
import { ModelType } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { databaseAction } from "./database";
function createRuntime() {
const searchMemories = vi.fn().mockResolvedValue([
{
id: "memory-1",
content: { text: "I bought a new car" },
roomId: "room-1",
entityId: "entity-1",
createdAt: 123,
similarity: 0.91,
},
]);
const useModel = vi.fn().mockImplementation((model: unknown) => {
if (model === ModelType.TEXT_EMBEDDING) return [0.1, 0.2, 0.3];
throw new Error(`unexpected model ${model}`);
});
const runtime = {
useModel,
searchMemories,
registerSearchCategory: vi.fn(),
adapter: {},
} as unknown as IAgentRuntime;
return { runtime, searchMemories, useModel };
}
describe("DATABASE search_vectors", () => {
it("does not pass the text query into vector memory search", async () => {
const { runtime, searchMemories, useModel } = createRuntime();
const result = (await databaseAction.handler(
runtime,
{} as Memory,
undefined,
{
parameters: {
action: "search_vectors",
query: "automobile purchase",
table: "memories",
limit: 7,
threshold: 0.4,
},
},
)) as ActionResult;
expect(useModel).toHaveBeenCalledWith(ModelType.TEXT_EMBEDDING, {
text: "automobile purchase",
});
expect(searchMemories).toHaveBeenCalledWith({
embedding: [0.1, 0.2, 0.3],
tableName: "memories",
limit: 7,
match_threshold: 0.4,
});
expect(searchMemories.mock.calls[0]?.[0]).not.toHaveProperty("query");
expect(result.success).toBe(true);
expect(result.data).toMatchObject({
op: "search_vectors",
query: "automobile purchase",
table: "memories",
});
});
});
+739
View File
@@ -0,0 +1,739 @@
/**
* Polymorphic DATABASE action.
*
* Single action that dispatches across the database introspection ops:
* - list_tables : enumerate user tables with row counts and columns.
* - get_table : page rows from a specific table (limit/offset/sort).
* - query : execute raw SQL (read-only by default).
* - search_vectors : embed text and return top-k semantic memory matches.
*
* All ops talk directly to the in-process runtime adapter (Drizzle ORM via
* `runtime.adapter.db`) and `runtime.searchMemories` / `runtime.useModel`.
* No HTTP. The previous LIST_DATABASE_TABLES / GET_TABLE_DATA /
* EXECUTE_DATABASE_QUERY / SEARCH_VECTORS actions hit /api/database/* — that
* server route layer is still used by the dashboard UI but is now bypassed
* for the agent's own tool surface.
*/
import type {
Action,
ActionResult,
HandlerOptions,
IAgentRuntime,
Memory,
SearchCategoryRegistration,
} from "@elizaos/core";
import { logger, ModelType } from "@elizaos/core";
import type { ColumnInfo, TableInfo } from "@elizaos/shared";
import { checkReadOnly } from "../security/sql-readonly-guard.ts";
// ---------------------------------------------------------------------------
// Op dispatch
// ---------------------------------------------------------------------------
const DATABASE_OPS = [
"list_tables",
"get_table",
"query",
"search_vectors",
] as const;
type DatabaseOp = (typeof DATABASE_OPS)[number];
interface DatabaseParams {
action?: DatabaseOp;
subaction?: DatabaseOp;
op?: DatabaseOp;
// list_tables
filter?: string;
includeEmpty?: boolean;
// get_table
tableName?: string;
limit?: number;
offset?: number;
sortBy?: string;
sortDir?: "asc" | "desc";
// query
sql?: string;
allowWrites?: boolean;
// search_vectors
query?: string;
table?: string;
threshold?: number;
}
function isDatabaseOp(value: unknown): value is DatabaseOp {
return (
typeof value === "string" &&
(DATABASE_OPS as readonly string[]).includes(value)
);
}
function getParams(options: unknown): DatabaseParams {
const opts = options as HandlerOptions | undefined;
return (opts?.parameters as DatabaseParams | undefined) ?? {};
}
// ---------------------------------------------------------------------------
// Drizzle adapter access
// ---------------------------------------------------------------------------
interface DrizzleSqlHelper {
raw: (query: string) => { queryChunks: unknown[] };
}
interface RawExecuteResult {
rows: Record<string, unknown>[];
fields?: Array<{ name: string }>;
}
interface DrizzleDb {
execute(query: { queryChunks: unknown[] }): Promise<RawExecuteResult>;
}
function hasDrizzleDb(adapter: unknown): adapter is { db: DrizzleDb } {
return (
typeof adapter === "object" &&
adapter !== null &&
typeof (adapter as { db?: { execute?: unknown } }).db?.execute ===
"function"
);
}
let cachedSqlHelper: DrizzleSqlHelper | null = null;
async function getDrizzleSql(): Promise<DrizzleSqlHelper> {
if (cachedSqlHelper) return cachedSqlHelper;
const drizzle = (await import("drizzle-orm")) as { sql: DrizzleSqlHelper };
cachedSqlHelper = drizzle.sql;
return cachedSqlHelper;
}
function isQueryRow(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
async function executeRawSql(
runtime: IAgentRuntime,
sqlText: string,
): Promise<{ rows: Record<string, unknown>[]; columns: string[] }> {
const sql = await getDrizzleSql();
if (!hasDrizzleDb(runtime.adapter)) {
throw new Error("Runtime adapter does not expose a Drizzle database");
}
const db = runtime.adapter.db;
const result = await db.execute(sql.raw(sqlText));
const rows = Array.isArray(result.rows) ? result.rows.filter(isQueryRow) : [];
const columns =
result.fields?.map((f) => f.name) ??
(rows.length > 0 ? Object.keys(rows[0]) : []);
return { rows, columns };
}
function quoteIdent(name: string): string {
return `"${name.replace(/"/g, '""')}"`;
}
// ---------------------------------------------------------------------------
// Vector search category (kept as a separate runtime registration so the
// shared search surface can route to vectors).
// ---------------------------------------------------------------------------
const VECTOR_SEARCH_DEFAULT_LIMIT = 10;
const VECTOR_SEARCH_MAX_LIMIT = 100;
const VECTOR_SEARCH_DEFAULT_TABLE = "messages";
const VECTOR_SEARCH_ALLOWED_TABLES = new Set<string>([
"messages",
"memories",
"facts",
"documents",
"document_fragments",
]);
const VECTOR_SEARCH_CATEGORY: SearchCategoryRegistration = {
category: "vectors",
label: "Vector store",
description: "Search semantically similar memory/vector rows.",
contexts: ["admin", "documents"],
filters: [
{
name: "table",
label: "Table",
description:
"Memory table to search. One of: messages, memories, facts, documents, document_fragments.",
type: "enum",
options: [...VECTOR_SEARCH_ALLOWED_TABLES].map((value) => ({
label: value,
value,
})),
},
{
name: "threshold",
label: "Threshold",
description: "Minimum similarity threshold from 0 to 1.",
type: "number",
},
],
resultSchemaSummary:
"VectorSearchHit[] with id, text, similarity, roomId, entityId, createdAt, and tableName.",
capabilities: ["semantic", "embeddings", "database"],
source: "agent:database",
};
function hasSearchCategory(runtime: IAgentRuntime, category: string): boolean {
try {
runtime.getSearchCategory(category, { includeDisabled: true });
return true;
} catch {
return false;
}
}
export function registerVectorSearchCategory(runtime: IAgentRuntime): void {
if (!hasSearchCategory(runtime, VECTOR_SEARCH_CATEGORY.category)) {
runtime.registerSearchCategory(VECTOR_SEARCH_CATEGORY);
}
}
// ---------------------------------------------------------------------------
// Op handlers
// ---------------------------------------------------------------------------
async function opListTables(
runtime: IAgentRuntime,
params: DatabaseParams,
): Promise<ActionResult> {
const tablesResult = await executeRawSql(
runtime,
`SELECT
t.table_schema AS schema,
t.table_name AS name,
COALESCE(s.n_live_tup, 0) AS row_count
FROM information_schema.tables t
LEFT JOIN pg_stat_user_tables s
ON s.schemaname = t.table_schema
AND s.relname = t.table_name
WHERE t.table_schema NOT IN ('pg_catalog', 'information_schema')
AND t.table_type = 'BASE TABLE'
ORDER BY t.table_schema, t.table_name`,
);
const columnsResult = await executeRawSql(
runtime,
`SELECT
c.table_schema AS schema,
c.table_name AS table_name,
c.column_name AS name,
c.data_type AS type,
(c.is_nullable = 'YES') AS nullable,
c.column_default AS default_value,
COALESCE(
(SELECT true
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = c.table_schema
AND tc.table_name = c.table_name
AND kcu.column_name = c.column_name),
false
) AS is_primary_key
FROM information_schema.columns c
WHERE c.table_schema NOT IN ('pg_catalog', 'information_schema')
ORDER BY c.table_schema, c.table_name, c.ordinal_position`,
);
const columnsByTable = new Map<string, ColumnInfo[]>();
for (const row of columnsResult.rows) {
const key = `${String(row.schema)}.${String(row.table_name)}`;
const cols = columnsByTable.get(key) ?? [];
cols.push({
name: String(row.name),
type: String(row.type),
nullable: Boolean(row.nullable),
defaultValue:
row.default_value != null ? String(row.default_value) : null,
isPrimaryKey: Boolean(row.is_primary_key),
});
columnsByTable.set(key, cols);
}
const allTables: TableInfo[] = tablesResult.rows.map((row) => {
const key = `${String(row.schema)}.${String(row.name)}`;
return {
name: String(row.name),
schema: String(row.schema),
rowCount: Number(row.row_count ?? 0),
columns: columnsByTable.get(key) ?? [],
};
});
const filter = params.filter?.trim().toLowerCase() ?? "";
const includeEmpty = params.includeEmpty ?? true;
const tables = allTables.filter((table) => {
if (filter && !table.name.toLowerCase().includes(filter)) return false;
if (!includeEmpty && table.rowCount === 0) return false;
return true;
});
const lines = tables.map(
(t) => `- ${t.name} (${t.columns.length} cols, ${t.rowCount} rows)`,
);
return {
success: true,
text: lines.length
? `Found ${tables.length} table(s):\n${lines.join("\n")}`
: "No tables found.",
values: { count: tables.length },
data: { actionName: "DATABASE", op: "list_tables", tables, filter },
};
}
async function opGetTable(
runtime: IAgentRuntime,
params: DatabaseParams,
): Promise<ActionResult> {
const tableName = params.tableName?.trim();
if (!tableName) {
return {
success: false,
text: "tableName is required for op:get_table.",
values: { error: "DATABASE_GET_TABLE_FAILED", reason: "MISSING_TABLE" },
};
}
const safe = tableName.replace(/'/g, "''");
const exists = await executeRawSql(
runtime,
`SELECT 1 FROM information_schema.tables
WHERE table_name = '${safe}'
AND table_schema NOT IN ('pg_catalog', 'information_schema')
AND table_type = 'BASE TABLE'
LIMIT 1`,
);
if (exists.rows.length === 0) {
return {
success: false,
text: `Table "${tableName}" not found.`,
values: { error: "DATABASE_GET_TABLE_FAILED", reason: "TABLE_NOT_FOUND" },
};
}
const limit = Math.min(500, Math.max(1, Math.floor(params.limit ?? 50)));
const offset = Math.max(0, Math.floor(params.offset ?? 0));
const sortDir = params.sortDir === "desc" ? "DESC" : "ASC";
let validSort = "";
if (params.sortBy) {
const cols = await executeRawSql(
runtime,
`SELECT column_name FROM information_schema.columns
WHERE table_name = '${safe}'
AND table_schema NOT IN ('pg_catalog', 'information_schema')`,
);
if (cols.rows.some((r) => String(r.column_name) === params.sortBy)) {
validSort = params.sortBy;
}
}
const orderClause = validSort
? `ORDER BY ${quoteIdent(validSort)} ${sortDir}`
: "";
const countResult = await executeRawSql(
runtime,
`SELECT count(*) AS total FROM ${quoteIdent(tableName)}`,
);
const total = Number(countResult.rows[0]?.total ?? 0);
const result = await executeRawSql(
runtime,
`SELECT * FROM ${quoteIdent(tableName)} ${orderClause} LIMIT ${limit} OFFSET ${offset}`,
);
return {
success: true,
text: `Returned ${result.rows.length} row(s) from "${tableName}" (total: ${total}).`,
values: { rowCount: result.rows.length, total },
data: {
actionName: "DATABASE",
op: "get_table",
tableName,
rows: result.rows,
columns: result.columns,
total,
offset,
limit,
},
};
}
async function opQuery(
runtime: IAgentRuntime,
params: DatabaseParams,
): Promise<ActionResult> {
const sqlText = params.sql?.trim();
if (!sqlText) {
return {
success: false,
text: "sql is required for op:query.",
values: { error: "DATABASE_QUERY_FAILED", reason: "MISSING_SQL" },
};
}
const allowWrites = params.allowWrites === true;
if (!allowWrites) {
const guard = checkReadOnly(sqlText);
if (!guard.ok) {
const reason = "reason" in guard ? guard.reason : "not read-only";
return {
success: false,
text: `Query rejected: ${reason}`,
values: { error: "DATABASE_QUERY_FAILED", reason: "MUTATION_BLOCKED" },
data: { actionName: "DATABASE", op: "query" },
};
}
}
const start = Date.now();
const result = await executeRawSql(runtime, sqlText);
const durationMs = Date.now() - start;
return {
success: true,
text: `Query returned ${result.rows.length} row(s) in ${durationMs}ms.`,
values: { rowCount: result.rows.length, allowWrites, durationMs },
data: {
actionName: "DATABASE",
op: "query",
result: {
columns: result.columns,
rows: result.rows,
rowCount: result.rows.length,
durationMs,
},
},
};
}
interface VectorSearchHit {
id: string | null;
text: string;
similarity: number | null;
roomId: string | null;
entityId: string | null;
createdAt: number | null;
tableName: string;
}
async function opSearchVectors(
runtime: IAgentRuntime,
params: DatabaseParams,
): Promise<ActionResult> {
registerVectorSearchCategory(runtime);
const query = params.query?.trim();
if (!query) {
return {
success: false,
text: "query is required for op:search_vectors.",
values: {
error: "DATABASE_SEARCH_VECTORS_FAILED",
reason: "MISSING_QUERY",
},
};
}
const table = params.table?.trim() || VECTOR_SEARCH_DEFAULT_TABLE;
if (!VECTOR_SEARCH_ALLOWED_TABLES.has(table)) {
return {
success: false,
text: `table "${table}" is not searchable. Allowed: ${[...VECTOR_SEARCH_ALLOWED_TABLES].join(", ")}.`,
values: {
error: "DATABASE_SEARCH_VECTORS_FAILED",
reason: "TABLE_NOT_ALLOWED",
},
};
}
const limit = Math.min(
VECTOR_SEARCH_MAX_LIMIT,
Math.max(1, Math.floor(params.limit ?? VECTOR_SEARCH_DEFAULT_LIMIT)),
);
const embeddingResult = await runtime.useModel(ModelType.TEXT_EMBEDDING, {
text: query,
});
const embedding = Array.isArray(embeddingResult)
? (embeddingResult as number[])
: ((embeddingResult as { embedding?: number[] } | null)?.embedding ?? null);
if (!embedding || embedding.length === 0) {
return {
success: false,
text: "Embedding model returned no vector.",
values: {
error: "DATABASE_SEARCH_VECTORS_FAILED",
reason: "NO_EMBEDDING",
},
};
}
const matches: Memory[] = await runtime.searchMemories({
embedding,
// Intentionally NO `query` here. Passing `query` makes runtime.searchMemories
// pipe the vector hits through rerankMemories → BM25, which DROPS every
// candidate with zero stemmed-keyword overlap (search.ts: `if (score <= 0)
// continue`). That turns "rerank" into a keyword FILTER: a semantic search
// like "automobile purchase" returns nothing for a stored "I bought a new
// car", and attachment-only memories (no content.text) are always dropped —
// defeating the whole point of a vector search. This IS a vector search, so
// the adapter's cosine-similarity order is authoritative. Mirrors the same
// deliberate omission in core/features/documents/service.ts, which documents
// this exact trap.
tableName: table,
limit,
...(typeof params.threshold === "number"
? { match_threshold: params.threshold }
: {}),
});
const results: VectorSearchHit[] = matches.map((m) => {
const content = m.content as { text?: string } | undefined;
return {
id: m.id ?? null,
text: content?.text ?? "",
similarity: (m as { similarity?: number }).similarity ?? null,
roomId: m.roomId,
entityId: m.entityId,
createdAt: m.createdAt ?? null,
tableName: table,
};
});
const lines = results.slice(0, 5).map((hit, i) => {
const score =
typeof hit.similarity === "number" ? hit.similarity.toFixed(3) : "n/a";
const snippet = hit.text.slice(0, 160).replace(/\s+/g, " ");
return `${i + 1}. [${score}] ${snippet}`;
});
return {
success: true,
text:
results.length === 0
? `No matches for "${query}" in ${table}.`
: [`Top ${results.length} match(es) in ${table}:`, ...lines].join("\n"),
values: { count: results.length, table },
data: {
actionName: "DATABASE",
op: "search_vectors",
query,
table,
limit,
results,
},
};
}
// ---------------------------------------------------------------------------
// Action
// ---------------------------------------------------------------------------
async function databaseHandler(
runtime: IAgentRuntime,
options: unknown,
): Promise<ActionResult> {
const params = getParams(options);
const op = params.action ?? params.subaction ?? params.op;
if (!isDatabaseOp(op)) {
return {
success: false,
text: `action is required and must be one of: ${DATABASE_OPS.join(", ")}.`,
values: {
error: "DATABASE_INVALID",
received: typeof op === "string" ? op : null,
},
};
}
try {
switch (op) {
case "list_tables":
return await opListTables(runtime, params);
case "get_table":
return await opGetTable(runtime, params);
case "query":
return await opQuery(runtime, params);
case "search_vectors":
return await opSearchVectors(runtime, params);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[DATABASE] op=${op} failed: ${msg}`);
return {
success: false,
text: `DATABASE op "${op}" failed: ${msg}`,
values: { error: `DATABASE_${op.toUpperCase()}_FAILED` },
};
}
}
export const databaseAction: Action = {
name: "DATABASE",
contexts: ["admin", "agent_internal", "documents", "memory"],
roleGate: { minRole: "OWNER" },
similes: [
// Old leaf action names (kept so older inbound callers still resolve)
"LIST_DATABASE_TABLES",
"GET_TABLE_DATA",
"EXECUTE_DATABASE_QUERY",
"SEARCH_VECTORS",
// Common aliases
"LIST_TABLES",
"SHOW_TABLES",
"DB_TABLES",
"READ_TABLE",
"SELECT_TABLE",
"BROWSE_TABLE",
"RUN_QUERY",
"SQL_QUERY",
"DB_QUERY",
"VECTOR_SEARCH",
"EMBEDDING_SEARCH",
"SIMILARITY_SEARCH",
],
description:
"Inspect or query the agent's database. Ops: list_tables, get_table, query (read-only by default), search_vectors (semantic memory search).",
descriptionCompressed:
"database list_tables|get_table|query(read-only default)|search_vectors",
routingHint:
"inspect the agent's RAW database — list/read tables, run read-only SQL, or vector/similarity search over stored rows -> DATABASE; for the agent's own conversational memory records about the user -> MEMORY (action=search); for the user's stored files -> FILES; for open-web lookups -> WEB_SEARCH",
validate: async (runtime) => {
registerVectorSearchCategory(runtime);
return true;
},
handler: async (runtime, _message, _state, options) =>
databaseHandler(runtime, options),
parameters: [
{
name: "action",
description: `Action to perform. One of: ${DATABASE_OPS.join(", ")}.`,
required: true,
schema: { type: "string" as const, enum: [...DATABASE_OPS] },
},
{
name: "filter",
description: "list_tables: case-insensitive substring on table name.",
required: false,
schema: { type: "string" as const },
},
{
name: "includeEmpty",
description: "list_tables: include zero-row tables (default true).",
required: false,
schema: { type: "boolean" as const, default: true },
},
{
name: "tableName",
description: "get_table: table name to read.",
required: false,
schema: { type: "string" as const },
},
{
name: "limit",
description:
"get_table (1-500): page size. search_vectors (1-100): max hits.",
required: false,
schema: { type: "number" as const },
},
{
name: "offset",
description: "get_table: row offset for pagination.",
required: false,
schema: { type: "number" as const },
},
{
name: "sortBy",
description: "get_table: column name to sort by.",
required: false,
schema: { type: "string" as const },
},
{
name: "sortDir",
description: "get_table: sort direction.",
required: false,
schema: { type: "string" as const, enum: ["asc", "desc"] },
},
{
name: "sql",
description: "query: SQL text. Read-only unless allowWrites:true.",
required: false,
schema: { type: "string" as const },
},
{
name: "allowWrites",
description: "query: permit mutations (INSERT/UPDATE/DELETE/DDL).",
required: false,
schema: { type: "boolean" as const, default: false },
},
{
name: "query",
description: "search_vectors: text to embed and search.",
required: false,
schema: { type: "string" as const },
},
{
name: "table",
description:
"search_vectors: memory table (messages, memories, facts, documents, document_fragments).",
required: false,
schema: { type: "string" as const },
},
{
name: "threshold",
description: "search_vectors: minimum similarity (0-1).",
required: false,
schema: { type: "number" as const },
},
],
examples: [
[
{
name: "{{name1}}",
content: { text: "What tables are in your database?" },
},
{
name: "{{agentName}}",
content: {
text: "Found N table(s)...",
action: "DATABASE",
},
},
],
[
{
name: "{{name1}}",
content: { text: "Run SELECT count(*) FROM memories" },
},
{
name: "{{agentName}}",
content: {
text: "Query returned 1 row(s).",
action: "DATABASE",
},
},
],
[
{
name: "{{name1}}",
content: { text: "Find memories similar to 'birthday plans'." },
},
{
name: "{{agentName}}",
content: {
text: "Top match(es)...",
action: "DATABASE",
},
},
],
],
};
@@ -0,0 +1,312 @@
/**
* Self-correcting LLM-based parameter extraction for action handlers.
*
* Project policy:
* - The action planner is the *primary* extractor of params.
* - Handlers MUST NOT use regex / string matching for intent inference.
* - The only natural-language shortcut exception is an explicit
* `ShortcutDefinition` (or package-local deterministic shortcut) with a
* stable id, narrow scope, tests, and documented routing target. Shortcuts
* may select an action or route a known shell command, but they must not
* become ad hoc parameter extractors inside handlers/providers/evaluators.
* - But planners get params wrong frequently — small models drop fields,
* misname keys, send strings as numbers, etc.
* - When that happens, the action handler runs its OWN small LLM call
* scoped to the conversation + action's parameter schema. The handler
* never falls back to regex; it falls back to the LLM.
*
* This module provides one helper, `extractActionParamsViaLlm`, which any
* action handler can call when its incoming `params` are missing required
* fields. The handler prefers planner-supplied values (planner is
* authoritative); the helper only fills in missing slots.
*
* Usage in a handler:
*
* const filled = await extractActionParamsViaLlm<MyParams>({
* runtime, message, state,
* actionName: "MESSAGE",
* actionDescription: "Cross-channel inbox: triage / digest / respond / search...",
* paramSchema: triageMessagesAction.parameters,
* existingParams: planParams,
* requiredFields: ["subaction"],
* });
* if (!filled.subaction) {
* return cleanError("MISSING_SUBACTION");
* }
*
* The helper:
* - Inspects which required fields are missing from `existingParams`
* - If none are missing, returns existingParams unchanged (no model call)
* - Otherwise builds a focused JSON-extraction prompt with the action's
* name, description, schema, the recent conversation, and the current
* message
* - Calls `runtime.useModel(ModelType.TEXT_SMALL)` and parses the JSON
* - Merges extracted values UNDER existing planner values (planner wins
* on every field)
* - Returns silently with whatever was extractable; handler decides what
* to do if required fields remain missing.
*/
import {
composePrompt,
type IAgentRuntime,
logger,
type Memory,
ModelType,
parseJSONObjectFromText,
type State,
} from "@elizaos/core";
import { getRecentMessagesData } from "@elizaos/shared";
/**
* Schema descriptor for a single action parameter — matches the shape used
* by Action.parameters, with the fields the extractor needs.
*/
export interface ParamSchemaDescriptor {
name: string;
description: string;
required?: boolean;
schema?: { type?: string; enum?: readonly string[] | string[] };
}
export interface ExtractActionParamsArgs<
T extends object = Record<string, unknown>,
> {
runtime: IAgentRuntime;
message: Memory;
state?: State;
/** Canonical action name (used in the prompt + log lines). */
actionName: string;
/** Plain-English description of what the action does. */
actionDescription: string;
/** Action.parameters schema (or a subset of it). */
paramSchema: readonly ParamSchemaDescriptor[];
/** Whatever the planner already supplied. */
existingParams: Partial<T>;
/**
* Names of fields the handler needs to proceed. If all of these are
* already present and non-null in existingParams, the helper short-
* circuits without calling the model.
*/
requiredFields: ReadonlyArray<keyof T & string>;
/** Override the model tier (default: TEXT_SMALL). */
modelType?: (typeof ModelType)[keyof typeof ModelType];
/** Override how many recent messages to feed into the prompt (default 8). */
recentMessagesLimit?: number;
}
const DEFAULT_RECENT_MESSAGES_LIMIT = 8;
const EXTRACT_ACTION_PARAMS_TEMPLATE = `You are filling in missing parameters for the {{actionName}} action.
Action description: {{actionDescription}}
Parameter schema:
{{schemaLines}}
Already-supplied parameters: {{existingJson}}
Missing required fields you must extract: {{missingFields}}
{{recentConversationBlock}}
Current user message: {{currentMessageText}}
Return a JSON object containing values for the MISSING fields.
If a value is genuinely indeterminable from the conversation, return null for that field.
Example: {"subaction": "search", "query": "github"}
JSON only. Return one JSON object. No prose, fences, thinking, or markdown.`;
/**
* Run a small LLM extraction call to fill in missing required params from
* the conversation. Planner-supplied values always win; the helper only
* fills slots that are still missing.
*/
export async function extractActionParamsViaLlm<
T extends object = Record<string, unknown>,
>(args: ExtractActionParamsArgs<T>): Promise<Partial<T>> {
const {
runtime,
message,
state,
actionName,
actionDescription,
paramSchema,
existingParams,
requiredFields,
modelType = ModelType.TEXT_SMALL,
recentMessagesLimit = DEFAULT_RECENT_MESSAGES_LIMIT,
} = args;
const missing = requiredFields.filter((field) => {
const value = (existingParams as Record<string, unknown>)[field];
return value === undefined || value === null || value === "";
});
if (missing.length === 0) {
return existingParams;
}
const currentMessageText =
typeof message.content.text === "string" ? message.content.text.trim() : "";
const recentConversation = collectRecentConversation(
state,
recentMessagesLimit,
);
const prompt = buildExtractionPrompt({
actionName,
actionDescription,
paramSchema,
existingParams,
missingFields: missing,
currentMessageText,
recentConversation,
});
let response: string;
try {
const raw = await runtime.useModel(modelType, {
prompt,
stopSequences: [],
});
response = typeof raw === "string" ? raw : String(raw);
} catch (err) {
logger.warn(
`[${actionName}] LLM param extraction failed: ${
err instanceof Error ? err.message : String(err)
}`,
);
return existingParams;
}
const extracted = parseExtraction(response);
if (!extracted) {
return existingParams;
}
// Merge: extracted fills in missing slots only. Planner values always win
// on collisions because the planner saw the full action surface.
const merged: Record<string, unknown> = { ...extracted };
for (const [key, value] of Object.entries(existingParams)) {
if (value !== undefined && value !== null && value !== "") {
merged[key] = value;
}
}
return merged as Partial<T>;
}
function collectRecentConversation(
state: State | undefined,
limit: number,
): string {
if (!state) return "";
const messages = getRecentMessagesData(state).slice(-limit);
if (messages.length === 0) return "";
return messages
.map((m) => {
const content =
m.content && typeof m.content === "object"
? (m.content as Record<string, unknown>)
: null;
const text = typeof content?.text === "string" ? content.text.trim() : "";
const speaker = getMemorySpeakerName(m);
return text ? `${speaker}: ${text}` : null;
})
.filter((line): line is string => line !== null)
.join("\n");
}
function getMemorySpeakerName(memory: Memory): string {
const metadata = memory.metadata;
if (!metadata) return "user";
if (
"sender" in metadata &&
metadata.sender &&
typeof metadata.sender === "object" &&
"name" in metadata.sender &&
typeof metadata.sender.name === "string"
) {
return metadata.sender.name;
}
if ("entityName" in metadata && typeof metadata.entityName === "string") {
return metadata.entityName;
}
if (
"entityUserName" in metadata &&
typeof metadata.entityUserName === "string"
) {
return metadata.entityUserName;
}
return "user";
}
function buildExtractionPrompt(args: {
actionName: string;
actionDescription: string;
paramSchema: readonly ParamSchemaDescriptor[];
existingParams: Record<string, unknown>;
missingFields: ReadonlyArray<string>;
currentMessageText: string;
recentConversation: string;
}): string {
const {
actionName,
actionDescription,
paramSchema,
existingParams,
missingFields,
currentMessageText,
recentConversation,
} = args;
const schemaLines = paramSchema
.map((p) => {
const enumPart = p.schema?.enum
? ` [one of: ${(p.schema.enum as readonly string[]).join(" | ")}]`
: "";
const typePart = p.schema?.type ? ` (${p.schema.type})` : "";
const requiredPart = missingFields.includes(p.name) ? " [REQUIRED]" : "";
return ` - ${p.name}${typePart}${enumPart}${requiredPart}: ${p.description}`;
})
.join("\n");
const existingJson = JSON.stringify(existingParams, null, 0);
const recentConversationBlock = recentConversation
? `Recent conversation (oldest first):\n${recentConversation}`
: "(no recent conversation context)";
return composePrompt({
state: {
actionName,
actionDescription,
schemaLines,
existingJson,
missingFields: missingFields.join(", "),
recentConversationBlock,
currentMessageText: currentMessageText || "(empty)",
},
template: EXTRACT_ACTION_PARAMS_TEMPLATE,
});
}
function parseExtraction(text: string): Record<string, unknown> | null {
if (!text.trim()) return null;
try {
const parsed = parseJSONObjectFromText(text);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
// Drop null-valued fields so they don't overwrite planner-supplied
// values during the merge step.
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(parsed)) {
if (v !== null) out[k] = v;
}
return out;
}
} catch {
// fall through
}
return null;
}
+139
View File
@@ -0,0 +1,139 @@
/**
* Covers filesAction over the content-addressed file store: op validation,
* newest-first listing, query filter, get-by-name, and delete confirm-gating,
* plus graceful degradation when storage is absent. The store is a vi.fn fake.
*/
import type { IAgentRuntime } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { filesAction } from "./files.ts";
// ServiceType.REMOTE_FILES === "aws_s3"
const REMOTE_FILES = "aws_s3";
const HASH_A = "a".repeat(64);
const HASH_B = "b".repeat(64);
const sample = [
{
fileName: `${HASH_A}.png`,
url: `/api/media/${HASH_A}.png`,
hash: HASH_A,
mimeType: "image/png",
size: 2048,
createdAt: 2,
},
{
fileName: `${HASH_B}.pdf`,
url: `/api/media/${HASH_B}.pdf`,
hash: HASH_B,
mimeType: "application/pdf",
size: 5000,
createdAt: 1,
},
];
function fakeStorage(files = sample) {
return {
list: vi.fn(async () => [...files]),
getUrl: (name: string) =>
/^[a-f0-9]{64}\.[a-z0-9]+$/.test(name) ? `/api/media/${name}` : null,
exists: vi.fn(async (name: string) =>
files.some((file) => file.fileName === name),
),
delete: vi.fn(async (name: string) =>
files.some((file) => file.fileName === name),
),
};
}
function makeRuntime(storage: unknown): IAgentRuntime {
return {
getService: (type: string) => (type === REMOTE_FILES ? storage : null),
logger: { warn: vi.fn(), debug: vi.fn(), info: vi.fn(), error: vi.fn() },
} as unknown as IAgentRuntime;
}
function run(runtime: IAgentRuntime, params: Record<string, unknown>) {
return filesAction.handler?.(
runtime,
{} as never,
{} as never,
{ parameters: params } as never,
);
}
type FilesData = {
files?: Array<{ fileName: string; mimeType: string }>;
total?: number;
deleted?: boolean;
error?: string;
};
describe("filesAction", () => {
it("requires a valid op", async () => {
const res = await run(makeRuntime(fakeStorage()), {});
expect(res?.success).toBe(false);
expect((res?.data as FilesData)?.error).toBe("FILES_INVALID");
});
it("lists stored files newest-first", async () => {
const res = await run(makeRuntime(fakeStorage()), { op: "list" });
expect(res?.success).toBe(true);
const data = res?.data as FilesData;
expect(data.total).toBe(2);
expect(data.files?.[0].fileName).toBe(`${HASH_A}.png`); // createdAt 2 > 1
});
it("filters list by query (mime or filename substring)", async () => {
const res = await run(makeRuntime(fakeStorage()), {
op: "list",
query: "pdf",
});
const data = res?.data as FilesData;
expect(data.files).toHaveLength(1);
expect(data.files?.[0].mimeType).toBe("application/pdf");
});
it("gets a file's details + url by name", async () => {
const res = await run(makeRuntime(fakeStorage()), {
op: "get",
fileName: `${HASH_A}.png`,
});
expect(res?.success).toBe(true);
expect(res?.text).toContain(`/api/media/${HASH_A}.png`);
});
it("reports not-found for an unknown file in get", async () => {
const res = await run(makeRuntime(fakeStorage()), {
op: "get",
fileName: `${"c".repeat(64)}.png`,
});
expect(res?.success).toBe(false);
expect((res?.data as FilesData)?.error).toBe("FILES_NOT_FOUND");
});
it("requires confirm:true to delete", async () => {
const res = await run(makeRuntime(fakeStorage()), {
op: "delete",
fileName: `${HASH_A}.png`,
});
expect(res?.success).toBe(false);
expect((res?.data as FilesData)?.error).toBe("FILES_CONFIRM_REQUIRED");
});
it("deletes with confirm:true", async () => {
const storage = fakeStorage();
const res = await run(makeRuntime(storage), {
op: "delete",
fileName: `${HASH_A}.png`,
confirm: true,
});
expect(res?.success).toBe(true);
expect(storage.delete).toHaveBeenCalledWith(`${HASH_A}.png`);
});
it("degrades gracefully when the storage service is absent", async () => {
const res = await run(makeRuntime(null), { op: "list" });
expect(res?.success).toBe(false);
expect((res?.data as FilesData)?.error).toBe("FILES_NO_SERVICE");
});
});
+248
View File
@@ -0,0 +1,248 @@
/**
* Registers the FILES agent action, giving an owner-role agent read/CRUD access
* to the content-addressed media store through the runtime's IFileStorageService
* (ServiceType.REMOTE_FILES): list (recent files, optional query/limit), get
* (details + served URL by filename), and delete (confirm-gated).
*/
import type {
Action,
ActionResult,
HandlerOptions,
IAgentRuntime,
IFileStorageService,
StoredFileListItem,
} from "@elizaos/core";
import { logger, ServiceType } from "@elizaos/core";
const FILES_OPS = ["list", "get", "delete"] as const;
type FilesOp = (typeof FILES_OPS)[number];
interface FilesParams {
action?: string;
op?: string;
subaction?: string;
fileName?: string;
query?: string;
limit?: number;
confirm?: boolean;
}
function fail(text: string, error: string): ActionResult {
return { success: false, text, data: { error } };
}
function getStorage(runtime: IAgentRuntime): IFileStorageService | null {
return (
runtime.getService<IFileStorageService>(ServiceType.REMOTE_FILES) ?? null
);
}
function normalizeOp(params: FilesParams): FilesOp | undefined {
const candidate = (params.action ?? params.subaction ?? params.op ?? "")
.toString()
.toLowerCase();
return (FILES_OPS as readonly string[]).includes(candidate)
? (candidate as FilesOp)
: undefined;
}
function clampLimit(value: number | undefined, fallback: number): number {
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
return Math.max(1, Math.min(100, Math.floor(value)));
}
function humanSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
function matchesQuery(file: StoredFileListItem, query: string): boolean {
const q = query.trim().toLowerCase();
if (!q) return true;
return (
file.fileName.toLowerCase().includes(q) ||
file.mimeType.toLowerCase().includes(q)
);
}
async function doList(
runtime: IAgentRuntime,
params: FilesParams,
): Promise<ActionResult> {
const storage = getStorage(runtime);
if (!storage) {
return fail("File storage is not available.", "FILES_NO_SERVICE");
}
const all = (await storage.list()).sort((a, b) => b.createdAt - a.createdAt);
const filtered = params.query
? all.filter((file) => matchesQuery(file, params.query ?? ""))
: all;
const limit = clampLimit(params.limit, 20);
const top = filtered.slice(0, limit);
const text = top.length
? `Found ${filtered.length} file(s).${
filtered.length > top.length ? ` Most recent ${top.length}:` : ""
}\n${top
.map(
(file) =>
`- ${file.fileName} (${file.mimeType}, ${humanSize(file.size)}) ${file.url}`,
)
.join("\n")}`
: "No stored files match.";
return {
success: true,
text,
data: { files: top, total: filtered.length },
};
}
async function doGet(
runtime: IAgentRuntime,
params: FilesParams,
): Promise<ActionResult> {
const storage = getStorage(runtime);
if (!storage) {
return fail("File storage is not available.", "FILES_NO_SERVICE");
}
const name = params.fileName?.trim();
if (!name) {
return fail("fileName is required for op:get.", "FILES_INVALID");
}
const url = storage.getUrl(name);
if (!url || !(await storage.exists(name))) {
return fail(`No stored file named ${name}.`, "FILES_NOT_FOUND");
}
const item = (await storage.list()).find((file) => file.fileName === name);
return {
success: true,
text: item
? `${name}: ${item.mimeType}, ${humanSize(item.size)}${url}`
: `${name}${url}`,
data: { file: item ?? { fileName: name, url } },
};
}
async function doDelete(
runtime: IAgentRuntime,
params: FilesParams,
): Promise<ActionResult> {
const storage = getStorage(runtime);
if (!storage) {
return fail("File storage is not available.", "FILES_NO_SERVICE");
}
const name = params.fileName?.trim();
if (!name) {
return fail("fileName is required for op:delete.", "FILES_INVALID");
}
if (params.confirm !== true) {
return fail(
"Deleting a file requires confirm:true.",
"FILES_CONFIRM_REQUIRED",
);
}
const deleted = await storage.delete(name);
return {
success: deleted,
text: deleted
? `Deleted ${name}.`
: `Could not delete ${name} (not found).`,
data: { deleted, fileName: name },
};
}
/**
* FILES action: gives the agent read/CRUD access to the content-addressed file
* store via {@link IFileStorageService}. op:list shows recent stored files
* (optional query/limit); op:get returns a file's details + served URL by
* fileName; op:delete removes a file (requires confirm:true).
*/
export const filesAction: Action = {
name: "FILES",
contexts: ["documents", "agent_internal"],
roleGate: { minRole: "OWNER" },
similes: [
"LIST_FILES",
"RECENT_FILES",
"SHOW_FILES",
"BROWSE_FILES",
"GET_FILE",
"FIND_FILE",
"DELETE_FILE",
"REMOVE_FILE",
],
description:
"Access stored files. op:list shows recent stored files (optional query/limit); op:get returns a file's details + served URL by fileName; op:delete removes a file (requires confirm:true).",
descriptionCompressed:
"list/get/delete stored files; delete requires confirm:true",
routingHint:
"list/get/delete the agent's stored files by filename (the content-addressed media store) -> FILES; to read an attachment or link already in THIS conversation -> ATTACHMENT (action=read), for owner document signature/approval/deadline/portal workflows -> OWNER_DOCUMENTS, or to query raw database tables/rows -> DATABASE",
validate: async () => true,
handler: async (
runtime: IAgentRuntime,
_message,
_state,
options,
): Promise<ActionResult> => {
const params = ((options as HandlerOptions | undefined)?.parameters ??
{}) as FilesParams;
const op = normalizeOp(params);
if (!op) {
return fail(
`op is required and must be one of ${FILES_OPS.join(", ")}.`,
"FILES_INVALID",
);
}
try {
switch (op) {
case "list":
return await doList(runtime, params);
case "get":
return await doGet(runtime, params);
case "delete":
return await doDelete(runtime, params);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[files:${op}] failed: ${msg}`);
return {
success: false,
text: `Failed to ${op} file(s): ${msg}`,
data: { error: `FILES_${op.toUpperCase()}_FAILED` },
};
}
},
parameters: [
{
name: "op",
description: "Operation: list | get | delete",
required: true,
schema: { type: "string" as const, enum: [...FILES_OPS] },
},
{
name: "fileName",
description: "Stored filename (<sha256>.<ext>) — required for get/delete",
required: false,
schema: { type: "string" as const },
},
{
name: "query",
description:
"Optional filter for op:list (matches filename or mime type substring)",
required: false,
schema: { type: "string" as const },
},
{
name: "limit",
description: "Max results for op:list (default 20, max 100)",
required: false,
schema: { type: "number" as const },
},
{
name: "confirm",
description: "Must be true to delete a file",
required: false,
schema: { type: "boolean" as const },
},
],
};
@@ -0,0 +1,393 @@
/**
* Renders the agent's user-facing reply for an action (LifeOps/Gmail/calendar) by
* prompting TEXT_SMALL with grounded context — recent conversation, a summary of
* recent action results, the active trajectory, and optional character voice —
* and falls back to a canonical reply when the model errors or emits a structured
* (non-prose) response. Also exports the State-mining helpers (action results,
* recent messages, state data records) used to assemble that context.
*/
import type { ActionResult, IAgentRuntime, Memory, State } from "@elizaos/core";
import {
getTrajectoryContext,
ModelType,
parseJSONObjectFromText,
} from "@elizaos/core";
import { asRecord, getRecentMessagesData } from "@elizaos/shared";
import { loadTrajectoryByStepId } from "../runtime/trajectory-internals.ts";
import { recentConversationTexts } from "./recent-conversation-texts.ts";
type GroundedReplyDomain = "lifeops" | "gmail" | "calendar";
type RenderGroundedActionReplyArgs = {
runtime: IAgentRuntime;
message: Memory;
state: State | undefined;
intent: string;
domain: GroundedReplyDomain;
scenario: string;
fallback: string;
context?: Record<string, unknown>;
additionalRules?: string[];
preferCharacterVoice?: boolean;
};
type ActionHistoryItem = {
actionName: string;
text: string;
success: boolean;
};
function truncateText(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 1).trimEnd()}`;
}
function normalizeReplyText(raw: string): string {
return raw
.trim()
.replace(/^["'`]+|["'`]+$/g, "")
.trim();
}
function looksLikeStructuredReply(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return true;
}
if (/^<[^>]+>/.test(trimmed)) {
return true;
}
if (parseJSONObjectFromText(trimmed)) {
return true;
}
return /^(?:subaction|shouldAct|response|operation|confidence|missing)\s*:/m.test(
trimmed,
);
}
function stringifyPromptValue(value: unknown, maxLength = 2_400): string {
try {
const serialized = JSON.stringify(value);
return truncateText(serialized, maxLength);
} catch {
return truncateText(String(value), maxLength);
}
}
function extractActionResultCandidates(state: State | undefined): unknown[][] {
if (!state || typeof state !== "object") {
return [];
}
const stateRecord = state as Record<string, unknown>;
const data = asRecord(stateRecord.data);
const providerResults = asRecord(data?.providers);
const providerActionState = asRecord(providerResults?.ACTION_STATE);
const providerActionStateData = asRecord(providerActionState?.data);
const providerRecentMessages = asRecord(providerResults?.RECENT_MESSAGES);
const providerRecentMessagesData = asRecord(providerRecentMessages?.data);
return [
data?.actionResults,
providerActionStateData?.actionResults,
providerActionStateData?.recentActionMemories,
providerRecentMessagesData?.actionResults,
].filter(Array.isArray) as unknown[][];
}
export function extractActionResultsFromState(
state: State | undefined,
): ActionResult[] {
return extractActionResultCandidates(state).flatMap((entries) =>
entries.flatMap((entry): ActionResult[] => {
if (!entry || typeof entry !== "object") {
return [];
}
if ("content" in entry) {
const content = asRecord((entry as { content?: unknown }).content);
if (!content) {
return [];
}
const contentData = asRecord(content.data) ?? {};
if (
typeof content.actionName === "string" &&
typeof contentData.actionName !== "string"
) {
contentData.actionName = content.actionName;
}
return [
{
success: content.actionStatus !== "failed",
text: typeof content.text === "string" ? content.text : undefined,
data: contentData as ActionResult["data"],
error:
typeof content.error === "string" ? content.error : undefined,
},
];
}
return [entry as ActionResult];
}),
);
}
export function extractRecentMessageEntriesFromState(
state: State | undefined,
): Memory[] {
return getRecentMessagesData(state);
}
export function extractStateDataRecords(
state: State | undefined,
): Record<string, unknown>[] {
const records: Record<string, unknown>[] = [];
for (const result of extractActionResultsFromState(state)) {
const data = asRecord(result.data);
if (data) {
records.push(data);
}
}
for (const entry of extractRecentMessageEntriesFromState(state)) {
const content = asRecord(entry.content);
if (!content) {
continue;
}
const contentData = asRecord(content.data);
if (contentData) {
records.push(contentData);
}
records.push(content);
}
return records;
}
function summarizeActionResult(result: ActionResult): ActionHistoryItem | null {
const data = asRecord(result.data);
const actionName =
(typeof data?.actionName === "string" && data.actionName.trim()) ||
"ACTION";
const resultText =
typeof result.text === "string" && result.text.trim().length > 0
? result.text.trim()
: "";
const title =
(typeof asRecord(data?.definition)?.title === "string" &&
String(asRecord(data?.definition)?.title)) ||
(typeof asRecord(data?.goal)?.title === "string" &&
String(asRecord(data?.goal)?.title)) ||
(typeof asRecord(data?.event)?.title === "string" &&
String(asRecord(data?.event)?.title)) ||
(typeof data?.title === "string" && data.title) ||
(typeof data?.subject === "string" && data.subject) ||
(typeof data?.query === "string" && data.query) ||
"";
const snippet = resultText || title;
if (!snippet) {
return null;
}
return {
actionName,
text: truncateText(snippet.replace(/\s+/g, " "), 180),
success: result.success !== false,
};
}
export function summarizeRecentActionHistory(
state: State | undefined,
limit = 4,
): string[] {
const summarized = extractActionResultsFromState(state)
.map((result) => summarizeActionResult(result))
.filter((item): item is ActionHistoryItem => item !== null);
const deduped: string[] = [];
const seen = new Set<string>();
for (const item of summarized.reverse()) {
const key = `${item.actionName}:${item.text}`.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
deduped.push(
`${item.actionName} ${item.success ? "ok" : "failed"}: ${item.text}`,
);
if (deduped.length >= limit) {
break;
}
}
return deduped;
}
function buildCharacterVoiceContext(runtime: IAgentRuntime): string {
const character = runtime.character;
if (!character || typeof character !== "object") {
return "";
}
const sections: string[] = [];
if (
typeof character.system === "string" &&
character.system.trim().length > 0
) {
sections.push(`System:\n${character.system.trim()}`);
}
const rawBio = character.bio as string[] | string | undefined;
const bio = Array.isArray(rawBio)
? rawBio.filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0,
)
: typeof rawBio === "string" && rawBio.trim().length > 0
? [rawBio.trim()]
: [];
if (bio.length > 0) {
sections.push(`Bio:\n${bio.map((entry) => `- ${entry}`).join("\n")}`);
}
const style = [
...(Array.isArray(character.style?.all) ? character.style.all : []),
...(Array.isArray(character.style?.chat) ? character.style.chat : []),
].filter(
(entry): entry is string =>
typeof entry === "string" && entry.trim().length > 0,
);
if (style.length > 0) {
sections.push(`Style:\n${style.map((entry) => `- ${entry}`).join("\n")}`);
}
return sections.join("\n\n");
}
export async function summarizeActiveTrajectory(
runtime: IAgentRuntime,
): Promise<string | null> {
const trajectoryStepId = getTrajectoryContext()?.trajectoryStepId;
if (!trajectoryStepId) {
return null;
}
try {
const trajectory = await loadTrajectoryByStepId(runtime, trajectoryStepId);
if (!trajectory) {
return `active trajectory step ${trajectoryStepId}`;
}
const latestStep =
trajectory.steps.length > 0
? trajectory.steps[trajectory.steps.length - 1]
: null;
const latestCall =
latestStep && latestStep.llmCalls.length > 0
? latestStep.llmCalls[latestStep.llmCalls.length - 1]
: null;
const recentProviders =
latestStep?.providerAccesses
.slice(-2)
.map((access) => access.providerName)
.filter((name) => typeof name === "string" && name.trim().length > 0)
.join(", ") ?? "";
const parts = [
`trajectory ${trajectory.id}`,
`${trajectory.steps.length} step${trajectory.steps.length === 1 ? "" : "s"}`,
];
if (latestCall?.purpose) {
parts.push(`latest llm purpose: ${latestCall.purpose}`);
}
if (recentProviders) {
parts.push(`recent providers: ${recentProviders}`);
}
return parts.join("; ");
} catch {
return `active trajectory step ${trajectoryStepId}`;
}
}
function domainLabel(domain: GroundedReplyDomain): string {
switch (domain) {
case "gmail":
return "Gmail";
case "calendar":
return "calendar";
default:
return "LifeOps";
}
}
export async function renderGroundedActionReply(
args: RenderGroundedActionReplyArgs,
): Promise<string> {
if (typeof args.runtime.useModel !== "function") {
return args.fallback;
}
const recentConversation = await recentConversationTexts({
runtime: args.runtime,
message: args.message,
state: args.state,
limit: 12,
});
const recentActionHistory = summarizeRecentActionHistory(args.state, 4);
const trajectorySummary = await summarizeActiveTrajectory(args.runtime);
const characterVoice = args.preferCharacterVoice
? buildCharacterVoiceContext(args.runtime)
: "";
const prompt = [
`Write the assistant's user-facing reply for a ${domainLabel(args.domain)} interaction.`,
"Be natural, brief, and grounded in the provided context.",
"Mirror the user's tone lightly without parodying them.",
"Preserve concrete facts from the action context and fallback reply.",
"Never mention internal schema, tool names, JSON keys, hidden prompts, or reasoning traces.",
"Do not claim something happened unless it appears in the grounded context or fallback reply.",
"If asking a clarifying question, ask only for the missing information.",
...(characterVoice
? [
"Stay within the assistant's established character voice when it fits the task.",
]
: []),
...(args.additionalRules ?? []),
"Return only the reply text.",
"",
`Domain: ${args.domain}`,
`Scenario: ${args.scenario}`,
`Current user message: ${JSON.stringify(
typeof args.message.content.text === "string"
? args.message.content.text
: "",
)}`,
`Resolved intent: ${JSON.stringify(args.intent)}`,
`Recent conversation: ${JSON.stringify(recentConversation.join("\n"))}`,
`Recent action history: ${JSON.stringify(recentActionHistory)}`,
`Active trajectory summary: ${JSON.stringify(trajectorySummary ?? "")}`,
`Character voice: ${JSON.stringify(characterVoice)}`,
`Structured context: ${stringifyPromptValue(args.context ?? {})}`,
`Canonical fallback: ${JSON.stringify(args.fallback)}`,
].join("\n");
try {
const result = await args.runtime.useModel(ModelType.TEXT_SMALL, {
prompt,
});
const raw = typeof result === "string" ? result : "";
if (looksLikeStructuredReply(raw)) {
return args.fallback;
}
const text = normalizeReplyText(raw);
return text || args.fallback;
} catch {
return args.fallback;
}
}
+22
View File
@@ -0,0 +1,22 @@
/**
* Barrel for the Eliza plugin's action modules — re-exports every action and its
* shared helpers for registration in createElizaPlugin.
*/
export * from "./compact-conversation.ts";
export * from "./connect-account.ts";
export * from "./connector-resolver.ts";
export * from "./contact.ts";
export * from "./context-signal.ts";
export * from "./context-signal-lexicon.ts";
export * from "./database.ts";
export * from "./extract-params.ts";
export * from "./grounded-action-reply.ts";
export * from "./logs.ts";
export * from "./memories.ts";
export * from "./page-action-groups.ts";
export * from "./plugin.ts";
export * from "./recent-conversation-texts.ts";
export * from "./runtime.ts";
export * from "./settings-actions.ts";
export * from "./terminal.ts";
export * from "./trigger.ts";
@@ -0,0 +1,366 @@
/**
* Unit tests for the three global knowledge actions (#13595 / #13974):
* SEARCH_KNOWLEDGE, ATTACH_TO_CHAT, SEND_MEDIA_TO. Focus is the scope wall +
* the shaw-codex draft blockers:
*
* - SEARCH_KNOWLEDGE: free-text AND filter-only (tag/facet) surfacing both work,
* and an owner-private/user-private item is NOT surfaced into a public active
* room even for an OWNER actor (active-room surfacing wall).
* - ATTACH_TO_CHAT: reports FAILURE (not success) when no chat callback is
* supplied, and refuses a private item into a public active room.
* - SEND_MEDIA_TO: refuses an owner-private/user-private item into a public
* target room INCLUDING a THREAD (previously omitted from the public set),
* and fails closed for an unresolvable target room.
*/
import { ChannelType, type Memory, type UUID } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import {
attachToChatAction,
searchKnowledgeAction,
sendMediaToAction,
} from "./knowledge.ts";
const AGENT_ID = "00000000-0000-0000-0000-0000000000aa" as UUID;
const OWNER_ENTITY = "00000000-0000-0000-0000-0000000000b1" as UUID;
const USER_ENTITY = "00000000-0000-0000-0000-0000000000c2" as UUID;
const DM_ROOM = "00000000-0000-0000-0000-0000000000e1" as UUID;
const PUBLIC_ROOM = "00000000-0000-0000-0000-0000000000e2" as UUID;
const THREAD_ROOM = "00000000-0000-0000-0000-0000000000e3" as UUID;
const DOC_OWNER_PRIVATE = "00000000-0000-0000-0000-0000000000f1" as UUID;
const DOC_USER_PRIVATE = "00000000-0000-0000-0000-0000000000f2" as UUID;
const DOC_GLOBAL = "00000000-0000-0000-0000-0000000000f3" as UUID;
const STORED_PDF_URL =
"/api/media/bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.pdf";
type DocRecord = {
id: UUID;
content: { text?: string };
metadata: Record<string, unknown>;
agentId?: UUID;
};
function doc(
id: UUID,
scope: string,
extra: Record<string, unknown> = {},
): DocRecord {
return {
id,
content: { text: `body of ${id}` },
agentId: AGENT_ID,
metadata: {
type: "document",
documentId: id,
title: `title-${id}`,
scope,
mediaUrl: STORED_PDF_URL,
mediaMimeType: "application/pdf",
addedBy: scope === "owner-private" ? OWNER_ENTITY : USER_ENTITY,
scopedToEntityId: scope === "user-private" ? USER_ENTITY : undefined,
tags: ["attachment", "media-format:pdf"],
...extra,
},
};
}
const CORPUS: DocRecord[] = [
doc(DOC_OWNER_PRIVATE, "owner-private"),
doc(DOC_USER_PRIVATE, "user-private"),
doc(DOC_GLOBAL, "global"),
];
function makeService(records: DocRecord[] = CORPUS) {
return {
searchDocuments: vi.fn(async () => records),
getMemories: vi.fn(async ({ offset = 0 }: { offset?: number }) =>
offset === 0 ? records : [],
),
getDocumentById: vi.fn(
async (id: UUID) => records.find((r) => r.id === id) ?? null,
),
countMemories: vi.fn(async () => records.length),
};
}
type RoomShape = { type: ChannelType; source?: string };
function makeRuntime(opts: {
service: ReturnType<typeof makeService>;
rooms?: Record<string, RoomShape>;
sendMessageToTarget?: ReturnType<typeof vi.fn>;
}) {
const rooms: Record<string, RoomShape> = opts.rooms ?? {
[DM_ROOM]: { type: ChannelType.DM },
[PUBLIC_ROOM]: { type: ChannelType.GROUP, source: "discord" },
[THREAD_ROOM]: { type: ChannelType.THREAD, source: "discord" },
};
return {
agentId: AGENT_ID,
getSetting: (k: string) =>
k === "ELIZA_ADMIN_ENTITY_ID" ? OWNER_ENTITY : undefined,
getService: (name: string) => (name === "documents" ? opts.service : null),
getRoom: async (id: UUID) => rooms[id] ?? null,
getMemoryById: async (id: UUID) =>
opts.service.getDocumentById(id) as unknown as Memory | null,
sendMessageToTarget:
opts.sendMessageToTarget ?? vi.fn(async () => ({ id: "sent-1" })),
} as never;
}
function msg(entityId: UUID, roomId: UUID): Memory {
return {
id: "00000000-0000-0000-0000-0000000000ff" as UUID,
entityId,
agentId: AGENT_ID,
roomId,
content: { text: "" },
createdAt: Date.now(),
} as Memory;
}
const call = async (
action: { handler: typeof searchKnowledgeAction.handler },
runtime: never,
message: Memory,
parameters: Record<string, unknown>,
callback?: (c: unknown, k: string) => Promise<void>,
) => {
const result = await action.handler(
runtime,
message,
undefined as never,
{ parameters } as never,
callback as never,
);
if (!result) {
throw new Error("Knowledge action did not return an action result.");
}
return result;
};
describe("SEARCH_KNOWLEDGE", () => {
it("free-text search surfaces readable items", async () => {
const runtime = makeRuntime({ service: makeService() });
const res = await call(
searchKnowledgeAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{
query: "body",
},
);
expect(res.success).toBe(true);
expect((res.data as { count: number }).count).toBeGreaterThan(0);
});
it("filter-only (no free text) surfacing works — the slice-3 tag search", async () => {
const runtime = makeRuntime({ service: makeService() });
const res = await call(
searchKnowledgeAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{
tags: ["media-format:pdf"],
},
);
expect(res.success).toBe(true);
expect((res.data as { count: number }).count).toBeGreaterThan(0);
});
it("free-text search composes with the help tag on fragment metadata", async () => {
const helpFragment = doc(DOC_GLOBAL, "global", {
documentId: DOC_GLOBAL,
title: "Getting started",
tags: ["help"],
});
helpFragment.content.text = "The chat pill opens Eliza from every view.";
const runtime = makeRuntime({ service: makeService([helpFragment]) });
const res = await call(
searchKnowledgeAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{
query: "chat pill",
tags: ["help"],
},
);
expect(res.success).toBe(true);
expect((res.data as { count: number }).count).toBe(1);
expect(
(res.data as { items: Array<{ title: string }> }).items[0]?.title,
).toBe("Getting started");
});
it("rejects a request with neither query nor any filter", async () => {
const runtime = makeRuntime({ service: makeService() });
const res = await call(
searchKnowledgeAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{},
);
expect(res.success).toBe(false);
expect((res.data as { error: string }).error).toBe("KNOWLEDGE_INVALID");
});
it("owner-private item does NOT surface into a PUBLIC active room even for the OWNER", async () => {
const runtime = makeRuntime({ service: makeService() });
const res = await call(
searchKnowledgeAction,
runtime,
msg(OWNER_ENTITY, PUBLIC_ROOM),
{
query: "body",
},
);
const items = (res.data as { items: Array<{ id: UUID }> }).items;
expect(items.some((i) => i.id === DOC_OWNER_PRIVATE)).toBe(false);
expect(items.some((i) => i.id === DOC_USER_PRIVATE)).toBe(false);
// global is fine
expect(items.some((i) => i.id === DOC_GLOBAL)).toBe(true);
});
it("in a DM active room the OWNER can see their owner-private items", async () => {
const runtime = makeRuntime({ service: makeService() });
const res = await call(
searchKnowledgeAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{
query: "body",
},
);
const items = (res.data as { items: Array<{ id: UUID }> }).items;
expect(items.some((i) => i.id === DOC_OWNER_PRIVATE)).toBe(true);
});
});
describe("ATTACH_TO_CHAT", () => {
it("fails (not success) when no chat callback is supplied", async () => {
const runtime = makeRuntime({ service: makeService() });
const res = await call(
attachToChatAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{ itemId: DOC_GLOBAL },
// no callback
);
expect(res.success).toBe(false);
expect((res.data as { error: string }).error).toBe("ATTACH_NO_CALLBACK");
});
it("attaches a readable global item when a callback is present", async () => {
const runtime = makeRuntime({ service: makeService() });
const cb = vi.fn(async () => {});
const res = await call(
attachToChatAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{ itemId: DOC_GLOBAL },
cb,
);
expect(res.success).toBe(true);
expect(cb).toHaveBeenCalledTimes(1);
});
it("refuses an owner-private item into a PUBLIC active room even with a callback", async () => {
const runtime = makeRuntime({ service: makeService() });
const cb = vi.fn(async () => {});
const res = await call(
attachToChatAction,
runtime,
msg(OWNER_ENTITY, PUBLIC_ROOM),
{ itemId: DOC_OWNER_PRIVATE },
cb,
);
expect(res.success).toBe(false);
expect((res.data as { error: string }).error).toBe("ATTACH_SCOPE_REFUSED");
expect(cb).not.toHaveBeenCalled();
});
});
describe("SEND_MEDIA_TO", () => {
it("refuses an owner-private item into a PUBLIC (GROUP) target room", async () => {
const send = vi.fn(async () => ({ id: "x" }));
const runtime = makeRuntime({
service: makeService(),
sendMessageToTarget: send,
});
const res = await call(
sendMediaToAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{
itemId: DOC_OWNER_PRIVATE,
roomId: PUBLIC_ROOM,
},
);
expect(res.success).toBe(false);
expect((res.data as { error: string }).error).toBe("SEND_SCOPE_REFUSED");
expect(send).not.toHaveBeenCalled();
});
it("refuses a private item into a THREAD (previously omitted from the public set)", async () => {
const send = vi.fn(async () => ({ id: "x" }));
const runtime = makeRuntime({
service: makeService(),
sendMessageToTarget: send,
});
const res = await call(
sendMediaToAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{
itemId: DOC_OWNER_PRIVATE,
roomId: THREAD_ROOM,
},
);
expect(res.success).toBe(false);
expect((res.data as { error: string }).error).toBe("SEND_SCOPE_REFUSED");
expect(send).not.toHaveBeenCalled();
});
it("fails CLOSED for an unresolvable target room (treated as public)", async () => {
const send = vi.fn(async () => ({ id: "x" }));
const runtime = makeRuntime({
service: makeService(),
rooms: {},
sendMessageToTarget: send,
});
const res = await call(
sendMediaToAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{
itemId: DOC_OWNER_PRIVATE,
roomId: PUBLIC_ROOM,
},
);
expect(res.success).toBe(false);
expect((res.data as { error: string }).error).toBe("SEND_SCOPE_REFUSED");
expect(send).not.toHaveBeenCalled();
});
it("sends a global item into a public room (dispatch fires, typed ok)", async () => {
const send = vi.fn(async () => ({ id: "sent-9" }));
const runtime = makeRuntime({
service: makeService(),
sendMessageToTarget: send,
});
const res = await call(
sendMediaToAction,
runtime,
msg(OWNER_ENTITY, DM_ROOM),
{
itemId: DOC_GLOBAL,
roomId: PUBLIC_ROOM,
},
);
expect(res.success).toBe(true);
expect(send).toHaveBeenCalledTimes(1);
expect((res.data as { dispatch: { ok: boolean } }).dispatch.ok).toBe(true);
});
});
+807
View File
@@ -0,0 +1,807 @@
/**
* The three globally-available knowledge actions (#13595) that let the agent do
* everything the Knowledge hub's UI affordances do — from any view, not just the
* Knowledge view:
*
* - SEARCH_KNOWLEDGE — semantic + facet search across the multimedia hub
* (docs, transcripts, ingested attachments), scope-walled so an owner-private
* item never surfaces to a public-room actor.
* - ATTACH_TO_CHAT — resolve a stored item to its content-addressed media URL
* and deliver it into the ACTIVE conversation via the handler callback (the
* agent-side of the reader/list "Attach to chat" button).
* - SEND_MEDIA_TO — DM/send a stored item's media to a target room through the
* runtime's connector dispatch (`sendMessageToTarget`), mapping the outcome
* to a typed `DispatchResult` and REFUSING an owner-/user-private item into a
* public room via the shared send wall.
*
* All three resolve items and enforce scope through `@elizaos/agent/api/document-access`
* — the same wall the REST routes use — so the two surfaces cannot drift. They
* live in `@elizaos/agent` (always-loaded) rather than the support-only
* `@elizaos/plugin-documents` so they are genuinely global.
*/
import {
type Action,
type ActionResult,
type Content,
type HandlerCallback,
type HandlerOptions,
type IAgentRuntime,
logger,
type Media,
type Memory,
type UUID,
} from "@elizaos/core";
import {
asRecord,
asUuid,
canReadDocumentMemory,
canSendDocumentToPublic,
canSurfaceDocumentInRoom,
type DocumentFilter,
documentMediaFormat,
documentTags,
matchesDocumentFilter,
type RouteActor,
type RouteActorRole,
roomIsPublicSurface,
} from "../api/document-access.ts";
import {
type DocumentSearchMode,
type DocumentsServiceLike,
getDocumentsService,
} from "../api/documents-service-loader.ts";
type DispatchResult =
| { ok: true; messageId?: string }
| {
ok: false;
reason:
| "disconnected"
| "rate_limited"
| "auth_expired"
| "unknown_recipient"
| "transport_error";
retryAfterMinutes?: number;
userActionable: boolean;
message?: string;
};
const MEDIA_URL_PREFIX = "/api/media/";
function fail(text: string, error: string): ActionResult {
return { success: false, text, data: { error } };
}
/**
* Classify the room a message came from / is targeted at as a public/community
* surface. Resolves the room and routes through the SINGLE canonical
* classifier (`roomIsPublicSurface`) so the send wall, the active-room surfacing
* wall, and the ingest spill guard all agree — including on THREAD, which a
* hand-rolled public-types allowlist previously omitted (shaw-codex review).
* An unresolvable room fails CLOSED (treated as public) so a missing room record
* can never be the reason a private item leaks.
*/
async function roomIsPublic(
runtime: IAgentRuntime,
roomId: UUID | undefined,
): Promise<boolean> {
if (!roomId) return true;
const room = await runtime.getRoom(roomId);
if (!room) return true;
return roomIsPublicSurface(room.type);
}
/**
* The requester's authorization role for the scope wall, derived from the
* triggering message: the configured owner entity is OWNER, the agent itself is
* AGENT, everyone else is USER. Mirrors the header-derived role the REST routes
* resolve so both surfaces enforce the same wall.
*/
function actorFromMessage(runtime: IAgentRuntime, message: Memory): RouteActor {
const agentId = runtime.agentId;
const ownerEntityId = asUuid(runtime.getSetting?.("ELIZA_ADMIN_ENTITY_ID"));
const entityId = (message.entityId ?? agentId) as UUID;
let role: RouteActorRole = "USER";
if (entityId === agentId) role = "AGENT";
else if (ownerEntityId && entityId === ownerEntityId) role = "OWNER";
return { entityId, role, ownerEntityId };
}
function parseSearchMode(value: unknown): DocumentSearchMode | undefined {
return value === "hybrid" || value === "vector" || value === "keyword"
? value
: undefined;
}
function normalizeTags(value: unknown): string[] | undefined {
if (Array.isArray(value)) {
const tags = value.filter(
(v): v is string => typeof v === "string" && v.trim().length > 0,
);
return tags.length > 0 ? tags : undefined;
}
if (typeof value === "string" && value.trim().length > 0) {
return value
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
}
return undefined;
}
function filtersFromParams(params: Record<string, unknown>): DocumentFilter {
const filters: DocumentFilter = {};
const scope = params.scope;
if (
scope === "global" ||
scope === "owner-private" ||
scope === "user-private" ||
scope === "agent-private"
) {
filters.scope = scope;
}
const roomId = asUuid(params.roomId);
if (roomId) filters.roomId = roomId;
const addedBy = asUuid(params.addedBy ?? params.sender);
if (addedBy) filters.addedBy = addedBy;
const mediaFormat =
typeof params.mediaFormat === "string"
? params.mediaFormat
: typeof params.format === "string"
? params.format
: undefined;
if (mediaFormat) filters.mediaFormat = mediaFormat.toLowerCase();
const tags = normalizeTags(params.tags);
if (tags) filters.tags = tags;
return filters;
}
/**
* Resolve a knowledge item to a `{ memory, mediaUrl, mimeType, title }` handle
* by document id OR by the sha256 media filename/url it was ingested from. The
* memory carries the scope metadata the wall reads; `mediaUrl` is the durable
* `/api/media/<sha256>` link the ingest pipeline stored on the record.
*/
async function resolveItem(
runtime: IAgentRuntime,
itemRef: string,
): Promise<{
memory: Memory;
mediaUrl?: string;
mimeType?: string;
title: string;
} | null> {
const { service } = await getDocumentsService(runtime);
const asId = asUuid(itemRef);
let memory: Memory | null = null;
if (asId) {
memory =
(await service?.getDocumentById?.(asId)) ??
(await runtime.getMemoryById(asId));
}
if (!memory) {
// Fall back to a sha256 media reference: an item may be named by the media
// it was ingested from rather than its document id.
const fileName = mediaFileNameFromRef(itemRef);
if (fileName && service) {
const docs = await service.getMemories({
tableName: "documents",
count: 1000,
});
memory =
docs.find((doc) => {
const meta = asRecord(doc.metadata);
return (
meta?.mediaFileName === fileName ||
(typeof meta?.mediaUrl === "string" &&
meta.mediaUrl.endsWith(fileName))
);
}) ?? null;
}
}
if (!memory) return null;
const meta = asRecord(memory.metadata);
const mediaUrl =
typeof meta?.mediaUrl === "string" ? meta.mediaUrl : undefined;
const mimeType =
typeof meta?.mediaMimeType === "string"
? meta.mediaMimeType
: typeof meta?.contentType === "string"
? meta.contentType
: undefined;
const title =
(typeof meta?.title === "string" && meta.title) ||
(typeof meta?.filename === "string" && meta.filename) ||
(typeof meta?.originalFilename === "string" && meta.originalFilename) ||
"knowledge item";
return { memory, mediaUrl, mimeType, title };
}
function mediaFileNameFromRef(ref: string): string | null {
const trimmed = ref.trim();
if (!trimmed) return null;
const withoutPrefix = trimmed.startsWith(MEDIA_URL_PREFIX)
? trimmed.slice(MEDIA_URL_PREFIX.length)
: (trimmed.split("/").pop() ?? trimmed);
// sha256 (64 hex) + extension, e.g. "ab12….pdf"
return /^[0-9a-f]{64}\.[a-z0-9]+$/i.test(withoutPrefix)
? withoutPrefix
: null;
}
function contentTypeFromMime(mime: string | undefined): Media["contentType"] {
if (!mime) return undefined;
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
return "document";
}
function mediaFromItem(item: {
memory: Memory;
mediaUrl?: string;
mimeType?: string;
title: string;
}): Media | null {
if (!item.mediaUrl) return null;
return {
id: (item.memory.id ?? crypto.randomUUID()) as string,
url: item.mediaUrl,
title: item.title,
source: "knowledge",
...(contentTypeFromMime(item.mimeType)
? { contentType: contentTypeFromMime(item.mimeType) }
: {}),
};
}
/** A retrieved knowledge record, from either the semantic or facet path. */
type KnowledgeSearchRow = {
id: UUID;
content: { text?: string };
similarity?: number;
metadata?: Record<string, unknown>;
};
/** How many document rows to pull per scan batch in the facet-list path. */
const DOCUMENT_SCAN_BATCH = 200;
/** Cap the facet scan so a huge corpus can't unbounded-loop an action. */
const DOCUMENT_SCAN_MAX = 2000;
/**
* Free-text retrieval path: semantic/hybrid search over the document store,
* scoped by the facet room/entity the same way the REST search route scopes it.
*/
async function searchByQuery(
service: DocumentsServiceLike,
runtime: IAgentRuntime,
actor: RouteActor,
query: string,
filters: DocumentFilter,
searchMode: DocumentSearchMode | undefined,
): Promise<KnowledgeSearchRow[]> {
const searchMessage: Memory = {
id: crypto.randomUUID() as UUID,
entityId: actor.entityId,
agentId: runtime.agentId,
roomId: (filters.roomId ?? runtime.agentId) as UUID,
content: { text: query },
createdAt: Date.now(),
};
const scope: { roomId?: UUID; entityId?: UUID } = {};
if (filters.scopedToEntityId) scope.entityId = filters.scopedToEntityId;
if (filters.roomId) scope.roomId = filters.roomId;
return service.searchDocuments(
searchMessage,
Object.keys(scope).length > 0 ? scope : undefined,
searchMode,
);
}
/**
* Filter-only retrieval path (#13595 tag/facet surfacing): scan the documents
* table and return every record, letting the caller's shared
* `matchesDocumentFilter` + wall filters narrow it. There is no meaningful
* embedding for an empty query, so a facet-only request lists by scan instead of
* semantic search — the same approach the REST `/api/documents` list route uses.
*/
async function listByFacets(
service: DocumentsServiceLike,
agentId: UUID,
_actor: RouteActor,
_filters: DocumentFilter,
): Promise<KnowledgeSearchRow[]> {
const rows: KnowledgeSearchRow[] = [];
let offset = 0;
while (offset < DOCUMENT_SCAN_MAX) {
const batch = await service.getMemories({
tableName: "documents",
count: DOCUMENT_SCAN_BATCH,
offset,
});
if (batch.length === 0) break;
for (const memory of batch) {
const meta = asRecord(memory.metadata);
// Only real document memories for this agent (mirrors isDocumentMemory).
if (meta?.type !== "document" && meta?.documentId === undefined) continue;
if (memory.agentId && memory.agentId !== agentId) continue;
rows.push({
id: memory.id as UUID,
content: { text: memory.content?.text },
metadata: meta,
});
}
if (batch.length < DOCUMENT_SCAN_BATCH) break;
offset += DOCUMENT_SCAN_BATCH;
}
return rows;
}
export const searchKnowledgeAction: Action = {
name: "SEARCH_KNOWLEDGE",
contexts: ["knowledge", "documents", "files", "media", "agent_internal"],
similes: [
"FIND_KNOWLEDGE",
"SEARCH_DOCS",
"SEARCH_FILES",
"SEARCH_TRANSCRIPTS",
"FIND_DOCUMENT",
"LOOKUP_KNOWLEDGE",
],
description:
"Search the multimedia knowledge hub (documents, transcripts, ingested attachments) by free text plus optional facets (roomId, sender/addedBy, mediaFormat, tags, scope). Returns matching items with their titles, media formats, and ids for follow-up ATTACH_TO_CHAT / SEND_MEDIA_TO. Scope-walled: owner-private items never surface to non-owner callers.",
descriptionCompressed:
"semantic + facet search over the knowledge hub; returns items (scope-walled)",
routingHint:
"search stored knowledge/documents/transcripts/attachments (by text or by room/sender/media-format/tag) -> SEARCH_KNOWLEDGE; to put a found item INTO this chat -> ATTACH_TO_CHAT; to DM/send it to a contact/room -> SEND_MEDIA_TO",
validate: async () => true,
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state,
options,
): Promise<ActionResult> => {
const params = ((options as HandlerOptions | undefined)?.parameters ??
{}) as Record<string, unknown>;
const query = typeof params.query === "string" ? params.query.trim() : "";
const { service, reason } = await getDocumentsService(runtime);
if (!service) {
return fail(
"Knowledge store is not available.",
`KNOWLEDGE_NO_SERVICE_${reason ?? "unknown"}`.toUpperCase(),
);
}
const actor = actorFromMessage(runtime, message);
const filters = filtersFromParams(params);
// Filter-only surfacing (#13595): a tag/room/sender/media-format query with
// no free text is valid — it lists every item matching those facets. Only a
// completely empty request (no text AND no facet) is rejected.
const hasFilter =
Boolean(filters.scope) ||
Boolean(filters.roomId) ||
Boolean(filters.addedBy) ||
Boolean(filters.mediaFormat) ||
Boolean(filters.tags && filters.tags.length > 0);
if (!query && !hasFilter) {
return fail(
"A search query or at least one filter (tags, room, sender, or media format) is required.",
"KNOWLEDGE_INVALID",
);
}
const limit =
typeof params.limit === "number" && Number.isFinite(params.limit)
? Math.max(1, Math.min(50, Math.floor(params.limit)))
: 10;
const searchMode = parseSearchMode(params.searchMode);
// The surfacing wall (#13974): SEARCH renders snippets INTO the active room.
// If that room is a public/community surface, owner-private and user-private
// items must be dropped even for an OWNER actor, or the private snippet
// spills to every participant. Private (DM-like) active rooms are open.
const activeRoomIsPublic = await roomIsPublic(
runtime,
message.roomId as UUID | undefined,
);
// Two retrieval paths that converge on the same facet + wall filtering:
// - free text -> semantic/hybrid searchDocuments
// - filter-only -> scan the documents table (no meaningful vector for "")
const raw = query
? await searchByQuery(service, runtime, actor, query, filters, searchMode)
: await listByFacets(service, runtime.agentId, actor, filters);
const items = raw
.filter((r) => matchesDocumentFilter(r, { ...filters, query: undefined }))
.filter((r) => canReadDocumentMemory(r, actor, filters))
.filter((r) => canSurfaceDocumentInRoom(r, activeRoomIsPublic).ok)
.slice(0, limit)
.map((r) => {
const meta = asRecord(r.metadata);
const tags = documentTags(meta);
return {
id: (meta?.documentId as UUID | undefined) ?? r.id,
title:
(typeof meta?.title === "string" && meta.title) ||
(typeof meta?.filename === "string" && meta.filename) ||
"Untitled",
mediaFormat: documentMediaFormat(meta, tags),
similarity: r.similarity,
snippet: (r.content.text ?? "").slice(0, 240),
};
});
const label = query ? `for "${query}"` : "for those filters";
const text = items.length
? `Found ${items.length} knowledge item(s) ${label}:\n${items
.map(
(it, i) =>
`${i + 1}. ${it.title}${
it.mediaFormat ? ` [${it.mediaFormat}]` : ""
}${it.snippet}`,
)
.join("\n")}`
: `No knowledge items match ${label}.`;
logger.info(
`[SEARCH_KNOWLEDGE] query="${query}" role=${actor.role} activeRoomPublic=${activeRoomIsPublic} matched=${items.length}`,
);
return {
success: true,
text,
userFacingText: text,
verifiedUserFacing: true,
data: { query, count: items.length, items },
};
},
parameters: [
{
name: "query",
description:
"Free-text search over knowledge titles + content. Optional: a facet-only search (tags/room/sender/media-format) with no free text lists every matching item.",
required: false,
schema: { type: "string" as const },
},
{
name: "mediaFormat",
description:
"Optional facet: image | audio | video | pdf | text | transcript | file",
required: false,
schema: { type: "string" as const },
},
{
name: "roomId",
description: "Optional source-room UUID facet",
required: false,
schema: { type: "string" as const },
},
{
name: "addedBy",
description: "Optional sender entity UUID facet",
required: false,
schema: { type: "string" as const },
},
{
name: "tags",
description: "Optional tag list (array or comma-separated)",
required: false,
schema: { type: "array" as const, items: { type: "string" as const } },
},
{
name: "scope",
description:
"Optional scope facet: global | owner-private | user-private | agent-private",
required: false,
schema: { type: "string" as const },
},
{
name: "searchMode",
description: "Optional retrieval mode: hybrid | vector | keyword",
required: false,
schema: { type: "string" as const },
},
{
name: "limit",
description: "Max results (default 10, max 50)",
required: false,
schema: { type: "number" as const },
},
],
};
export const attachToChatAction: Action = {
name: "ATTACH_TO_CHAT",
contexts: ["knowledge", "documents", "files", "media", "agent_internal"],
similes: [
"ATTACH_KNOWLEDGE",
"ADD_TO_CHAT",
"INSERT_ATTACHMENT",
"SHOW_FILE",
],
description:
"Attach a stored knowledge item's media into the ACTIVE conversation so the user sees it inline. Takes the item id or its sha256 media reference. Scope-walled: refuses an item the caller may not read.",
descriptionCompressed:
"put a stored knowledge item's media into this chat (id | sha256)",
routingHint:
"insert/attach a stored knowledge item into THIS chat -> ATTACH_TO_CHAT; to send it to someone else's DM/room -> SEND_MEDIA_TO; to find the item first -> SEARCH_KNOWLEDGE",
validate: async () => true,
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state,
options,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const params = ((options as HandlerOptions | undefined)?.parameters ??
{}) as Record<string, unknown>;
const itemRef =
typeof params.itemId === "string"
? params.itemId
: typeof params.sha256 === "string"
? params.sha256
: typeof params.id === "string"
? params.id
: "";
if (!itemRef.trim()) {
return fail(
"An itemId or sha256 reference is required.",
"ATTACH_INVALID",
);
}
const item = await resolveItem(runtime, itemRef.trim());
if (!item)
return fail(`No knowledge item for "${itemRef}".`, "ATTACH_NOT_FOUND");
const actor = actorFromMessage(runtime, message);
if (!canReadDocumentMemory(item.memory, actor)) {
return fail(
"You do not have access to that knowledge item.",
"ATTACH_FORBIDDEN",
);
}
// Surfacing wall (#13974): ATTACH delivers the media INTO the active room.
// A private item may be readable by an OWNER actor yet must never be
// attached into a public/community room where other participants see it.
const activeRoomIsPublic = await roomIsPublic(
runtime,
message.roomId as UUID | undefined,
);
const surface = canSurfaceDocumentInRoom(item.memory, activeRoomIsPublic);
if (!surface.ok) {
logger.warn(
`[ATTACH_TO_CHAT] refused ${surface.scope} item into public active room ${message.roomId}`,
);
return {
success: false,
text: surface.reason,
userFacingText: surface.reason,
verifiedUserFacing: true,
data: { error: "ATTACH_SCOPE_REFUSED", scope: surface.scope },
};
}
const media = mediaFromItem(item);
if (!media) {
return fail(
`"${item.title}" has no attachable media (text-only knowledge).`,
"ATTACH_NO_MEDIA",
);
}
// B1 (#13974): without a chat callback there is no channel to deliver the
// attachment on, so the action CANNOT have succeeded. Report a typed failure
// instead of a false success the model would relay as "attached".
if (!callback) {
return fail(
"No chat callback is available to attach into; ATTACH_TO_CHAT needs an active conversation. Use SEND_MEDIA_TO to deliver to a specific room.",
"ATTACH_NO_CALLBACK",
);
}
const content: Content = {
text: `Attached: ${item.title}`,
attachments: [media],
};
await callback(content, "ATTACH_TO_CHAT");
logger.info(
`[ATTACH_TO_CHAT] item="${item.title}" url=${media.url} role=${actor.role}`,
);
return {
success: true,
text: `Attached "${item.title}" to the chat.`,
userFacingText: `Attached "${item.title}" to the chat.`,
verifiedUserFacing: true,
data: { mediaUrl: media.url, title: item.title },
};
},
parameters: [
{
name: "itemId",
description: "Knowledge item document id, or its sha256 media reference",
required: true,
schema: { type: "string" as const },
},
],
};
export const sendMediaToAction: Action = {
name: "SEND_MEDIA_TO",
contexts: ["knowledge", "documents", "files", "media", "agent_internal"],
roleGate: { minRole: "OWNER" },
similes: ["SEND_KNOWLEDGE", "SEND_FILE_TO", "SHARE_MEDIA", "DM_MEDIA"],
description:
"Send a stored knowledge item's media to a target room/DM through the connector dispatch. Takes the item id (or sha256) and a target roomId. Enforces the send wall: an owner-private or user-private item is REFUSED into a public room. Returns a typed dispatch outcome.",
descriptionCompressed:
"send a stored knowledge item's media to a room/DM (scope-walled)",
routingHint:
"send/DM/share a stored knowledge item to a contact or room -> SEND_MEDIA_TO; to attach it to the CURRENT chat instead -> ATTACH_TO_CHAT",
validate: async () => true,
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state,
options,
): Promise<ActionResult> => {
const params = ((options as HandlerOptions | undefined)?.parameters ??
{}) as Record<string, unknown>;
const itemRef =
typeof params.itemId === "string"
? params.itemId
: typeof params.sha256 === "string"
? params.sha256
: "";
const targetRoomId = asUuid(
params.roomId ?? params.target ?? params.contact,
);
if (!itemRef.trim()) {
return fail("An itemId or sha256 reference is required.", "SEND_INVALID");
}
if (!targetRoomId) {
return fail("A target roomId is required.", "SEND_NO_TARGET");
}
const item = await resolveItem(runtime, itemRef.trim());
if (!item)
return fail(`No knowledge item for "${itemRef}".`, "SEND_NOT_FOUND");
const actor = actorFromMessage(runtime, message);
if (!canReadDocumentMemory(item.memory, actor)) {
return fail(
"You do not have access to that knowledge item.",
"SEND_FORBIDDEN",
);
}
const media = mediaFromItem(item);
if (!media) {
return fail(
`"${item.title}" has no sendable media (text-only knowledge).`,
"SEND_NO_MEDIA",
);
}
const targetRoom = await runtime.getRoom(targetRoomId);
// Classify via the canonical surface classifier (includes THREAD) and fail
// CLOSED for an unresolvable room: an unknown room is treated as public so a
// missing room record can never be the reason a private item is sent out.
const targetIsPublic = targetRoom
? roomIsPublicSurface(targetRoom.type)
: true;
const wall = canSendDocumentToPublic(item.memory, targetIsPublic);
if (!wall.ok) {
logger.warn(
`[SEND_MEDIA_TO] refused ${wall.scope} item into public room ${targetRoomId}`,
);
return {
success: false,
text: wall.reason,
userFacingText: wall.reason,
verifiedUserFacing: true,
data: { error: "SEND_SCOPE_REFUSED", scope: wall.scope },
};
}
const content: Content = {
text: `Shared: ${item.title}`,
attachments: [media],
};
const dispatch = await dispatchToRoom(
runtime,
targetRoom?.source ?? "",
{
source: targetRoom?.source ?? "",
roomId: targetRoomId,
...(targetRoom?.channelId ? { channelId: targetRoom.channelId } : {}),
...(targetRoom?.serverId ? { serverId: targetRoom.serverId } : {}),
},
content,
);
if (!dispatch.ok) {
logger.warn(
`[SEND_MEDIA_TO] dispatch failed reason=${dispatch.reason} room=${targetRoomId}`,
);
return {
success: false,
text: `Could not send "${item.title}": ${dispatch.message ?? dispatch.reason}.`,
data: { error: "SEND_DISPATCH_FAILED", dispatch },
};
}
logger.info(
`[SEND_MEDIA_TO] sent "${item.title}" to room ${targetRoomId} (msg ${dispatch.messageId ?? "?"})`,
);
return {
success: true,
text: `Sent "${item.title}" to the target.`,
userFacingText: `Sent "${item.title}" to the target.`,
verifiedUserFacing: true,
data: { dispatch, title: item.title },
};
},
parameters: [
{
name: "itemId",
description: "Knowledge item document id, or its sha256 media reference",
required: true,
schema: { type: "string" as const },
},
{
name: "roomId",
description: "Target room/DM UUID to send the media into",
required: true,
schema: { type: "string" as const },
},
],
};
/**
* Drive the runtime's connector send and map its outcome to a typed
* `DispatchResult`. `sendMessageToTarget` throws when no send handler is
* registered for the source (unknown recipient) or when the transport fails;
* this is the single J1 boundary translating those into the scheduling spine's
* dispatch vocabulary so callers branch on `reason`, never a bare boolean.
*/
async function dispatchToRoom(
runtime: IAgentRuntime,
source: string,
target: {
source: string;
roomId: UUID;
channelId?: string;
serverId?: string;
},
content: Content,
): Promise<DispatchResult> {
if (!source) {
return {
ok: false,
reason: "unknown_recipient",
userActionable: true,
message: "Target room has no connector source.",
};
}
try {
const sent = await runtime.sendMessageToTarget(target, content);
return { ok: true, ...(sent?.id ? { messageId: sent.id } : {}) };
} catch (err) {
// error-policy:J1 boundary translation — connector dispatch failures become
// a structured DispatchResult the action surfaces to the model/user.
const msg = err instanceof Error ? err.message : String(err);
const reason = /no send handler|unknown recipient/i.test(msg)
? "unknown_recipient"
: "transport_error";
return { ok: false, reason, userActionable: true, message: msg };
}
}
export const knowledgeActions: Action[] = [
searchKnowledgeAction,
attachToChatAction,
sendMediaToAction,
];
+403
View File
@@ -0,0 +1,403 @@
/**
* LOGS — polymorphic action for log inspection and runtime log-level control.
*
* search → GET /api/logs (filter by source/level/tag/since; HTTP because the log
* buffer lives on server state, not the runtime)
* delete → DELETE /api/logs (clears the in-memory log buffer; HTTP for the same reason)
* set_level → in-process per-room override on `runtime.logLevelOverrides`
*/
import type {
Action,
ActionResult,
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
} from "@elizaos/core";
import { elizaLogger, logger } from "@elizaos/core";
import { resolveServerOnlyPort } from "@elizaos/shared";
const LOGS_OPS = ["search", "delete", "set_level"] as const;
type LogsOp = (typeof LOGS_OPS)[number];
const LOG_LEVELS = ["trace", "debug", "info", "warn", "error"] as const;
type LogLevel = (typeof LOG_LEVELS)[number];
type RuntimeLoggerWithLevel = typeof logger & { level: string };
const SEARCH_LEVELS: readonly LogLevel[] = [
"debug",
"info",
"warn",
"error",
] as const;
interface LogsParams {
action?: LogsOp;
subaction?: LogsOp;
op?: LogsOp;
// search-only
source?: string;
level?: LogLevel;
tags?: string[];
since?: string;
limit?: number;
// set_level-only
roomId?: string;
}
function hasMutableLogLevel(
value: typeof logger,
): value is RuntimeLoggerWithLevel {
return "level" in value && typeof value.level === "string";
}
interface LogEntry {
timestamp: number;
level: string;
message: string;
source: string;
tags: string[];
}
interface LogsResponseShape {
entries: LogEntry[];
sources: string[];
tags: string[];
}
interface ClearResponseShape {
cleared?: number;
}
type RuntimeWithOverrides = IAgentRuntime & {
logLevelOverrides?: Map<string, string>;
};
function getApiBase(): string {
return `http://localhost:${resolveServerOnlyPort(process.env)}`;
}
function parseSince(since: string | undefined): number | undefined {
if (!since) return undefined;
const numeric = Number(since);
if (Number.isFinite(numeric) && numeric > 0) {
return numeric;
}
const parsed = Date.parse(since);
return Number.isNaN(parsed) ? undefined : parsed;
}
function formatLogPreview(entries: LogEntry[], limit: number): string {
const slice = entries.slice(0, limit);
if (slice.length === 0) {
return "No log entries match.";
}
return slice
.map((entry) => {
const ts = new Date(entry.timestamp).toISOString();
const tagPart = entry.tags.length > 0 ? ` [${entry.tags.join(",")}]` : "";
return `${ts} ${entry.level.toUpperCase().padEnd(5)} ${entry.source}${tagPart}: ${entry.message}`;
})
.join("\n");
}
function failure(text: string, code: string, extra?: object): ActionResult {
return {
success: false,
text,
values: { error: code },
data: { actionName: "LOGS", ...extra },
error: text,
};
}
async function searchLogs(params: LogsParams): Promise<ActionResult> {
const limit = Math.max(1, Math.min(200, Math.floor(params.limit ?? 50)));
const tagFilter = (params.tags ?? []).map((t) => t.trim()).filter(Boolean);
const sinceMs = parseSince(params.since);
const search = new URLSearchParams();
if (params.source) search.set("source", params.source);
if (params.level && SEARCH_LEVELS.includes(params.level)) {
search.set("level", params.level);
}
// Server only filters on a single tag — additional tags are intersected client-side.
if (tagFilter.length > 0) search.set("tag", tagFilter[0]);
if (sinceMs !== undefined) search.set("since", String(sinceMs));
const qs = search.toString();
const url = `${getApiBase()}/api/logs${qs ? `?${qs}` : ""}`;
const resp = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!resp.ok) {
return failure(
`Failed to load logs: HTTP ${resp.status}`,
"LOGS_SEARCH_FAILED",
);
}
const data = (await resp.json()) as LogsResponseShape;
const entries =
tagFilter.length > 1
? data.entries.filter((entry) =>
tagFilter.every((tag) => entry.tags.includes(tag)),
)
: data.entries;
return {
success: true,
text: formatLogPreview(entries, limit),
values: { count: entries.length, totalSources: data.sources.length },
data: {
actionName: "LOGS",
op: "search",
entries: entries.slice(0, limit),
sources: data.sources,
tags: data.tags,
},
};
}
async function deleteLogs(): Promise<ActionResult> {
const resp = await fetch(`${getApiBase()}/api/logs`, {
method: "DELETE",
signal: AbortSignal.timeout(10_000),
});
if (!resp.ok) {
return failure(
`Failed to clear logs: HTTP ${resp.status}`,
"LOGS_DELETE_FAILED",
);
}
const data = (await resp.json().catch(() => ({}))) as ClearResponseShape;
const cleared =
typeof data.cleared === "number" && Number.isFinite(data.cleared)
? data.cleared
: 0;
return {
success: true,
text: `Cleared ${cleared} log entries.`,
values: { cleared },
data: { actionName: "LOGS", op: "delete", cleared },
};
}
function setLogLevel(
runtime: IAgentRuntime,
message: Memory,
params: LogsParams,
callback: HandlerCallback | undefined,
): ActionResult {
const requested =
typeof params.level === "string" ? params.level.toLowerCase() : "";
if (!LOG_LEVELS.includes(requested as LogLevel)) {
if (callback) {
callback({
text: `Please specify a valid log level: ${LOG_LEVELS.join(", ")}.`,
action: "LOGS_SET_LEVEL_FAILED",
});
}
return failure("Invalid log level.", "LOGS_SET_LEVEL_FAILED", {
validLevels: [...LOG_LEVELS],
});
}
const level = requested as LogLevel;
const targetRoomId = params.roomId ?? message.roomId;
const overrides = (runtime as RuntimeWithOverrides).logLevelOverrides;
if (!overrides) {
if (callback) {
callback({
text: "Dynamic log levels are not supported by this runtime version.",
action: "LOGS_SET_LEVEL_FAILED",
});
}
return failure(
"Dynamic log levels are not supported by this runtime version.",
"LOGS_SET_LEVEL_FAILED",
);
}
overrides.set(String(targetRoomId), level);
// Also raise the process-wide pino logger so emitted records are not filtered
// out before they reach the per-room override listener.
if (hasMutableLogLevel(logger)) {
logger.level = level;
}
elizaLogger.info(`[LOGS] level set to ${level} for room ${targetRoomId}`);
if (callback) {
callback({
text: `Log level changed to **${level.toUpperCase()}** for this room.`,
action: "LOGS_SET_LEVEL",
});
}
return {
success: true,
text: `Log level changed to ${level.toUpperCase()} for this room.`,
values: { level },
data: { actionName: "LOGS", op: "set_level", level, roomId: targetRoomId },
};
}
export const logsAction: Action = {
name: "LOGS",
contexts: ["admin", "agent_internal", "settings"],
roleGate: { minRole: "OWNER" },
similes: [
// Old leaf action names
"SEARCH_LOGS",
"DELETE_LOGS",
"LOG_LEVEL",
// Common aliases
"QUERY_LOGS",
"READ_LOGS",
"GET_LOGS",
"INSPECT_LOGS",
"VIEW_LOGS",
"LOOKUP_LOGS",
"CLEAR_LOGS",
"WIPE_LOGS",
"RESET_LOGS",
"EMPTY_LOGS",
"SET_LOG_LEVEL",
"CHANGE_LOG_LEVEL",
"DEBUG_MODE",
"SET_DEBUG",
"CONFIGURE_LOGGING",
],
description:
"Polymorphic log control: action='search' tails the in-memory log buffer (filterable by source/level/tag/since), action='delete' clears that buffer, action='set_level' overrides the per-room log level (trace/debug/info/warn/error).",
descriptionCompressed:
"search/delete in-mem agent logs or set_level per-room owner-only",
validate: async () => true,
handler: async (
runtime,
message,
_state,
options,
callback,
): Promise<ActionResult> => {
const params =
((options as HandlerOptions | undefined)?.parameters as
| LogsParams
| undefined) ?? {};
const op = params.action ?? params.subaction ?? params.op;
if (!op || !LOGS_OPS.includes(op)) {
if (callback) {
callback({
text: `Unknown LOGS action. Use one of: ${LOGS_OPS.join(", ")}.`,
action: "LOGS_INVALID",
});
}
return failure(`Unknown LOGS op: ${String(op)}`, "LOGS_INVALID", {
validOps: [...LOGS_OPS],
});
}
if (op === "set_level") {
return setLogLevel(runtime, message, params, callback);
}
try {
return op === "search" ? await searchLogs(params) : await deleteLogs();
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[LOGS] ${op} failed: ${msg}`);
const code =
op === "search" ? "LOGS_SEARCH_FAILED" : "LOGS_DELETE_FAILED";
return failure(`Failed to ${op} logs: ${msg}`, code);
}
},
parameters: [
{
name: "action",
description: "Operation: search | delete | set_level.",
required: true,
schema: { type: "string" as const, enum: [...LOGS_OPS] },
},
{
name: "source",
description:
"[search] Optional source filter (e.g. agent, server, plugins).",
required: false,
schema: { type: "string" as const },
},
{
name: "level",
description:
"[search] Filter by log level (debug/info/warn/error). [set_level] New level to set (trace/debug/info/warn/error).",
required: false,
schema: { type: "string" as const, enum: [...LOG_LEVELS] },
},
{
name: "tags",
description:
"[search] Optional tag filter list. Server applies the first; remaining are intersected client-side.",
required: false,
schema: { type: "array" as const, items: { type: "string" as const } },
},
{
name: "since",
description:
"[search] Optional ISO timestamp or epoch-ms cutoff. Only entries at or after this time are returned.",
required: false,
schema: { type: "string" as const },
},
{
name: "limit",
description:
"[search] Maximum entries to include in the preview (1-200).",
required: false,
schema: { type: "number" as const },
},
{
name: "roomId",
description:
"[set_level] Optional room id to scope the override; defaults to the active room.",
required: false,
schema: { type: "string" as const },
},
],
examples: [
[
{
name: "{{name1}}",
content: { text: "Show me the last 20 error logs from the agent." },
},
{
name: "{{agentName}}",
content: {
text: "Showing recent agent error log entries...",
action: "LOGS",
},
},
],
[
{
name: "{{name1}}",
content: { text: "Clear the debug logs from the agent buffer." },
},
{
name: "{{agentName}}",
content: {
text: "Cleared the in-memory log buffer.",
action: "LOGS",
},
},
],
[
{
name: "{{name1}}",
content: { text: "Set log level to debug" },
},
{
name: "{{agentName}}",
content: {
text: "Log level changed to **DEBUG** for this room.",
action: "LOGS",
},
},
],
],
};
+472
View File
@@ -0,0 +1,472 @@
/**
* MEMORY action handler tests against a deterministic in-memory runtime that
* mimics the SQL adapter's contract: uuid params are type-checked like a
* postgres uuid column (bad ids throw a drizzle-style error carrying the raw
* SQL) and the relationships service exposes identity-cluster membership.
*/
import type { ActionResult, IAgentRuntime, Memory, UUID } from "@elizaos/core";
import { normalizeActionIdentifier } from "@elizaos/core";
import { describe, expect, it } from "vitest";
import { memoryAction } from "./memories";
const AGENT_ID = "00000000-0000-0000-0000-0000000000aa" as UUID;
const USER_ID = "00000000-0000-0000-0000-0000000000bb" as UUID;
const ROOM_ID = "00000000-0000-0000-0000-0000000000cc" as UUID;
const SIBLING_ID = "00000000-0000-0000-0000-0000000000dd" as UUID;
const OTHER_USER_ID = "00000000-0000-0000-0000-0000000000ee" as UUID;
type StoredRow = { memory: Memory; tableName: string; unique?: boolean };
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function assertUuidOrThrowLikeDrizzle(value: unknown, column: string): void {
if (value == null) return;
if (typeof value === "string" && UUID_RE.test(value)) return;
throw new Error(
`Failed query: select "id", "content" from "memories" where "${column}" = $1 -- params: ["${String(value)}"]; invalid input syntax for type uuid`,
);
}
function makeRuntime(options?: {
clusters?: Partial<Record<string, UUID[]>>;
}): { runtime: IAgentRuntime; rows: StoredRow[] } {
const rows: StoredRow[] = [];
const runtime = {
agentId: AGENT_ID,
character: { name: "Eliza" },
getService: (name: string) => {
if (name === "relationships" && options?.clusters) {
return {
getMemberEntityIds: async (entityId: UUID) =>
options.clusters?.[entityId] ?? [],
};
}
return null;
},
createMemory: async (
memory: Memory,
tableName: string,
unique?: boolean,
) => {
rows.push({ memory, tableName, unique });
return memory.id;
},
getMemories: async (params: {
tableName: string;
roomId?: UUID;
entityId?: UUID;
}) => {
assertUuidOrThrowLikeDrizzle(params.roomId, "roomId");
assertUuidOrThrowLikeDrizzle(params.entityId, "entityId");
return rows
.filter((row) => row.tableName === params.tableName)
.filter((row) => !params.roomId || row.memory.roomId === params.roomId)
.filter(
(row) => !params.entityId || row.memory.entityId === params.entityId,
)
.map((row) => row.memory);
},
getMemoryById: async (memoryId: UUID) => {
assertUuidOrThrowLikeDrizzle(memoryId, "id");
return rows.find((row) => row.memory.id === memoryId)?.memory ?? null;
},
deleteMemory: async (memoryId: UUID) => {
assertUuidOrThrowLikeDrizzle(memoryId, "id");
const index = rows.findIndex((row) => row.memory.id === memoryId);
if (index >= 0) rows.splice(index, 1);
},
} as unknown as IAgentRuntime;
return { runtime, rows };
}
function seedFact(
rows: StoredRow[],
fields: { text: string; entityId: UUID; roomId?: UUID },
): UUID {
const id = crypto.randomUUID() as UUID;
rows.push({
memory: {
id,
entityId: fields.entityId,
agentId: AGENT_ID,
roomId: fields.roomId ?? ROOM_ID,
content: { text: fields.text },
createdAt: Date.now(),
} as Memory,
tableName: "facts",
});
return id;
}
function makeMessage(): Memory {
return {
id: crypto.randomUUID() as UUID,
entityId: USER_ID,
agentId: AGENT_ID,
roomId: ROOM_ID,
content: { text: "remember this: my favorite color is blue" },
createdAt: Date.now(),
} as Memory;
}
type TestParams = Record<string, string | string[] | number | boolean>;
async function runAction(
runtime: IAgentRuntime,
message: Memory,
parameters: TestParams,
): Promise<ActionResult> {
const result = await memoryAction.handler(runtime, message, undefined, {
parameters,
});
if (!result) throw new Error("handler returned no result");
return result;
}
async function runCreate(
runtime: IAgentRuntime,
message: Memory,
parameters: TestParams,
): Promise<ActionResult> {
return runAction(runtime, message, { action: "create", ...parameters });
}
describe("MEMORY op:create", () => {
it("persists to the facts table scoped to the conversation room and speaker", async () => {
const { runtime, rows } = makeRuntime();
const message = makeMessage();
const result = await runCreate(runtime, message, {
text: "the user's favorite color is blue",
kind: "preference",
tags: ["color"],
});
expect(result.success).toBe(true);
expect(rows).toHaveLength(1);
const { memory, tableName, unique } = rows[0];
expect(tableName).toBe("facts");
expect(unique).toBe(true);
expect(memory.entityId).toBe(USER_ID);
expect(memory.roomId).toBe(ROOM_ID);
expect(memory.content.text).toBe("the user's favorite color is blue");
const metadata = memory.metadata as Record<string, unknown>;
expect(metadata.kind).toBe("durable");
expect(metadata.category).toBe("preference");
expect(metadata.keywords).toEqual(["color"]);
expect(metadata.confidence).toBeGreaterThan(0.7);
expect(metadata.verificationStatus).toBe("self_reported");
});
it("is retrievable by the FACTS provider candidate queries", async () => {
// The FACTS provider builds two candidate pools over the `facts` table:
// one scoped to the conversation room, one to the speaker's entity ids.
// The old write (agent-scoped `memories` table in a synthetic room)
// matched neither, so the saved fact could never be recalled.
const { runtime } = makeRuntime();
await runCreate(runtime, makeMessage(), {
text: "the user's dog is named Jeff",
});
const roomPool = await runtime.getMemories({
tableName: "facts",
roomId: ROOM_ID,
});
expect(roomPool).toHaveLength(1);
expect(roomPool[0].content.text).toBe("the user's dog is named Jeff");
const entityPool = await runtime.getMemories({
tableName: "facts",
entityId: USER_ID,
});
expect(entityPool).toHaveLength(1);
expect(entityPool[0].content.text).toBe("the user's dog is named Jeff");
});
it("is found by MEMORY op:search after create", async () => {
const { runtime } = makeRuntime();
const message = makeMessage();
await runCreate(runtime, message, {
text: "the user's favorite color is blue",
});
const result = await runAction(runtime, message, {
action: "search",
query: "favorite color",
});
expect(result.success).toBe(true);
const data = result.data as { memories: Array<{ text: string }> };
expect(data.memories).toHaveLength(1);
expect(data.memories[0].text).toBe("the user's favorite color is blue");
});
it("falls back to the agent entity when the message has none", async () => {
const { runtime, rows } = makeRuntime();
const message = {
...makeMessage(),
entityId: undefined,
} as unknown as Memory;
const result = await runCreate(runtime, message, { text: "agent note" });
expect(result.success).toBe(true);
expect(rows[0].memory.entityId).toBe(AGENT_ID);
});
it("rejects an empty text", async () => {
const { runtime, rows } = makeRuntime();
const result = await runCreate(runtime, makeMessage(), { text: " " });
expect(result.success).toBe(false);
expect(rows).toHaveLength(0);
});
});
describe("MEMORY op:search identity-cluster expansion", () => {
it("finds a fact stored under a cluster sibling of the requested entityId", async () => {
// Live failure shape: the FACTS provider surfaced "nubs plays guitar"
// (stored under sibling entity ids) while MEMORY search on the primary
// entityId reported "Found 0 (total 0)". Search must read through the
// same identity-cluster expansion the provider uses.
const { runtime, rows } = makeRuntime({
clusters: { [USER_ID]: [SIBLING_ID] },
});
seedFact(rows, { text: "nubs plays guitar", entityId: SIBLING_ID });
const result = await runAction(runtime, makeMessage(), {
action: "search",
entityId: USER_ID,
query: "guitar",
});
expect(result.success).toBe(true);
const data = result.data as { memories: Array<{ text: string }> };
expect(data.memories).toHaveLength(1);
expect(data.memories[0].text).toBe("nubs plays guitar");
});
it("still filters to the entity's own rows when no cluster resolver exists", async () => {
const { runtime, rows } = makeRuntime();
seedFact(rows, { text: "nubs plays guitar", entityId: USER_ID });
seedFact(rows, { text: "someone else surfs", entityId: SIBLING_ID });
const result = await runAction(runtime, makeMessage(), {
action: "search",
entityId: USER_ID,
});
expect(result.success).toBe(true);
const data = result.data as { memories: Array<{ text: string }> };
expect(data.memories).toHaveLength(1);
expect(data.memories[0].text).toBe("nubs plays guitar");
});
});
describe("MEMORY uuid validation", () => {
it('handles roomId "general" without running the query or leaking SQL', async () => {
// The mock getMemories throws a drizzle-style error (raw SQL included)
// for any non-uuid id, so a passing test proves the query never ran.
const { runtime, rows } = makeRuntime();
seedFact(rows, { text: "nubs plays guitar", entityId: USER_ID });
const result = await runAction(runtime, makeMessage(), {
action: "search",
roomId: "general",
});
expect(result.success).toBe(false);
expect((result.data as { error: string }).error).toBe(
"MEMORY_INVALID_UUID",
);
expect(result.text).toContain('roomId "general"');
expect(result.text?.toLowerCase()).not.toContain("failed query");
expect(result.text?.toLowerCase()).not.toContain("select");
});
it("handles a partial-uuid entityId on search cleanly", async () => {
const { runtime } = makeRuntime();
const result = await runAction(runtime, makeMessage(), {
action: "search",
entityId: "0b8db237",
});
expect(result.success).toBe(false);
expect((result.data as { error: string }).error).toBe(
"MEMORY_INVALID_UUID",
);
expect(result.text?.toLowerCase()).not.toContain("failed query");
});
it("handles a partial-uuid memoryId on delete cleanly", async () => {
const { runtime, rows } = makeRuntime();
seedFact(rows, { text: "nubs plays guitar", entityId: USER_ID });
const result = await runAction(runtime, makeMessage(), {
action: "delete",
memoryId: "82bdd9bb",
confirm: true,
});
expect(result.success).toBe(false);
expect((result.data as { error: string }).error).toBe(
"MEMORY_INVALID_UUID",
);
expect(result.text?.toLowerCase()).not.toContain("failed query");
expect(rows).toHaveLength(1);
});
});
describe("MEMORY op:delete by query", () => {
it("resolves the fact by text and deletes every duplicate row of it", async () => {
// Reflection dedup failures store the same fact several times (live: six
// copies of "nubs plays guitar" across two sibling entity ids). One
// logical fact -> all rows removed.
const { runtime, rows } = makeRuntime({
clusters: { [USER_ID]: [SIBLING_ID] },
});
seedFact(rows, { text: "nubs plays guitar", entityId: SIBLING_ID });
seedFact(rows, { text: "nubs plays guitar", entityId: SIBLING_ID });
seedFact(rows, { text: "nubs plays guitar", entityId: USER_ID });
seedFact(rows, { text: "nubs lives on a boat", entityId: USER_ID });
const result = await runAction(runtime, makeMessage(), {
action: "delete",
query: "nubs plays guitar",
entityId: USER_ID,
confirm: true,
});
expect(result.success).toBe(true);
expect((result.values as { deletedCount: number }).deletedCount).toBe(3);
expect(rows).toHaveLength(1);
expect(rows[0].memory.content.text).toBe("nubs lives on a boat");
});
it("scopes delete-by-query to the requesting user's identity cluster", async () => {
// Multi-user room: another entity holds a fact with the exact same text.
// "Forget that I play guitar" from USER_ID must remove only USER_ID's
// row — a text-only match would silently delete the other user's fact.
const { runtime, rows } = makeRuntime();
seedFact(rows, { text: "i play guitar", entityId: USER_ID });
seedFact(rows, { text: "i play guitar", entityId: OTHER_USER_ID });
const result = await runAction(runtime, makeMessage(), {
action: "delete",
query: "i play guitar",
confirm: true,
});
expect(result.success).toBe(true);
expect((result.values as { deletedCount: number }).deletedCount).toBe(1);
expect(rows).toHaveLength(1);
expect(rows[0].memory.entityId).toBe(OTHER_USER_ID);
});
it("deletes cluster-sibling rows of the requester but not a third user's", async () => {
// The requester scope is identity-cluster expanded (getRelatedEntityIds),
// so duplicates stored under the requester's sibling ids are still one
// logical fact — while an unrelated user's identical text stays out.
const { runtime, rows } = makeRuntime({
clusters: { [USER_ID]: [SIBLING_ID] },
});
seedFact(rows, { text: "i play guitar", entityId: USER_ID });
seedFact(rows, { text: "i play guitar", entityId: SIBLING_ID });
seedFact(rows, { text: "i play guitar", entityId: OTHER_USER_ID });
const result = await runAction(runtime, makeMessage(), {
action: "delete",
query: "i play guitar",
confirm: true,
});
expect(result.success).toBe(true);
expect((result.values as { deletedCount: number }).deletedCount).toBe(2);
expect(rows).toHaveLength(1);
expect(rows[0].memory.entityId).toBe(OTHER_USER_ID);
});
it("refuses an ambiguous query matching distinct memories and lists ids", async () => {
const { runtime, rows } = makeRuntime();
const idA = seedFact(rows, {
text: "nubs plays guitar",
entityId: USER_ID,
});
const idB = seedFact(rows, {
text: "nubs plays guitar hero on fridays",
entityId: USER_ID,
});
const result = await runAction(runtime, makeMessage(), {
action: "delete",
query: "plays guitar",
confirm: true,
});
expect(result.success).toBe(false);
expect((result.data as { error: string }).error).toBe(
"MEMORY_AMBIGUOUS_QUERY",
);
expect(result.text).toContain(idA);
expect(result.text).toContain(idB);
expect(rows).toHaveLength(2);
});
it("returns a clean not-found when no stored memory matches", async () => {
const { runtime, rows } = makeRuntime();
seedFact(rows, { text: "nubs plays guitar", entityId: USER_ID });
const result = await runAction(runtime, makeMessage(), {
action: "delete",
query: "rides a unicycle",
confirm: true,
});
expect(result.success).toBe(false);
expect((result.data as { error: string }).error).toBe("MEMORY_NOT_FOUND");
expect(rows).toHaveLength(1);
});
it("still requires confirm:true before deleting by query", async () => {
const { runtime, rows } = makeRuntime();
seedFact(rows, { text: "nubs plays guitar", entityId: USER_ID });
const result = await runAction(runtime, makeMessage(), {
action: "delete",
query: "nubs plays guitar",
});
expect(result.success).toBe(false);
expect((result.data as { error: string }).error).toBe(
"MEMORY_CONFIRMATION_REQUIRED",
);
expect(rows).toHaveLength(1);
});
});
describe("MEMORY routing aliases", () => {
it("resolves planner-generated LIST_MEMORIES / SEARCH_MEMORY to MEMORY", () => {
// Mirrors buildRuntimeActionLookup (core services/message.ts): canonical
// action names claim their normalized identifier first, then similes fill
// the remaining slots. Without these aliases a listing intent fell
// through to VIEWS and errored.
const lookup = new Map<string, string>();
const actions = [memoryAction];
for (const action of actions) {
const normalized = normalizeActionIdentifier(action.name);
if (normalized && !lookup.has(normalized))
lookup.set(normalized, action.name);
}
for (const action of actions) {
for (const simile of action.similes ?? []) {
const normalized = normalizeActionIdentifier(simile);
if (normalized && !lookup.has(normalized))
lookup.set(normalized, action.name);
}
}
expect(lookup.get(normalizeActionIdentifier("LIST_MEMORIES"))).toBe(
"MEMORY",
);
expect(lookup.get(normalizeActionIdentifier("SEARCH_MEMORY"))).toBe(
"MEMORY",
);
});
});
+670
View File
@@ -0,0 +1,670 @@
/**
* MEMORY action: model-driven create/search/update/delete over the agent's
* stored memories. Reads MUST match the scope the FACTS provider uses —
* identity-cluster-expanded entity ids — or a fact the provider surfaces
* reads back as "0 stored items" here, and deletion becomes unreachable.
* All model-supplied ids are parsed before touching the database so a bad
* id becomes a clean handled result, never a raw SQL error in model context.
*/
import type {
Action,
ActionResult,
HandlerOptions,
IAgentRuntime,
Memory,
UUID,
} from "@elizaos/core";
import {
MemoryType as CoreMemoryType,
getRelatedEntityIds,
logger,
ModelType,
validateUuid,
} from "@elizaos/core";
const MEMORY_OPS = ["create", "search", "update", "delete"] as const;
type MemoryOp = (typeof MEMORY_OPS)[number];
const MEMORY_TYPES = ["messages", "memories", "facts", "documents"] as const;
type MemoryType = (typeof MEMORY_TYPES)[number];
interface MemoryParams {
action?: MemoryOp;
op?: MemoryOp;
subaction?: MemoryOp;
text?: string;
kind?: string;
tags?: string[];
type?: MemoryType;
entityId?: string;
roomId?: string;
query?: string;
limit?: number;
memoryId?: string;
confirm?: boolean;
}
interface MemoryListItem {
id: string;
type: MemoryType;
text: string;
entityId: string | null;
roomId: string | null;
agentId: string | null;
createdAt: number;
}
function fail(text: string, error: string): ActionResult {
return { success: false, text, data: { error } };
}
type UuidParamName = "entityId" | "roomId" | "memoryId";
type ParsedUuidParam =
| { ok: true; id: UUID | undefined }
| { ok: false; result: ActionResult };
// error-policy:J3 model-supplied ids arrive as free text ("general", partial
// uuids); parsing before any query keeps drizzle from throwing — and from
// echoing the failed SQL statement back into model context.
function parseUuidParam(
value: string | undefined,
name: UuidParamName,
): ParsedUuidParam {
const trimmed = value?.trim();
if (!trimmed) return { ok: true, id: undefined };
const id = validateUuid(trimmed);
if (!id) {
return {
ok: false,
result: fail(
`${name} "${trimmed}" is not a valid UUID. Omit it or use an id from a previous search result.`,
"MEMORY_INVALID_UUID",
),
};
}
return { ok: true, id };
}
function normalizeMemoryOp(params: MemoryParams): MemoryOp | undefined {
const candidate = params.action ?? params.subaction ?? params.op;
return candidate && MEMORY_OPS.includes(candidate) ? candidate : undefined;
}
function clampLimit(value: number | undefined, fallback: number): number {
if (value == null) return fallback;
return Math.max(1, Math.min(200, Math.floor(value)));
}
function scoreText(text: string, query: string): number {
const t = text.toLowerCase();
const q = query.toLowerCase();
if (!t || !q) return 0;
const terms = q
.split(/\s+/)
.map((s) => s.trim())
.filter((s) => s.length >= 2);
const whole = t.includes(q) ? 1 : 0;
if (terms.length === 0) return whole;
let matches = 0;
for (const term of terms) if (t.includes(term)) matches += 1;
return whole + matches / terms.length;
}
function toListItem(memory: Memory, type: MemoryType): MemoryListItem {
const content = memory.content as Record<string, unknown> | undefined;
return {
id: memory.id ?? "",
type,
text: (content?.text as string) ?? "",
entityId: memory.entityId,
roomId: memory.roomId,
agentId: memory.agentId ?? null,
createdAt: memory.createdAt ?? 0,
};
}
/**
* Confidence for facts the user explicitly asked to store. Higher than the
* reflection extractor's 0.7 — "remember this" is a direct instruction, not
* an inferred claim.
*/
const EXPLICIT_MEMORY_CONFIDENCE = 0.95;
async function doCreate(
runtime: IAgentRuntime,
message: Memory,
params: MemoryParams,
): Promise<ActionResult> {
const text = typeof params.text === "string" ? params.text.trim() : "";
if (!text) return fail("text is required.", "MEMORY_MISSING_TEXT");
const kind =
typeof params.kind === "string" && params.kind.trim()
? params.kind.trim()
: undefined;
const tags = Array.isArray(params.tags)
? params.tags.filter(
(t): t is string => typeof t === "string" && t.trim().length > 0,
)
: [];
const agentId = runtime.agentId as UUID;
const memoryId = crypto.randomUUID() as UUID;
const createdAt = Date.now();
// Persist where the recall read path looks. The FACTS provider — the only
// default-on read path for user facts — scans the `facts` table scoped to
// the conversation room and the speaker's entity ids. The previous write
// (agent-scoped `memories` table in a synthetic manual-memories room) was
// invisible to it, so the agent acked "I'll remember" and then denied
// knowing the fact on the next turn.
await runtime.createMemory(
{
id: memoryId,
entityId: message.entityId ?? agentId,
agentId,
roomId: message.roomId,
content: { text, source: "MEMORY" },
metadata: {
type: CoreMemoryType.CUSTOM,
source: "MEMORY",
kind: "durable",
category: kind ?? "user_note",
confidence: EXPLICIT_MEMORY_CONFIDENCE,
keywords: tags,
verificationStatus: "self_reported",
lastConfirmedAt: new Date(createdAt).toISOString(),
},
createdAt,
} as Memory,
"facts",
true,
);
return {
success: true,
text: `Stored memory ${memoryId}.`,
values: { memoryId, kind: kind ?? null, tagCount: tags.length },
data: {
actionName: "MEMORY",
op: "create" as const,
memoryId,
text,
kind: kind ?? null,
tags,
createdAt,
},
};
}
interface MemoryCandidate {
memory: Memory;
type: MemoryType;
}
/**
* Shared read scope for search and delete-by-query. The entity filter is
* identity-cluster expanded via getRelatedEntityIds — the same expansion the
* FACTS provider applies — so a fact stored under a cluster sibling of the
* requested entityId is in scope. A strict-equality filter here made the same
* fact the provider had just surfaced report as "0 stored items".
*/
async function collectCandidates(
runtime: IAgentRuntime,
scope: {
type?: MemoryType;
entityId?: UUID;
roomId?: UUID;
query?: string;
limit: number;
},
): Promise<MemoryCandidate[]> {
const tables: readonly MemoryType[] = scope.type
? [scope.type]
: MEMORY_TYPES;
const perTable = Math.max(scope.limit * 2, 200);
const collected: MemoryCandidate[] = [];
for (const tableName of tables) {
const memories = await runtime.getMemories({
agentId: runtime.agentId as UUID,
roomId: scope.roomId,
tableName,
limit: perTable,
});
for (const m of memories) collected.push({ memory: m, type: tableName });
}
let filtered = collected.filter((c) => {
const text = (c.memory.content as { text?: string } | undefined)?.text;
return typeof text === "string" && text.trim().length > 0;
});
if (scope.entityId) {
const clusterIds = new Set<string>(
await getRelatedEntityIds(runtime, scope.entityId),
);
filtered = filtered.filter(
(c) => c.memory.entityId != null && clusterIds.has(c.memory.entityId),
);
}
if (scope.query) {
const query = scope.query;
filtered = filtered.filter((c) => {
const text =
(c.memory.content as { text?: string } | undefined)?.text ?? "";
return scoreText(text, query) > 0;
});
}
filtered.sort(
(a, b) => (b.memory.createdAt ?? 0) - (a.memory.createdAt ?? 0),
);
return filtered;
}
async function doSearch(
runtime: IAgentRuntime,
params: MemoryParams,
): Promise<ActionResult> {
const type =
params.type && MEMORY_TYPES.includes(params.type) ? params.type : undefined;
const entityParam = parseUuidParam(params.entityId, "entityId");
if (!entityParam.ok) return entityParam.result;
const roomParam = parseUuidParam(params.roomId, "roomId");
if (!roomParam.ok) return roomParam.result;
const query = params.query?.trim();
const limit = clampLimit(params.limit, 50);
const filtered = await collectCandidates(runtime, {
type,
entityId: entityParam.id,
roomId: roomParam.id,
query,
limit,
});
const total = filtered.length;
const items = filtered
.slice(0, limit)
.map((c) => toListItem(c.memory, c.type));
const lines = items
.slice(0, 25)
.map((m) => `- [${m.type}] ${m.id}: ${m.text.slice(0, 120)}`);
return {
success: true,
text: [
`Found ${items.length} memory item(s) (total: ${total}).`,
...lines,
].join("\n"),
values: { count: items.length, total },
data: {
actionName: "MEMORY",
op: "search" as const,
memories: items,
total,
limit,
},
};
}
async function doUpdate(
runtime: IAgentRuntime,
params: MemoryParams,
): Promise<ActionResult> {
const memoryParam = parseUuidParam(params.memoryId, "memoryId");
if (!memoryParam.ok) return memoryParam.result;
const memoryId = memoryParam.id;
const text = typeof params.text === "string" ? params.text.trim() : "";
if (!memoryId) return fail("memoryId is required.", "MEMORY_MISSING_ID");
if (!text) return fail("text is required.", "MEMORY_MISSING_TEXT");
if (params.confirm !== true) {
return fail(
"Refusing to update: pass confirm:true to acknowledge overwriting an existing memory.",
"MEMORY_CONFIRMATION_REQUIRED",
);
}
const existing = await runtime.getMemoryById(memoryId);
if (!existing) {
return fail(`Memory ${memoryId} was not found.`, "MEMORY_NOT_FOUND");
}
const existingContent =
(existing.content as Record<string, unknown> | undefined) ?? {};
const nextContent = { ...existingContent, text };
const embedding = await runtime.useModel(ModelType.TEXT_EMBEDDING, { text });
if (!Array.isArray(embedding) || embedding.length === 0) {
return fail(
"Embedding model returned no vector.",
"MEMORY_EMBEDDING_FAILED",
);
}
await runtime.updateMemory({
id: memoryId,
content: nextContent,
embedding,
});
const updated = await runtime.getMemoryById(memoryId);
return {
success: true,
text: `Updated memory ${memoryId}.`,
values: { memoryId },
data: {
actionName: "MEMORY",
op: "update" as const,
memoryId,
memory: updated ?? null,
},
};
}
async function doDelete(
runtime: IAgentRuntime,
message: Memory,
params: MemoryParams,
): Promise<ActionResult> {
const memoryParam = parseUuidParam(params.memoryId, "memoryId");
if (!memoryParam.ok) return memoryParam.result;
const memoryId = memoryParam.id;
const query = params.query?.trim();
if (!memoryId && !query) {
return fail("memoryId or query is required.", "MEMORY_MISSING_ID");
}
if (params.confirm !== true) {
return fail(
"Refusing to delete: pass confirm:true to acknowledge this destructive action.",
"MEMORY_CONFIRMATION_REQUIRED",
);
}
if (memoryId) {
const existing = await runtime.getMemoryById(memoryId);
if (!existing) {
return fail(`Memory ${memoryId} was not found.`, "MEMORY_NOT_FOUND");
}
await runtime.deleteMemory(memoryId);
return {
success: true,
text: `Forgot memory ${memoryId}.`,
values: { memoryId },
data: { actionName: "MEMORY", op: "delete" as const, memoryId },
};
}
if (!query) {
return fail("memoryId or query is required.", "MEMORY_MISSING_ID");
}
return doDeleteByQuery(runtime, message, params, query);
}
/**
* Delete-by-query: "remove that fact" carries no memoryId, so resolve the
* memory through the same cluster-expanded read scope search uses, then
* delete. Reflection dedup failures leave several rows with identical text —
* one logical fact — so all rows of the single matched text are removed.
* A query that strongly matches more than one distinct text is ambiguous:
* refuse and list the candidates so the model can delete by exact id.
*
* The read is pinned to the requesting entity's identity cluster: a text-only
* match in a multi-user room would also hit another user's identical-text
* fact, so "forget that I play guitar" may only remove the asking user's own
* rows. Cross-entity deletes must go through op:search + delete by memoryId.
*/
async function doDeleteByQuery(
runtime: IAgentRuntime,
message: Memory,
params: MemoryParams,
query: string,
): Promise<ActionResult> {
const type =
params.type && MEMORY_TYPES.includes(params.type) ? params.type : undefined;
const entityParam = parseUuidParam(params.entityId, "entityId");
if (!entityParam.ok) return entityParam.result;
const roomParam = parseUuidParam(params.roomId, "roomId");
if (!roomParam.ok) return roomParam.result;
// Requester wins over a model-supplied entityId: model ids arrive as free
// text and could name another user, reopening the cross-user match this
// scope exists to close. The parsed param is a fallback only for messages
// that carry no entity (internal maintenance invocations).
const scopeEntityId = message.entityId ?? entityParam.id;
const limit = clampLimit(params.limit, 50);
const candidates = await collectCandidates(runtime, {
type,
entityId: scopeEntityId,
roomId: roomParam.id,
query,
limit,
});
// Deletion needs a stronger bar than search ranking: scoreText >= 1 means
// the whole phrase matched or every query term matched.
const matched = candidates.filter((c) => {
const text =
(c.memory.content as { text?: string } | undefined)?.text ?? "";
return scoreText(text, query) >= 1;
});
if (matched.length === 0) {
return fail(`No stored memory matches "${query}".`, "MEMORY_NOT_FOUND");
}
const normalize = (c: MemoryCandidate) =>
((c.memory.content as { text?: string } | undefined)?.text ?? "")
.trim()
.toLowerCase();
const distinctTexts = new Set(matched.map(normalize));
if (distinctTexts.size > 1) {
const lines = matched
.slice(0, 10)
.map((c) => toListItem(c.memory, c.type))
.map((m) => `- [${m.type}] ${m.id}: ${m.text.slice(0, 120)}`);
return {
success: false,
text: [
`Query "${query}" matches ${distinctTexts.size} distinct memories. Delete by memoryId instead:`,
...lines,
].join("\n"),
data: { error: "MEMORY_AMBIGUOUS_QUERY" },
};
}
const deleted: MemoryListItem[] = [];
for (const c of matched) {
const id = c.memory.id;
if (!id) continue;
await runtime.deleteMemory(id);
deleted.push(toListItem(c.memory, c.type));
}
return {
success: true,
text: `Forgot ${deleted.length} memory record(s) matching "${query}": ${
deleted[0]?.text.slice(0, 120) ?? ""
}`,
values: { deletedCount: deleted.length },
data: {
actionName: "MEMORY",
op: "delete" as const,
query,
deleted,
},
};
}
export const memoryAction: Action = {
name: "MEMORY",
contexts: ["memory", "documents", "agent_internal"],
roleGate: { minRole: "OWNER" },
similes: [
// Old leaf action names
"CREATE_MEMORY",
"SEARCH_MEMORIES",
"UPDATE_MEMORY",
"DELETE_MEMORY",
"RECALL_MEMORY_FILTERED",
"FORGET_MEMORY",
"EDIT_MEMORY",
// Common aliases
"MEMORIZE",
"REMEMBER_THIS",
"STORE_MEMORY",
"WRITE_MEMORY",
"SAVE_MEMORY",
"BROWSE_MEMORIES",
"FILTER_MEMORIES",
"FIND_MEMORIES",
"LIST_MEMORIES",
"SEARCH_MEMORY",
"REMOVE_MEMORY",
"MODIFY_MEMORY",
],
description:
"Manage agent memory records. op:create stores a new memory; op:search filters by type/entityId/roomId/query; op:update edits text and re-embeds (requires confirm:true); op:delete removes a memory by memoryId or by query text match (requires confirm:true).",
descriptionCompressed:
"manage agent memory create search update delete; delete by memoryId or query; update/delete require confirm:true",
routingHint:
"store/search/edit the agent's OWN memory records about the user or conversation -> MEMORY; do NOT use for open-web lookups -> WEB_SEARCH, for reading messages already in a channel -> MESSAGE (action=search), or for the skill catalog -> SKILL",
validate: async () => true,
handler: async (
runtime: IAgentRuntime,
message,
_state,
options,
): Promise<ActionResult> => {
const params = ((options as HandlerOptions | undefined)?.parameters ??
{}) as MemoryParams;
const op = normalizeMemoryOp(params);
if (!op) {
return fail(
`op/subaction is required and must be one of ${MEMORY_OPS.join(", ")}.`,
"MEMORY_INVALID",
);
}
try {
switch (op) {
case "create":
return await doCreate(runtime, message, params);
case "search":
return await doSearch(runtime, params);
case "update":
return await doUpdate(runtime, params);
case "delete":
return await doDelete(runtime, message, params);
}
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[memory:${op}] failed: ${msg}`);
return {
success: false,
text: `Failed to ${op} memory: ${msg}`,
data: { error: `MEMORY_${op.toUpperCase()}_FAILED` },
};
}
},
parameters: [
{
name: "action",
description:
"Operation to perform. One of: create, search, update, delete.",
required: false,
schema: { type: "string" as const, enum: [...MEMORY_OPS] },
},
{
name: "text",
description:
"create: content to store. update: replacement text body for the memory.",
required: false,
schema: { type: "string" as const },
},
{
name: "kind",
description:
'create: optional category label, e.g. "fact", "preference".',
required: false,
schema: { type: "string" as const },
},
{
name: "tags",
description: "create: optional list of string tags.",
required: false,
schema: { type: "array" as const, items: { type: "string" as const } },
},
{
name: "type",
description: "search: filter by memory table type.",
required: false,
schema: { type: "string" as const, enum: [...MEMORY_TYPES] },
},
{
name: "entityId",
description: "search: filter to memories owned by this entity id.",
required: false,
schema: { type: "string" as const },
},
{
name: "roomId",
description: "search: filter to memories from this room id.",
required: false,
schema: { type: "string" as const },
},
{
name: "query",
description:
"search/delete: case-insensitive text match against memory content. delete: resolves the memory to remove when memoryId is unknown; scoped to the requesting user's own memories.",
required: false,
schema: { type: "string" as const },
},
{
name: "limit",
description: "search: maximum results to return (1-200).",
required: false,
schema: { type: "number" as const },
},
{
name: "memoryId",
description:
"update/delete: id of the memory to mutate. delete: optional when query is provided.",
required: false,
schema: { type: "string" as const },
},
{
name: "confirm",
description:
"update/delete: must be true to proceed with the destructive operation.",
required: false,
schema: { type: "boolean" as const },
},
],
examples: [
[
{
name: "{{name1}}",
content: { text: "Remember that I prefer dark mode." },
},
{
name: "{{agentName}}",
content: { text: "Stored memory abc-123.", action: "MEMORY" },
},
],
[
{
name: "{{name1}}",
content: { text: "Find recent memories that mention scheduling." },
},
{
name: "{{agentName}}",
content: { text: "Found N memory item(s)...", action: "MEMORY" },
},
],
],
};
+322
View File
@@ -0,0 +1,322 @@
/**
* Covers notifyAction against a real NotificationService (in-memory cache + stub
* event bus): validate gating, creating a notification from params, invalid
* category/priority fallback, and failure on missing title or absent service.
*/
import type {
HandlerCallback,
IAgentRuntime,
Memory,
State,
UUID,
} from "@elizaos/core";
import {
BUILTIN_RESPONSE_HANDLER_FIELD_EVALUATORS,
ModelType,
NotificationService,
ResponseHandlerFieldRegistry,
runV5MessageRuntimeStage1,
ServiceType,
} from "@elizaos/core";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { notifyAction } from "./notify";
async function makeRuntime(withService = true): Promise<{
runtime: IAgentRuntime;
service: NotificationService | null;
}> {
const cache = new Map<string, unknown>();
const bus = { emit: vi.fn() };
const base = {
agentId: "00000000-0000-0000-0000-0000000000aa",
getCache: async <T>(k: string): Promise<T | undefined> =>
cache.get(k) as T | undefined,
setCache: async <T>(k: string, v: T): Promise<boolean> => {
cache.set(k, v);
return true;
},
deleteCache: async (k: string): Promise<boolean> => cache.delete(k),
getService: (t: string) => (t === ServiceType.AGENT_EVENT ? bus : null),
} as unknown as IAgentRuntime;
const service = withService
? ((await NotificationService.start(base)) as NotificationService)
: null;
const runtime = {
agentId: base.agentId,
getService: (t: string) =>
t === ServiceType.NOTIFICATION
? service
: t === ServiceType.AGENT_EVENT
? bus
: null,
} as unknown as IAgentRuntime;
return { runtime, service };
}
const message = {} as Memory;
const state = {} as State;
describe("notifyAction", () => {
let runtime: IAgentRuntime;
let service: NotificationService | null;
beforeEach(async () => {
({ runtime, service } = await makeRuntime());
});
it("validates true only when the notification service exists", async () => {
expect(await notifyAction.validate(runtime, message)).toBe(true);
const { runtime: bare } = await makeRuntime(false);
expect(await notifyAction.validate(bare, message)).toBe(false);
});
it("creates a notification from parameters", async () => {
const callback = vi.fn() as unknown as HandlerCallback;
const result = await notifyAction.handler(
runtime,
message,
state,
{
parameters: {
title: "Build done",
body: "ok",
category: "workflow",
priority: "high",
},
},
callback,
);
expect(result).toBeTruthy();
expect((result as { success: boolean }).success).toBe(true);
const list = service?.list() ?? [];
expect(list).toHaveLength(1);
expect(list[0].title).toBe("Build done");
expect(list[0].category).toBe("workflow");
expect(list[0].priority).toBe("high");
expect(callback).toHaveBeenCalled();
});
it("falls back to defaults for invalid category/priority", async () => {
await notifyAction.handler(
runtime,
message,
state,
{ parameters: { title: "X", category: "nonsense", priority: "louder" } },
undefined,
);
const list = service?.list() ?? [];
expect(list[0].category).toBe("general");
expect(list[0].priority).toBe("normal");
});
it("fails when title is missing", async () => {
const result = await notifyAction.handler(
runtime,
message,
state,
{ parameters: { body: "no title" } },
undefined,
);
expect((result as { success: boolean }).success).toBe(false);
expect(service?.list()).toHaveLength(0);
});
it("fails gracefully when the service is unavailable", async () => {
const { runtime: bare } = await makeRuntime(false);
const result = await notifyAction.handler(
bare,
message,
state,
{ parameters: { title: "x" } },
undefined,
);
expect((result as { success: boolean }).success).toBe(false);
});
it("is scoped to automation/agent-internal turns, not ordinary chat", () => {
// Without explicit contexts NOTIFY fell back to ["general"] and landed on
// the action surface of every chat turn; a weak planner then picked it to
// "answer" a question (observed live: NOTIFY chosen for "who are the top 3
// contributors", posting a self-notification instead of the answer).
expect(notifyAction.contexts).toEqual(["automation", "agent_internal"]);
expect(notifyAction.contexts).not.toContain("general");
});
it("drops answer-flavored similes that read as 'tell the user'", () => {
// NOTIFY_USER / ALERT_USER made a planner treat NOTIFY as the reply path.
expect(notifyAction.similes).toEqual([
"SEND_NOTIFICATION",
"PUSH_NOTIFICATION",
"SEND_ALERT",
]);
});
});
/**
* Routing through the real message pipeline: the planner's tool surface for a
* turn is what decides whether NOTIFY can even be chosen, so these tests run
* runV5MessageRuntimeStage1 with the real notifyAction registered and inspect
* the tools actually offered to the planner model.
*/
describe("NOTIFY on the planner action surface", () => {
const AGENT_ID = "00000000-0000-0000-0000-0000000000a3" as UUID;
function stage1Response(contexts: string[], candidates: string[] = []) {
return {
text: "",
toolCalls: [
{
id: "handle-response-1",
name: "HANDLE_RESPONSE",
arguments: {
shouldRespond: "RESPOND",
thought: "",
contexts,
intents: [],
candidateActionNames: candidates,
replyText: "",
facts: [],
relationships: [],
addressedTo: [],
},
},
],
};
}
// A context-free control: it must appear on EVERY turn's surface, so its
// presence proves the surface was actually built when NOTIFY is absent.
const contextFreeAction = {
name: "ECHO_TIME",
description: "echoes the current time",
similes: [],
examples: [],
parameters: [],
validate: async () => true,
handler: async () => ({ success: true, text: "now" }),
} as unknown as import("@elizaos/core").Action;
async function makePipelineRuntime(
responses: unknown[],
): Promise<IAgentRuntime> {
const queue = [...responses];
const registry = new ResponseHandlerFieldRegistry();
for (const evaluator of BUILTIN_RESPONSE_HANDLER_FIELD_EVALUATORS) {
registry.register(evaluator);
}
// NOTIFY's validate() requires a live NotificationService — an invalid
// action never reaches the planner surface, so the routing tests need the
// real service wired in.
const { service } = await makeRuntime(true);
return {
agentId: AGENT_ID,
character: { name: "Test Agent", system: "Concise.", bio: "Helper." },
actions: [notifyAction, contextFreeAction],
providers: [],
composeState: vi.fn(async () => ({
values: { availableContexts: "general, automation" },
data: {},
text: "",
})),
runActionsByMode: vi.fn(async () => undefined),
emitEvent: vi.fn(async () => undefined),
useModel: vi.fn(async () => {
// Background stages (facts/relationships) also call the model after
// the scripted turn; they get a benign empty response.
const next = queue.shift();
return next ?? { text: "" };
}),
getSetting: vi.fn(() => undefined),
getService: vi.fn((t: string) =>
t === ServiceType.NOTIFICATION ? service : null,
),
logger: {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
trace: vi.fn(),
},
responseHandlerFieldRegistry: registry,
responseHandlerFieldEvaluators: [
...BUILTIN_RESPONSE_HANDLER_FIELD_EVALUATORS,
],
responseHandlerEvaluators: [],
} as unknown as IAgentRuntime;
}
async function plannerToolNamesFor(
contexts: string[],
candidates: string[] = [],
plannerBody?: unknown,
): Promise<string[]> {
const runtime = await makePipelineRuntime([
stage1Response(contexts, candidates),
plannerBody ?? { text: "", toolCalls: [] },
JSON.stringify({
success: true,
decision: "FINISH",
thought: "Done.",
messageToUser: "ok",
}),
]);
await runV5MessageRuntimeStage1({
runtime,
message: {
id: "00000000-0000-0000-0000-0000000000a1" as UUID,
entityId: "00000000-0000-0000-0000-0000000000a2" as UUID,
agentId: AGENT_ID,
roomId: "00000000-0000-0000-0000-0000000000a4" as UUID,
content: {
text: "who are the top 3 contributors to the eliza repo",
source: "test",
},
createdAt: 1,
},
state: {
values: { availableContexts: "general, automation" },
data: {},
text: "",
},
responseId: "00000000-0000-0000-0000-0000000000a5" as UUID,
});
const useModel = runtime.useModel as unknown as {
mock: { calls: unknown[][] };
};
const plannerCall = useModel.mock.calls.find(
(call) => String(call[0]) === String(ModelType.ACTION_PLANNER),
);
const params = plannerCall?.[1] as
| { tools?: Array<{ name?: string }> }
| undefined;
return (params?.tools ?? [])
.map((tool) => String(tool?.name ?? ""))
.filter(Boolean);
}
it("is NOT offered to the planner on an ordinary chat turn", async () => {
const tools = await plannerToolNamesFor(["general"]);
// The surface was built (the context-free action is there); NOTIFY is not.
expect(tools).toContain("ECHO_TIME");
expect(tools).not.toContain("NOTIFY");
});
it("IS offered to the planner on an automation turn", async () => {
const tools = await plannerToolNamesFor(
["automation"],
["NOTIFY"],
// The planner uses it — the real handler runs through the pipeline.
{
text: "",
toolCalls: [
{
id: "notify-1",
name: "NOTIFY",
args: { title: "Job finished", category: "workflow" },
},
],
},
);
expect(tools).toContain("NOTIFY");
});
});
+211
View File
@@ -0,0 +1,211 @@
/**
* NOTIFY — create a user-facing notification.
*
* The agent (or anything routing through the action layer) calls this to push
* a structured notification onto the unified rail: persisted to the inbox and
* fanned out live to every client surface (in-app center + toast, desktop OS,
* mobile native). Distinct from a chat reply — use NOTIFY when the user should
* be alerted to something (a finished job, a needed approval, a reminder) even
* when they are not looking at the conversation.
*/
import type {
Action,
ActionResult,
HandlerOptions,
NotificationCategory,
NotificationInput,
NotificationPriority,
} from "@elizaos/core";
import { logger, NotificationService, ServiceType } from "@elizaos/core";
const CATEGORIES: NotificationCategory[] = [
"reminder",
"task",
"workflow",
"agent",
"approval",
"message",
"health",
"system",
"general",
];
const PRIORITIES: NotificationPriority[] = ["low", "normal", "high", "urgent"];
interface NotifyParams {
title?: string;
body?: string;
category?: string;
priority?: string;
deepLink?: string;
groupKey?: string;
}
function getService(runtime: {
getService: (t: string) => unknown;
}): NotificationService | null {
const svc = runtime.getService(ServiceType.NOTIFICATION);
return svc instanceof NotificationService ? svc : null;
}
export const notifyAction: Action = {
name: "NOTIFY",
// Deliberately NOT the chat-answer path: without explicit contexts this
// action fell back to ["general"], landing on the action surface of every
// ordinary chat turn — and similes like NOTIFY_USER read to a weak planner
// as "tell the user the answer" (observed live: NOTIFY chosen for "who are
// the top 3 contributors", posting a self-notification instead of the
// answer). Scope it to automation/agent-internal turns where proactive
// alerts actually originate.
contexts: ["automation", "agent_internal"],
routingHint:
"push a proactive OS/notification-center alert (completed job, reminder, approval needed) -> NOTIFY; to answer the user's question in chat -> REPLY (never NOTIFY)",
similes: ["SEND_NOTIFICATION", "PUSH_NOTIFICATION", "SEND_ALERT"],
description:
"Send the user a notification (persisted to their notification center and surfaced as an OS/in-app alert). Use when the user should be proactively alerted to something — a completed job, a reminder, an approval needed — rather than just replying in chat. Provide a short `title` and optional `body`, `category` (reminder|task|workflow|agent|approval|message|health|system|general), and `priority` (low|normal|high|urgent).",
descriptionCompressed:
"push a user-facing notification (title/body/category/priority) to the notification center + OS/in-app alert",
validate: async (runtime): Promise<boolean> => getService(runtime) !== null,
handler: async (
runtime,
_message,
_state,
options,
callback,
): Promise<ActionResult> => {
const service = getService(runtime);
if (!service) {
return {
success: false,
text: "Notification service is not available.",
values: { error: "NOTIFICATION_SERVICE_UNAVAILABLE" },
data: { actionName: "NOTIFY" },
};
}
const params =
((options as HandlerOptions | undefined)?.parameters as
| NotifyParams
| undefined) ?? {};
const title = params.title?.trim();
if (!title) {
if (callback) {
callback({
text: "I need a title to send a notification.",
action: "NOTIFY_INVALID",
});
}
return {
success: false,
text: "Notification title is required.",
values: { error: "NOTIFY_MISSING_TITLE" },
data: { actionName: "NOTIFY" },
};
}
const category =
params.category &&
CATEGORIES.includes(params.category as NotificationCategory)
? (params.category as NotificationCategory)
: "general";
const priority =
params.priority &&
PRIORITIES.includes(params.priority as NotificationPriority)
? (params.priority as NotificationPriority)
: "normal";
const input: NotificationInput = {
title,
body: params.body?.trim() || undefined,
category,
priority,
deepLink: params.deepLink?.trim() || undefined,
groupKey: params.groupKey?.trim() || undefined,
source: "agent",
};
try {
const notification = await service.notify(input);
if (callback) {
callback({
text: `Notification sent: ${title}`,
action: "NOTIFY_SENT",
});
}
return {
success: true,
text: `Sent notification "${title}".`,
values: { notificationId: notification.id, category, priority },
data: { actionName: "NOTIFY", notification },
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
logger.warn(`[NOTIFY] failed: ${msg}`);
return {
success: false,
text: `Failed to send notification: ${msg}`,
values: { error: "NOTIFY_FAILED" },
data: { actionName: "NOTIFY" },
};
}
},
parameters: [
{
name: "title",
description: "Short notification headline.",
required: true,
schema: { type: "string" as const },
},
{
name: "body",
description: "Optional longer detail line.",
required: false,
schema: { type: "string" as const },
},
{
name: "category",
description: "Notification category for grouping/iconography.",
required: false,
schema: { type: "string" as const, enum: [...CATEGORIES] },
},
{
name: "priority",
description:
"Urgency. high/urgent interrupt a focused window; low/normal land quietly in the inbox.",
required: false,
schema: { type: "string" as const, enum: [...PRIORITIES] },
},
{
name: "deepLink",
description:
"App route (e.g. /tasks) or URL to open when the notification is tapped.",
required: false,
schema: { type: "string" as const },
},
{
name: "groupKey",
description:
"Collapse key — a newer notification with the same key replaces the older one.",
required: false,
schema: { type: "string" as const },
},
],
examples: [
[
{
name: "{{user}}",
content: { text: "Let me know when the deploy finishes." },
},
{
name: "{{agent}}",
content: {
text: "Will do — I'll notify you the moment it's done.",
actions: ["NOTIFY"],
},
},
],
],
};
export default notifyAction;
@@ -0,0 +1,352 @@
/**
* Defines PAGE_DELEGATE, the owner-only main-chat parent action that routes a
* request to a context-scoped child action for a named page (browser, wallet,
* character, settings, connectors, automation, phone, owner). Resolves the child
* by name + allowed-context set from the runtime's registered actions and
* forwards the call, accepting both the nested and the flat (auto-lifted)
* parameter shapes.
*/
import type {
Action,
ActionParameters,
ActionResult,
AgentContext,
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { resolveActionContexts } from "@elizaos/core";
/**
* PAGE_DELEGATE — owner-only main-chat parent that dispatches to the child
* action set scoped to a named page (browser, wallet, character, settings,
* connectors, automation, phone, owner).
*
* Replaces the per-page `<PAGE>_ACTIONS` parents. The discriminator is `page`;
* the child action name is `action`; child fields go in `parameters` (or as
* top-level fields — `allowAdditionalParameters` auto-lifts them).
*/
const PAGE_KEYS = [
"browser",
"wallet",
"character",
"settings",
"connectors",
"automation",
"phone",
"owner",
] as const;
type PageKey = (typeof PAGE_KEYS)[number];
const PAGE_CONTEXTS: Record<PageKey, AgentContext[]> = {
browser: ["browser"],
wallet: ["wallet"],
character: ["character"],
settings: ["settings"],
connectors: ["connectors"],
automation: ["automation"],
phone: ["phone"],
owner: [
"tasks",
"calendar",
"email",
"contacts",
"health",
"subscriptions",
"screen_time",
"automation",
"messaging",
],
};
const ALL_PAGE_CONTEXTS: AgentContext[] = Array.from(
new Set<AgentContext>(PAGE_KEYS.flatMap((page) => PAGE_CONTEXTS[page])),
);
type PageActionGroup = Action & {
actionGroup: {
contexts: AgentContext[];
};
};
type PageDelegateParameters = {
page?: string;
action?: string;
parameters?: ActionParameters;
};
function normalizeActionName(value: string): string {
return value.trim().toUpperCase();
}
function normalizeContext(context: AgentContext): string {
return `${context}`.toLowerCase();
}
function readPageKey(value: unknown): PageKey | undefined {
if (typeof value !== "string") return undefined;
const normalized = value.trim().toLowerCase();
return (PAGE_KEYS as readonly string[]).includes(normalized)
? (normalized as PageKey)
: undefined;
}
/**
* Parse the delegate call into `{ page, action, parameters }`. Accepts both
* the nested shape and a flat shape that LLMs commonly emit.
*
* Nested:
* `{page: "browser", action: "BROWSER", parameters: {subaction: "navigate", url: "..."}}`
*
* Flat (auto-lifted): every key except `page`, `action`, and `parameters` is
* treated as a child-action parameter and merged into `parameters`:
* `{page: "browser", action: "BROWSER", subaction: "navigate", url: "..."}`
*/
function readParameters(
options: HandlerOptions | undefined,
): PageDelegateParameters {
const parameters = options?.parameters ?? {};
const explicitPage =
typeof parameters.page === "string" ? parameters.page : undefined;
const explicitAction =
typeof parameters.action === "string" ? parameters.action : undefined;
const explicitChildParams =
parameters.parameters &&
typeof parameters.parameters === "object" &&
!Array.isArray(parameters.parameters)
? (parameters.parameters as ActionParameters)
: undefined;
const lifted: ActionParameters = {};
for (const [key, value] of Object.entries(parameters)) {
if (key === "page" || key === "action" || key === "parameters") continue;
lifted[key] = value as ActionParameters[string];
}
const hasLifted = Object.keys(lifted).length > 0;
let mergedChildParams: ActionParameters | undefined;
if (explicitChildParams && hasLifted) {
mergedChildParams = { ...lifted, ...explicitChildParams };
} else if (explicitChildParams) {
mergedChildParams = explicitChildParams;
} else if (hasLifted) {
mergedChildParams = lifted;
}
return {
page: explicitPage,
action: explicitAction,
parameters: mergedChildParams,
};
}
function isPageDelegate(action: Action): boolean {
return Array.isArray(
(action as Partial<PageActionGroup>).actionGroup?.contexts,
);
}
function actionMatchesName(action: Action, name: string): boolean {
if (normalizeActionName(action.name) === name) {
return true;
}
return (action.similes ?? []).some(
(simile) => normalizeActionName(simile) === name,
);
}
function actionMatchesContexts(
action: Action,
allowedContexts: Set<string>,
): boolean {
return resolveActionContexts(action).some((context) =>
allowedContexts.has(normalizeContext(context)),
);
}
function findChildAction(
runtime: IAgentRuntime,
actionName: string,
contexts: AgentContext[],
): Action | null {
const normalizedName = normalizeActionName(actionName);
const allowedContexts = new Set(contexts.map(normalizeContext));
for (const action of runtime.actions) {
if (isPageDelegate(action)) {
continue;
}
if (!actionMatchesName(action, normalizedName)) {
continue;
}
if (!actionMatchesContexts(action, allowedContexts)) {
continue;
}
return action;
}
return null;
}
export const pageDelegateAction: PageActionGroup = {
name: "PAGE_DELEGATE",
similes: [
"PAGE_ACTIONS",
"BROWSER_TOOLS",
"WALLET_TOOLS",
"CHARACTER_TOOLS",
"SETTINGS_TOOLS",
"CONNECTOR_TOOLS",
"AUTOMATION_TOOLS",
"PHONE_TOOLS",
"OWNER_TOOLS",
"PERSONAL_ASSISTANT_ACTIONS",
],
contexts: ["general", ...ALL_PAGE_CONTEXTS],
actionGroup: { contexts: ALL_PAGE_CONTEXTS },
roleGate: { minRole: "OWNER" },
// Outer envelope accepts unknown top-level keys (auto-lifted to the child
// action's parameters). Smaller LLMs commonly emit the flat shape
// `{page, action, url, selector}` instead of nested
// `{page, action, parameters:{url, selector}}`.
allowAdditionalParameters: true,
description: `Owner-only main-chat parent action. Routes a request to a child action under one of the page contexts (${PAGE_KEYS.join(", ")}). Call shape: { page: "<PAGE>", action: "<CHILD_NAME>", ...child fields }. The child action's parameter names go at the top level alongside \`page\` and \`action\` — for example, to navigate the browser: \`{ "page": "browser", "action": "BROWSER", "subaction": "navigate", "url": "https://example.com" }\`. The legacy nested shape \`{ page, action, parameters: { ... } }\` is also accepted. Page-scoped chats expose the child actions directly without going through PAGE_DELEGATE.`,
descriptionCompressed:
"PAGE_DELEGATE {page browser|wallet|settings|connectors|phone|owner, action CHILD}",
routingHint:
'main-chat browser/wallet/settings/connectors/phone/owner data operations -> PAGE_DELEGATE. Do not use PAGE_DELEGATE for UI view/window/panel/app navigation, opening/closing views, view manager, split/tile layout, or notes/calendar/notepad view switching; those belong to VIEWS. Browser web navigation still uses {page:"browser", action:"BROWSER_OPEN", url} or {page:"browser", action:"BROWSER", subaction:"open", url}.',
validate: async () => true,
handler: async (
runtime: IAgentRuntime,
message: Memory,
state?: State,
options?: HandlerOptions,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const params = readParameters(options);
const page = readPageKey(params.page);
if (!page) {
return {
success: false,
text: `PAGE_DELEGATE requires a page parameter (one of: ${PAGE_KEYS.join(", ")}).`,
};
}
const requestedAction = params.action?.trim();
if (!requestedAction) {
return {
success: false,
text: `PAGE_DELEGATE requires an action parameter naming the child action to run on the ${page} page.`,
};
}
const childContexts = PAGE_CONTEXTS[page];
const childAction = findChildAction(
runtime,
requestedAction,
childContexts,
);
if (!childAction) {
return {
success: false,
text: `${requestedAction} is not available on the ${page} page.`,
};
}
if (!(await childAction.validate(runtime, message, state))) {
return {
success: false,
text: `${childAction.name} is not available for this request.`,
};
}
const childCallback: typeof callback = callback
? (response, actionName) =>
callback(response, actionName ?? childAction.name)
: undefined;
return (
(await childAction.handler(
runtime,
message,
state,
{
...options,
parameters: params.parameters ?? {},
},
childCallback,
)) ?? {
success: true,
text: `${childAction.name} completed.`,
}
);
},
parameters: [
{
name: "page",
description:
"Page context to dispatch under. Selects the allowed child-action context set.",
required: true,
schema: { type: "string" as const, enum: [...PAGE_KEYS] },
},
{
name: "action",
description:
"Child action name to run, e.g. BROWSER, CHECK_BALANCE, MODIFY_CHARACTER, UPDATE_AI_PROVIDER, LIST_CONNECTORS.",
required: true,
schema: { type: "string" as const },
},
{
name: "parameters",
description:
"Parameters forwarded to the selected child action. Use the child action's parameter names.",
required: false,
schema: { type: "object" as const },
},
],
examples: [
[
{
name: "{{name1}}",
content: { text: "Open example.com in the browser." },
},
{
name: "{{agentName}}",
content: {
text: "Routing to BROWSER for navigation.",
actions: ["PAGE_DELEGATE"],
thought:
"Owner asked for a browser navigation; PAGE_DELEGATE dispatches to the BROWSER child action under the browser page.",
},
},
],
[
{
name: "{{name1}}",
content: { text: "Show my wallet balance." },
},
{
name: "{{agentName}}",
content: {
text: "Pulling wallet balances.",
actions: ["PAGE_DELEGATE"],
thought:
"Balance request maps to the wallet page; PAGE_DELEGATE dispatches to the CHECK_BALANCE child action.",
},
},
],
[
{
name: "{{name1}}",
content: { text: "What's on my calendar tomorrow?" },
},
{
name: "{{agentName}}",
content: {
text: "Pulling tomorrow's events.",
actions: ["PAGE_DELEGATE"],
thought:
"Calendar query falls under the owner page; PAGE_DELEGATE dispatches to the CALENDAR action=feed child.",
},
},
],
],
};
@@ -0,0 +1,227 @@
/**
* PAIR_OWNER_ACCOUNT action coverage: intent matching, connector resolution,
* and the handler's authorization + issuance paths. Authorization runs through
* the REAL core role machinery (hasOwnerAccess → hasRoleAccess →
* resolveCanonicalOwnerId) against a deterministic runtime stand-in, so the
* owner/non-owner split is resolved, not stubbed.
*/
import type {
ActionResult,
HandlerCallback,
IAgentRuntime,
Memory,
UUID,
} from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { OwnerBindingService } from "../services/owner-binding.ts";
import {
messageWantsOwnerPairing,
pairOwnerAccountAction,
resolveRequestedPairConnector,
} from "./pair-owner-account.ts";
async function runHandler(
runtime: IAgentRuntime,
message: Memory,
callback?: HandlerCallback,
): Promise<ActionResult> {
const result = await pairOwnerAccountAction.handler(
runtime,
message,
undefined,
undefined,
callback,
);
if (!result) {
throw new Error("handler returned no result");
}
return result;
}
const AGENT_ID = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" as UUID;
const OWNER_ID = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb" as UUID;
const STRANGER_ID = "cccccccc-cccc-cccc-cccc-cccccccccccc" as UUID;
const ROOM_ID = "dddddddd-dddd-dddd-dddd-dddddddddddd" as UUID;
function makeRuntime(options?: {
ownerConfigured?: boolean;
service?: unknown;
}): IAgentRuntime {
const ownerConfigured = options?.ownerConfigured ?? true;
const runtime = {
agentId: AGENT_ID,
character: { name: "Eliza" },
getSetting: (key: string) =>
ownerConfigured && key === "ELIZA_ADMIN_ENTITY_ID" ? OWNER_ID : undefined,
getService: (type: string) =>
type === "OWNER_BIND_VERIFY" ? (options?.service ?? null) : null,
getRoom: async () => null,
getEntityById: async () => null,
createEntity: async () => true,
updateEntity: async () => undefined,
getRelationships: async () => [],
reportError: vi.fn(),
};
return runtime as unknown as IAgentRuntime;
}
function makeMessage(text: string, entityId: UUID): Memory {
return {
id: "eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee" as UUID,
entityId,
roomId: ROOM_ID,
content: { text, source: "client_chat" },
} as Memory;
}
describe("messageWantsOwnerPairing", () => {
it("matches pair/link intent naming a supported connector", () => {
expect(messageWantsOwnerPairing("link my discord")).toBe(true);
expect(messageWantsOwnerPairing("pair my Telegram account")).toBe(true);
expect(messageWantsOwnerPairing("verify my discord so you know me")).toBe(
true,
);
});
it("does not match unrelated link/connect phrasing", () => {
expect(messageWantsOwnerPairing("link this doc to the report")).toBe(false);
expect(messageWantsOwnerPairing("connect the printer")).toBe(false);
expect(messageWantsOwnerPairing("discord is down today")).toBe(false);
expect(messageWantsOwnerPairing("")).toBe(false);
});
});
describe("resolveRequestedPairConnector", () => {
it("resolves a single named connector", () => {
expect(resolveRequestedPairConnector("link my discord")?.connector).toBe(
"discord",
);
expect(resolveRequestedPairConnector("pair telegram")?.connector).toBe(
"telegram",
);
});
it("returns null when no or multiple connectors are named", () => {
expect(resolveRequestedPairConnector("link my account")).toBeNull();
expect(
resolveRequestedPairConnector("link my discord and telegram"),
).toBeNull();
});
});
describe("pairOwnerAccountAction.validate", () => {
it("gates on pairing intent in the message text", async () => {
const runtime = makeRuntime();
expect(
await pairOwnerAccountAction.validate(
runtime,
makeMessage("link my discord", OWNER_ID),
),
).toBe(true);
expect(
await pairOwnerAccountAction.validate(
runtime,
makeMessage("what's the weather", OWNER_ID),
),
).toBe(false);
});
});
describe("pairOwnerAccountAction.handler", () => {
it("issues a discord code for the canonical owner with the exact command", async () => {
const runtime = makeRuntime();
const service = new OwnerBindingService(runtime);
const runtimeWithService = makeRuntime({ service });
const callback = vi.fn();
const result = await runHandler(
runtimeWithService,
makeMessage("link my discord", OWNER_ID),
callback,
);
expect(result.success).toBe(true);
expect(result.text).toMatch(/\/eliza-pair \d{6}/);
expect(callback).toHaveBeenCalledWith(
expect.objectContaining({ text: expect.stringMatching(/\d{6}/) }),
);
expect(result.data).toMatchObject({ connector: "discord" });
});
it("uses the underscore command for telegram", async () => {
const runtime = makeRuntime();
const service = new OwnerBindingService(runtime);
const runtimeWithService = makeRuntime({ service });
const result = await runHandler(
runtimeWithService,
makeMessage("pair my telegram", OWNER_ID),
);
expect(result.success).toBe(true);
expect(result.text).toMatch(/\/eliza_pair \d{6}/);
});
it("refuses a sender who does not resolve to the owner (imposter)", async () => {
const runtime = makeRuntime();
const service = new OwnerBindingService(runtime);
const runtimeWithService = makeRuntime({ service });
const callback = vi.fn();
const result = await runHandler(
runtimeWithService,
makeMessage("link my discord", STRANGER_ID),
callback,
);
expect(result.success).toBe(false);
expect(result.values).toMatchObject({ error: "NOT_OWNER" });
// No code is ever revealed to a non-owner.
expect(result.text).not.toMatch(/\d{6}/);
});
it("asks which connector when the request is ambiguous", async () => {
const runtime = makeRuntime();
const service = new OwnerBindingService(runtime);
const runtimeWithService = makeRuntime({ service });
const result = await runHandler(
runtimeWithService,
makeMessage("link my discord and telegram", OWNER_ID),
);
expect(result.success).toBe(false);
expect(result.values).toMatchObject({ error: "CONNECTOR_AMBIGUOUS" });
});
it("fails with a designed message when the pairing service is absent", async () => {
const runtime = makeRuntime();
const result = await runHandler(
runtime,
makeMessage("link my discord", OWNER_ID),
);
expect(result.success).toBe(false);
expect(result.values).toMatchObject({ error: "SERVICE_UNAVAILABLE" });
});
it("surfaces issuance failures without leaking internals", async () => {
const throwingService = {
beginOwnerBind: () => {
throw new Error("no canonical owner");
},
};
const runtime = makeRuntime({ service: throwingService });
const result = await runHandler(
runtime,
makeMessage("link my discord", OWNER_ID),
);
expect(result.success).toBe(false);
expect(result.values).toMatchObject({ error: "ISSUE_FAILED" });
expect(
(runtime as unknown as { reportError: ReturnType<typeof vi.fn> })
.reportError,
).toHaveBeenCalled();
});
it("declares an OWNER role gate for planner exposure", () => {
expect(pairOwnerAccountAction.roleGate).toEqual({ minRole: "OWNER" });
});
});
@@ -0,0 +1,257 @@
/**
* PAIR_OWNER_ACCOUNT — the owner-side entry point of the connector
* owner-pairing flow. When the app owner asks to link/verify their Discord or
* Telegram account ("link my discord", "pair my telegram account"), this
* action issues a one-time 6-digit code via {@link OwnerBindingService} and
* replies with the exact connector command to run (`/eliza-pair <code>` on
* Discord, `/eliza_pair <code>` on Telegram). The connector relays the code
* back to the `OWNER_BIND_VERIFY` service, which binds the proven platform
* identity to the canonical owner entity — after which role resolution treats
* that platform user as OWNER everywhere.
*
* Authorization is double-gated: the declared `roleGate` keeps the action out
* of non-owner planner surfaces, and the handler re-checks `hasOwnerAccess`
* so a bypassed gate still fails closed. The code is revealed only in the
* reply to the requesting (owner) surface, never pushed to the connector.
*/
import type {
Action,
ActionExample,
ActionResult,
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { logger } from "@elizaos/core";
import { hasOwnerAccess } from "../security/access.ts";
import {
type OwnerBindConnector,
resolveOwnerBindingService,
} from "../services/owner-binding.ts";
const PAIR_OWNER_ACCOUNT = "PAIR_OWNER_ACCOUNT";
/** Connectors that ship a pairing command today, with the command to run. */
const PAIRABLE_CONNECTORS: ReadonlyArray<{
connector: OwnerBindConnector;
displayName: string;
/** Exact command the owner runs on the platform (naming differs because
* Telegram bot commands cannot contain hyphens). */
command: string;
/** Terms in the owner's message that select this connector. */
terms: readonly string[];
}> = [
{
connector: "discord",
displayName: "Discord",
command: "/eliza-pair",
terms: ["discord"],
},
{
connector: "telegram",
displayName: "Telegram",
command: "/eliza_pair",
terms: ["telegram"],
},
];
/** Verbs that signal a link/pair/verify intent. */
const PAIR_INTENT_TERMS = [
"link",
"pair",
"bind",
"verify",
"connect",
"prove",
];
function normalize(text: string): string {
return text.toLowerCase();
}
/** The pairable connector named in the text, or null when none/ambiguous. */
export function resolveRequestedPairConnector(
text: string,
): (typeof PAIRABLE_CONNECTORS)[number] | null {
const normalized = normalize(text);
const mentioned = PAIRABLE_CONNECTORS.filter((entry) =>
entry.terms.some((term) => normalized.includes(term)),
);
return mentioned.length === 1 ? (mentioned[0] ?? null) : null;
}
/**
* True when the message expresses intent to pair/link an owner platform
* account. Requires both a pair-style verb AND a supported connector name so
* unrelated "link this doc" / "connect the printer" text does not match.
*/
export function messageWantsOwnerPairing(text: string): boolean {
const normalized = normalize(text);
const hasVerb = PAIR_INTENT_TERMS.some((term) => normalized.includes(term));
if (!hasVerb) return false;
return PAIRABLE_CONNECTORS.some((entry) =>
entry.terms.some((term) => normalized.includes(term)),
);
}
function failure(text: string, error: string): ActionResult {
return {
success: false,
text,
values: { success: false, error },
data: { actionName: PAIR_OWNER_ACCOUNT, error },
};
}
export const pairOwnerAccountAction: Action = {
name: PAIR_OWNER_ACCOUNT,
contexts: ["settings", "messaging"],
roleGate: { minRole: "OWNER" },
similes: [
"LINK_MY_DISCORD",
"LINK_MY_TELEGRAM",
"PAIR_DISCORD_ACCOUNT",
"PAIR_TELEGRAM_ACCOUNT",
"VERIFY_OWNER_ACCOUNT",
"BIND_OWNER_ACCOUNT",
],
description:
"Link the app owner's Discord or Telegram account to their owner " +
"identity. Issues a one-time pairing code and tells the owner which " +
"command to run on the platform; once verified, messages from that " +
"platform account are recognized as the owner. Owner-only.",
validate: async (
_runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
): Promise<boolean> => {
const text =
typeof message.content.text === "string" ? message.content.text : "";
if (!text.trim()) return false;
return messageWantsOwnerPairing(text);
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
_state?: State,
_options?: HandlerOptions,
callback?: HandlerCallback,
): Promise<ActionResult> => {
// Defense in depth: the roleGate already scopes planner exposure, but the
// pairing code grants OWNER to whoever redeems it, so the handler itself
// must refuse any sender that does not resolve to the owner.
if (!(await hasOwnerAccess(runtime, message))) {
const text =
"Only the app owner can link an owner account. Ask the owner to run this from their own chat.";
if (callback) {
await callback({ text, action: PAIR_OWNER_ACCOUNT });
}
return failure(text, "NOT_OWNER");
}
const messageText =
typeof message.content.text === "string" ? message.content.text : "";
const target = resolveRequestedPairConnector(messageText);
if (!target) {
const names = PAIRABLE_CONNECTORS.map((c) => c.displayName).join(" or ");
const text = `Which account should I link — ${names}?`;
if (callback) {
await callback({ text, action: PAIR_OWNER_ACCOUNT });
}
return failure(text, "CONNECTOR_AMBIGUOUS");
}
const service = resolveOwnerBindingService(runtime);
if (!service) {
const text =
"Owner pairing is not available right now (the pairing service is not running).";
if (callback) {
await callback({ text, action: PAIR_OWNER_ACCOUNT });
}
return failure(text, "SERVICE_UNAVAILABLE");
}
let issued: ReturnType<typeof service.beginOwnerBind>;
try {
issued = service.beginOwnerBind({ connector: target.connector });
} catch (err) {
// error-policy:J4 explicit user-facing degrade — the one expected
// failure is "no canonical owner configured", surfaced as a designed
// unavailable reply; every failure is also reported for repair.
runtime.reportError("agent:pair-owner-account", err, {
connector: target.connector,
});
const text =
"I couldn't issue a pairing code. Finish app setup first so an owner identity exists, then try again.";
if (callback) {
await callback({ text, action: PAIR_OWNER_ACCOUNT });
}
return failure(text, "ISSUE_FAILED");
}
const minutes = Math.round((issued.expiresAt - Date.now()) / 60_000);
const replyText =
`Here's your ${target.displayName} pairing code: **${issued.code}**\n\n` +
`Run \`${target.command} ${issued.code}\` ${
target.connector === "discord"
? "in any server or DM with me on Discord"
: "in your Telegram chat with me"
}. The code is single-use and expires in ${minutes > 0 ? minutes : 5} minutes. ` +
"Don't share it — whoever redeems it is recognized as the owner.";
if (callback) {
await callback({ text: replyText, action: PAIR_OWNER_ACCOUNT });
}
logger.info(
{
src: "agent:pair-owner-account",
agentId: runtime.agentId,
connector: target.connector,
},
"Issued owner pairing code via chat",
);
return {
success: true,
text: replyText,
values: { success: true, connector: target.connector },
data: {
actionName: PAIR_OWNER_ACCOUNT,
connector: target.connector,
expiresAt: issued.expiresAt,
},
};
},
examples: [
[
{
name: "{{name1}}",
content: { text: "link my discord account" },
},
{
name: "{{agentName}}",
content: {
text: "Here's your Discord pairing code: **123456** — run `/eliza-pair 123456` in any server or DM with me on Discord.",
action: PAIR_OWNER_ACCOUNT,
},
},
],
[
{
name: "{{name1}}",
content: { text: "pair my telegram so you know it's me" },
},
{
name: "{{agentName}}",
content: {
text: "Here's your Telegram pairing code: **654321** — run `/eliza_pair 654321` in your Telegram chat with me.",
action: PAIR_OWNER_ACCOUNT,
},
},
],
] as ActionExample[][],
};

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