chore: import upstream snapshot with attribution
Voice Workbench / headless workbench (mocked backends) (push) Has been cancelled
Voice Workbench / real acoustic lane (nightly, provisioned only) (push) Has been cancelled
ci / test (push) Has been cancelled
ci / lint-and-format (push) Has been cancelled
ci / build (push) Has been cancelled
ci / dev-startup (push) Has been cancelled
gitleaks / gitleaks (push) Has been cancelled
Markdown Links / Relative Markdown Links (push) Has been cancelled
Quality (Extended) / Homepage Build (PR smoke) (push) Has been cancelled
Quality (Extended) / Comment-only diff guard (push) Has been cancelled
Quality (Extended) / Format + Type Safety Ratchet (push) Has been cancelled
Quality (Extended) / Develop Gate (secret scan + UI determinism) (push) Has been cancelled
Quality (Extended) / Develop Gate (lint) (push) Has been cancelled
Chat shell gestures / Chat shell gesture + parity e2e (push) Has been cancelled
Cloud Gateway Discord / Test (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx @biomejs/biome check packages/lifeops-bench/src, benchmark-lint) (push) Has been cancelled
Benchmark Bridge Tests / benchmark (bunx vitest run --config packages/lifeops-bench/vitest.config.ts --root packages/lifeops-bench --passWithNoTests, benchmark-tests) (push) Has been cancelled
Build Agent Image / build-and-push (push) Has been cancelled
Dev Smoke / bun run dev onboarding chat (push) Has been cancelled
Dev Smoke / Vite HMR dependency-level smoke (push) Has been cancelled
Electrobun Submodule Guard / electrobun gitlink is fetchable (push) Has been cancelled
Publish @elizaos/example-code / check_npm (push) Has been cancelled
Publish @elizaos/example-code / publish_npm (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / verify_version (push) Has been cancelled
Publish @elizaos/plugin-elizacloud / publish_npm (push) Has been cancelled
Sandbox Live Smoke / Sandbox live smoke (push) Has been cancelled
Snap Build & Test / Build Snap (amd64) (push) Has been cancelled
Snap Build & Test / Build Snap (arm64) (push) Has been cancelled
Test Packaging / elizaos CLI global-install smoke (node + bun) (push) Has been cancelled
Cloud Gateway Webhook / Test (push) Has been cancelled
Cloud Tests / lint-and-types (push) Has been cancelled
Cloud Tests / unit-tests (push) Has been cancelled
Cloud Tests / integration-tests (push) Has been cancelled
Cloud Tests / e2e-tests (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Deploy Apps Worker (Product 2) / Determine environment (push) Has been cancelled
Deploy Apps Worker (Product 2) / Deploy apps worker to apps-control host (${{ needs.determine-env.outputs.environment }}) (push) Has been cancelled
Deploy Eliza Provisioning Worker / Determine environment (push) Has been cancelled
Deploy Eliza Provisioning Worker / Deploy worker to Hetzner host (${{ needs.determine-env.outputs.environment }} @ ${{ needs.determine-env.outputs.deployment_sha }}) (push) Has been cancelled
Dev Smoke / Classify changed paths (push) Has been cancelled
supply-chain / sbom (push) Has been cancelled
supply-chain / vulnerability-scan (push) Has been cancelled
Build, Push & Deploy to Phala Cloud / build-and-push (push) Has been cancelled
Test Packaging / Validate Packaging Configs (push) Has been cancelled
Test Packaging / Build & Test PyPI Package (push) Has been cancelled
Test Packaging / PyPI on Python ${{ matrix.python }} (push) Has been cancelled
Test Packaging / Pack & Test JS Tarballs (push) Has been cancelled
UI Fixture E2E / ui-fixture-e2e (push) Has been cancelled
UI Fixture E2E / fixture-e2e (push) Has been cancelled
UI Story Gate / story-gate (push) Has been cancelled
vault-ci / test (macos-latest) (push) Has been cancelled
vault-ci / test (ubuntu-latest) (push) Has been cancelled
vault-ci / test (windows-latest) (push) Has been cancelled
vault-ci / app-core wiring tests (push) Has been cancelled
verify-patches / verify patches/CHECKSUMS.sha256 (push) Has been cancelled
Voice Benchmark Smoke / voice-emotion fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voiceagentbench fixture smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench-quality unit smoke (push) Has been cancelled
Voice Benchmark Smoke / voicebench TypeScript unit (no audio) (push) Has been cancelled
Voice Benchmark Smoke / voice bench smoke summary (push) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) Has been cancelled
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) 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
+225
View File
@@ -0,0 +1,225 @@
# @elizaos/plugin-cli-inference
TOS-clean SAFE/CLOUD inference route for elizaOS. Serves chat/planner inference by **spawning the sanctioned local CLI** (`claude --print` or `codex exec`) as eliza model handlers. The CLI reads its own subscription credentials from disk — eliza never sees, forwards, or logs the token.
## Purpose / role
This is the develop-shippable peer to the two TOS-gray, never-commit bypass paths:
- the in-process claude-code-stealth fetch interceptor at `packages/agent/src/auth/credentials.ts`, and
- `plugin-codex-cli`'s in-process `postResponses` HTTP path,
both of which replay the consumer-subscription token in-process. Here the handlers SHELL OUT to the official CLI, which loads `~/.claude/.credentials.json` / `~/.codex/auth.json` itself. The token is never injected into the child env (`filterEnv` allowlist + `SENSITIVE_ENV_RE` blocklist) or into logs (stderr is redacted before logging).
Node-only (`"platforms": ["node"]`) — exported from `index.node.ts` only.
## Enable
Single env gate: **`ELIZA_CHAT_VIA_CLI=claude`**, **`claude-sdk`**, **`codex`**, or **`codex-sdk`**.
- Unset → the plugin is never added to the resolved set (`auto-enable.ts shouldEnable` is false), and even if force-loaded its models map is empty. INERT; no existing code path changes.
- `claude` / `codex` → the large-tier handlers **cold-spawn** that CLI per call (`claude --print` / `codex exec`).
- `claude-sdk` → the handlers run a **warm Claude Agent SDK session** (one persistent process per `(model, systemPrompt, mode)`), not a per-call spawn. This is the fast + TOS-clean path: ~1-2s warm vs the CLI's 25-68s cold-spawn-per-call, and it does **native tool-calling** for the planner. See "Warm Agent SDK backend" below.
## Plugin surface
No actions, providers, evaluators, or routes. Model handlers only, and **only the large tier** so high-frequency should-respond/triage calls fall through to the cheap configured provider (bounding per-turn spawn cost to a few ~3-4s calls):
| Model type | Backend |
|---|---|
| `TEXT_LARGE` | `claude --print` or `codex exec` |
| `TEXT_MEGA` | "" |
| `RESPONSE_HANDLER` | "" |
| `ACTION_PLANNER` | "" — **only when `ELIZA_PLANNER_NATIVE_TOOLS=0`** (text-planner mode) |
`TEXT_SMALL` / `TEXT_NANO` / `TEXT_MEDIUM` are intentionally **not** registered (high-frequency triage tiers fall through to the cheap provider).
`ACTION_PLANNER` is **conditional**: in the default native-tools mode
(`ELIZA_PLANNER_NATIVE_TOOLS=1`) it is **not** registered, because that planner
needs GBNF / native-tool grammar the free-text CLI cannot honor — so the planner
stays on a grammar-honoring provider while the CLI still serves the user-facing
reply (`RESPONSE_HANDLER`) and large generations (`TEXT_LARGE`). In **text-planner
mode** (`ELIZA_PLANNER_NATIVE_TOOLS=0`) the CLI **does** register and serve
`ACTION_PLANNER`: the grammar-heavy planner prompt is rewritten into a clean
"pick ONE action, emit `{action, params}` JSON" routing prompt (see
`clean-routing-planner.ts`, proven live with `claude --print --model
claude-opus-4-8`). This is how the **whole brain** (chat + planner + coding) can
run on a single Claude Max subscription **TOS-clean**, no API key, no stealth.
Note: the per-turn `claude` subprocess makes the text-planner path slower than a
direct-API provider (~tens of seconds for a planner turn) — use the `claude-sdk`
backend below to keep the clean path fast.
## Warm Agent SDK backend (`ELIZA_CHAT_VIA_CLI=claude-sdk`)
The fast, TOS-clean way to run the whole brain on a Claude Max subscription.
Effective 2026-06-15 Anthropic grants subscriptions a monthly **Agent SDK
credit**, so driving the brain through `@anthropic-ai/claude-agent-sdk` (which
reads `~/.claude` / `CLAUDE_CODE_OAUTH_TOKEN` itself — eliza never sees the
token) is **officially sanctioned**, strictly cleaner than the stealth
token-replay. The SDK is loaded via a variable dynamic import (`src/claude-sdk-session.ts`)
so the plugin stays inert and never imports it unless this backend is set.
A `ClaudeSdkSession` keeps ONE warm streaming-input `query()` process alive, so
the cold-start is paid once, not per call. Two modes:
- **TEXT mode** (`generate`) — `RESPONSE_HANDLER` / `TEXT_LARGE` / `TEXT_MEGA`.
`allowedTools: []` + `settingSources: []` strip Claude Code's own tools and
project context → a warm chat-completion engine. The model is reframed as a
pure completion engine (`frameTextSystemPrompt` system prefix + a closing
`appendTextDirective`) so it synthesizes the final reply from already-executed
tool results rather than narrating agentic intent ("I'll fetch it…").
- **ROUTE mode** (`route`) — `ACTION_PLANNER` (text-planner mode). A single
in-process MCP tool `route_action({action, params})` is the only allowed tool.
The model emits a **native `tool_use`**; the SDK routes it to our handler
in-process; the handler captures `{action, params}` and **eliza executes the
action** (Claude Code never does). This matches the stealth/native path's full
functionality (WEB_FETCH, sub-agents) with no free-text JSON parsing and no
required-tool retry loop. The returned bare `{action, params}` is consumed by
the loop's existing text-mode parser — no core change.
Sessions are keyed by `(model, mode, sha256(systemPrompt))` because the SDK
freezes `systemPrompt` + `mcpServers` at `query()` start (no mid-session reset);
`setModel()` switches tiers live on one process. Calls are serialized; the
session self-heals on error and restarts after `restartAfterTurns` (default 20)
to bound context growth. The `result` envelope is inspected so an
`error_max_turns`/empty turn falls back to `result.result` instead of throwing a
spurious "empty completion".
Per-tier models: `ELIZA_CLI_CLAUDE_PLANNER_MODEL` (small/planner, e.g. sonnet) +
`ELIZA_CLI_CLAUDE_MODEL` (large, e.g. opus); `ELIZA_CLI_CLAUDE_BIN` points the
SDK at the Claude Code executable.
**Caveat:** the monthly Agent SDK credit can run dry mid-month (the SDK then
returns a session-limit error); plan a fallback (a key/Cloud tier, or stealth on
a self-host) for production continuity.
## Warm Codex SDK backend (`ELIZA_CHAT_VIA_CLI=codex-sdk`)
The codex peer of `claude-sdk` (`src/codex-sdk-session.ts`). Runs the brain on a
ChatGPT/Codex subscription via `@openai/codex-sdk` (loaded by variable dynamic
import; reads `~/.codex/auth.json` itself). A `CodexSdkSession` keeps ONE warm
`Thread` (`codex.startThread()` once, `thread.run()` per turn) instead of the
`codex exec` cold-spawn-per-call. Two modes:
- **TEXT** (`generate`): `thread.run(body)` with `sandboxMode:"read-only"`,
`approvalPolicy:"never"`, `networkAccessEnabled:false` → a warm completion
engine; returns the turn's `finalResponse`.
- **ROUTE** (`route`): codex NATIVE structured output (`outputSchema`) constrains
the turn to `{action, params}` (params as a JSON string for OpenAI strict mode),
reliable at scale. REQUIRES `ELIZA_CLI_CODEX_BIN` pointing at the system codex —
the SDK bundles an old codex (0.80.0) that rejects current models/structured output.
codex-sdk has no thread-level system prompt, so the system is folded into the
body and ONE warm thread per `(model, mode)` serves every system prompt. Per-tier
models: `ELIZA_CLI_CODEX_PLANNER_MODEL` + `ELIZA_CLI_CODEX_MODEL`;
`ELIZA_CLI_CODEX_REASONING_EFFORT` sets `modelReasoningEffort`.
**Status:** LIVE-VERIFIED in the bot on a ChatGPT/Codex sub — btc \$59,527, eth
\$1,566, weather, identity, knows-user, 8×8=64; live-info routes to WEB_FETCH and
synthesizes the real fetched value (after the canonical-contentToText fix). Needs
`ELIZA_CLI_CODEX_BIN`=system codex. 12 fake-SDK unit tests.
## Layout
```
plugins/plugin-cli-inference/
index.ts Plugin entry — gates + registers large-tier handlers; init double-activation guard
index.node.ts Node re-export
index.browser.ts Browser stub (node-only plugin; empty models)
auto-enable.ts shouldEnable = ELIZA_CHAT_VIA_CLI is claude|claude-sdk|codex
src/
claude-cli.ts ClaudeCli — spawns `claude --print`; __setSpawnForTests seam
codex-cli-exec.ts CodexCli — spawns `codex exec --json`; JSONL last-assistant parse
prompt-flatten.ts system/developer -> system slot; user/assistant/tool -> body; nothing dropped
sandbox.ts SOC2 helpers copied from plugin-sub-agent-claude-code (filterEnv/resolveSafeCwd/resolveSafeBinary/SENSITIVE_ENV_RE)
__tests__/
cli-inference.test.ts Unit tests (mock spawn): argv, token-absence, threading, parse, throw-on-error, large-tier-only
build.ts vitest.config.ts tsconfig*.json biome.json
```
## GenerateTextParams -> CLI mapping (HARD REQ: forward BOTH system AND messages/prompt)
- **claude:** `[claude, -p <flattened body>, --system-prompt <params.system FULL REPLACE>, --exclude-dynamic-system-prompt-sections, --output-format text, --model <ELIZA_CLI_CLAUDE_MODEL || claude-opus-4-8>]`, stdin `/dev/null`, cwd = isolated empty tmpdir, env = `filterEnv(process.env)`.
- **codex:** `[codex, exec, -m <ELIZA_CLI_CODEX_MODEL || gpt-5.5>, -s read-only, --skip-git-repo-check, -C <cwd>, --color never, --json, <system folded on top of flattened body>]`.
`prompt-flatten` re-routes system/developer roles to the system slot and flattens user/assistant/tool turns into the body; messages are NEVER dropped (would strip skills/memory/recent-convo/grammar).
## Config / env vars
| Var | Required | Default | Description |
|---|---|---|---|
| `ELIZA_CHAT_VIA_CLI` | — | (unset = inert) | `claude`, `claude-sdk`, or `codex` — the single enable gate |
| `ELIZA_CLI_CLAUDE_MODEL` | No | `claude-opus-4-8` | claude large-tier model (`--model` / SDK large tier) |
| `ELIZA_CLI_CLAUDE_PLANNER_MODEL` | No | (falls back to large) | `claude-sdk` small/planner tier model (e.g. sonnet) |
| `ELIZA_CLI_CLAUDE_BIN` | No | (SDK default / allowlist lookup) | path to the claude executable: drives the `claude-sdk` session AND pins the cold `claude` spawn (deploys outside the SOC2 launcher allowlist) |
| `ELIZA_CLI_SDK_RESTART_AFTER_TURNS` | No | `20` | `claude-sdk`: restart a warm session after N turns (bounds context) |
| `ELIZA_CLI_CODEX_MODEL` | No | `gpt-5.5` | codex large-tier model (`codex exec -m` / SDK large tier) |
| `ELIZA_CLI_CODEX_PLANNER_MODEL` | No | (falls back to large) | `codex-sdk` small/planner tier model |
| `ELIZA_CLI_CODEX_REASONING_EFFORT` | No | (sdk default) | `codex-sdk`: `modelReasoningEffort` (minimal..xhigh) |
| `ELIZA_CLI_CODEX_BIN` | No | (sdk bundled / allowlist lookup) | path to the system codex binary: REQUIRED for `codex-sdk` (bundled 0.80.0 rejects current models); also pins the cold `codex` spawn |
| `ELIZA_CLI_TIMEOUT_MS` | No | `120000` | per-call spawn timeout (SIGTERM on expiry; CLI backends) |
## Errors
Handlers THROW on non-zero exit / timeout (`+SIGTERM`) / empty stdout so `useModel` + AccountPool failover treat them as provider failures — never swallow-and-return-empty. stderr is redacted via `SENSITIVE_ENV_RE` before it reaches the error message or log.
## Commands
```bash
bun run --cwd plugins/plugin-cli-inference test # vitest (mocks spawn; no real CLI)
bun run --cwd plugins/plugin-cli-inference typecheck
bun run --cwd plugins/plugin-cli-inference lint:check
bun run --cwd plugins/plugin-cli-inference build
```
## Conventions / gotchas
- **Node-only.** `index.browser.ts` is a stub; the real handlers use `node:child_process`.
- **Double-activation guard.** `ELIZA_CHAT_VIA_CLI=claude` + `ELIZA_ENABLE_CLAUDE_STEALTH` both set throws in `init()` (two colliding claude routes). The guard lives in THIS plugin because `credentials.ts` is skip-worktree on the live branch.
- **Isolated cwd per call.** Created with `mkdtemp` under `tmpdir()`, validated by `resolveSafeCwd`, removed in a `finally`. Keeps the CLI out of real projects (suppresses Claude Code repo-context identity).
- **`/dev/null` stdin is REQUIRED** — without it the CLI waits ~3s for stdin.
- **sandbox.ts is a copy.** Keep in sync with `packages/plugin-remote-manifest/src/sub-agent-claude-code/sandbox.ts` if `SENSITIVE_ENV_RE` / `SAFE_ENV_KEYS` change upstream.
- **Multi-account pool auth + rotation (SDK backends only).** The `claude-sdk` / `codex-sdk` chat brain consults the shared `CODING_AGENT_SELECTOR_BRIDGE_SYMBOL` bridge accessor from `@elizaos/core` (in `src/account-rotation.ts`) POOL-FIRST: the FIRST warm-session auth selects a healthy pooled account and materializes its subprocess-only SDK env (`CLAUDE_CODE_OAUTH_TOKEN` / per-account `CODEX_HOME`), so an app-connected subscription is used immediately — the ambient `~/.claude` / `CLAUDE_CODE_OAUTH_TOKEN` credential is only the fallback when the pool is empty or selection fails. On a subscription-limit throw it then rotates to the next healthy pooled account before falling to provider failover — see issue #11180. Rotation evicts the warm session so it re-auths as the new account and retries transparently without mutating the parent `process.env`. Only rate-limit-class errors rotate; non-limit errors rethrow straight to failover. Default ON when a pool is present; opt out with `ELIZA_CLI_INFERENCE_ACCOUNT_ROTATION=0`. The COLD `claude --print` / `codex exec` CLIs still own one on-disk cred set (pool auth is SDK-only; the bare-CLI shim is issue #11180 Gap B).
- See the root `AGENTS.md` for repo-wide architecture rules, logger conventions, and ESM requirements.
<!-- 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 — model provider:**
- A trajectory from a **live** call to this provider (not the proxy, not a mock): full request, raw response, token usage, finish reason, and streamed chunks.
- Proof of tool/function-calling and structured-output parsing against the real model.
- The error paths exercised: bad key, model-not-found, oversized context, timeout, rate-limit, mid-stream disconnect — plus latency and cost from the real call.
- If no key is available in CI, attach the documented live-run transcript as evidence — never a mocked client passed off as a pass.
<!-- END: evidence-and-e2e-mandate -->
+225
View File
@@ -0,0 +1,225 @@
# @elizaos/plugin-cli-inference
TOS-clean SAFE/CLOUD inference route for elizaOS. Serves chat/planner inference by **spawning the sanctioned local CLI** (`claude --print` or `codex exec`) as eliza model handlers. The CLI reads its own subscription credentials from disk — eliza never sees, forwards, or logs the token.
## Purpose / role
This is the develop-shippable peer to the two TOS-gray, never-commit bypass paths:
- the in-process claude-code-stealth fetch interceptor at `packages/agent/src/auth/credentials.ts`, and
- `plugin-codex-cli`'s in-process `postResponses` HTTP path,
both of which replay the consumer-subscription token in-process. Here the handlers SHELL OUT to the official CLI, which loads `~/.claude/.credentials.json` / `~/.codex/auth.json` itself. The token is never injected into the child env (`filterEnv` allowlist + `SENSITIVE_ENV_RE` blocklist) or into logs (stderr is redacted before logging).
Node-only (`"platforms": ["node"]`) — exported from `index.node.ts` only.
## Enable
Single env gate: **`ELIZA_CHAT_VIA_CLI=claude`**, **`claude-sdk`**, **`codex`**, or **`codex-sdk`**.
- Unset → the plugin is never added to the resolved set (`auto-enable.ts shouldEnable` is false), and even if force-loaded its models map is empty. INERT; no existing code path changes.
- `claude` / `codex` → the large-tier handlers **cold-spawn** that CLI per call (`claude --print` / `codex exec`).
- `claude-sdk` → the handlers run a **warm Claude Agent SDK session** (one persistent process per `(model, systemPrompt, mode)`), not a per-call spawn. This is the fast + TOS-clean path: ~1-2s warm vs the CLI's 25-68s cold-spawn-per-call, and it does **native tool-calling** for the planner. See "Warm Agent SDK backend" below.
## Plugin surface
No actions, providers, evaluators, or routes. Model handlers only, and **only the large tier** so high-frequency should-respond/triage calls fall through to the cheap configured provider (bounding per-turn spawn cost to a few ~3-4s calls):
| Model type | Backend |
|---|---|
| `TEXT_LARGE` | `claude --print` or `codex exec` |
| `TEXT_MEGA` | "" |
| `RESPONSE_HANDLER` | "" |
| `ACTION_PLANNER` | "" — **only when `ELIZA_PLANNER_NATIVE_TOOLS=0`** (text-planner mode) |
`TEXT_SMALL` / `TEXT_NANO` / `TEXT_MEDIUM` are intentionally **not** registered (high-frequency triage tiers fall through to the cheap provider).
`ACTION_PLANNER` is **conditional**: in the default native-tools mode
(`ELIZA_PLANNER_NATIVE_TOOLS=1`) it is **not** registered, because that planner
needs GBNF / native-tool grammar the free-text CLI cannot honor — so the planner
stays on a grammar-honoring provider while the CLI still serves the user-facing
reply (`RESPONSE_HANDLER`) and large generations (`TEXT_LARGE`). In **text-planner
mode** (`ELIZA_PLANNER_NATIVE_TOOLS=0`) the CLI **does** register and serve
`ACTION_PLANNER`: the grammar-heavy planner prompt is rewritten into a clean
"pick ONE action, emit `{action, params}` JSON" routing prompt (see
`clean-routing-planner.ts`, proven live with `claude --print --model
claude-opus-4-8`). This is how the **whole brain** (chat + planner + coding) can
run on a single Claude Max subscription **TOS-clean**, no API key, no stealth.
Note: the per-turn `claude` subprocess makes the text-planner path slower than a
direct-API provider (~tens of seconds for a planner turn) — use the `claude-sdk`
backend below to keep the clean path fast.
## Warm Agent SDK backend (`ELIZA_CHAT_VIA_CLI=claude-sdk`)
The fast, TOS-clean way to run the whole brain on a Claude Max subscription.
Effective 2026-06-15 Anthropic grants subscriptions a monthly **Agent SDK
credit**, so driving the brain through `@anthropic-ai/claude-agent-sdk` (which
reads `~/.claude` / `CLAUDE_CODE_OAUTH_TOKEN` itself — eliza never sees the
token) is **officially sanctioned**, strictly cleaner than the stealth
token-replay. The SDK is loaded via a variable dynamic import (`src/claude-sdk-session.ts`)
so the plugin stays inert and never imports it unless this backend is set.
A `ClaudeSdkSession` keeps ONE warm streaming-input `query()` process alive, so
the cold-start is paid once, not per call. Two modes:
- **TEXT mode** (`generate`) — `RESPONSE_HANDLER` / `TEXT_LARGE` / `TEXT_MEGA`.
`allowedTools: []` + `settingSources: []` strip Claude Code's own tools and
project context → a warm chat-completion engine. The model is reframed as a
pure completion engine (`frameTextSystemPrompt` system prefix + a closing
`appendTextDirective`) so it synthesizes the final reply from already-executed
tool results rather than narrating agentic intent ("I'll fetch it…").
- **ROUTE mode** (`route`) — `ACTION_PLANNER` (text-planner mode). A single
in-process MCP tool `route_action({action, params})` is the only allowed tool.
The model emits a **native `tool_use`**; the SDK routes it to our handler
in-process; the handler captures `{action, params}` and **eliza executes the
action** (Claude Code never does). This matches the stealth/native path's full
functionality (WEB_FETCH, sub-agents) with no free-text JSON parsing and no
required-tool retry loop. The returned bare `{action, params}` is consumed by
the loop's existing text-mode parser — no core change.
Sessions are keyed by `(model, mode, sha256(systemPrompt))` because the SDK
freezes `systemPrompt` + `mcpServers` at `query()` start (no mid-session reset);
`setModel()` switches tiers live on one process. Calls are serialized; the
session self-heals on error and restarts after `restartAfterTurns` (default 20)
to bound context growth. The `result` envelope is inspected so an
`error_max_turns`/empty turn falls back to `result.result` instead of throwing a
spurious "empty completion".
Per-tier models: `ELIZA_CLI_CLAUDE_PLANNER_MODEL` (small/planner, e.g. sonnet) +
`ELIZA_CLI_CLAUDE_MODEL` (large, e.g. opus); `ELIZA_CLI_CLAUDE_BIN` points the
SDK at the Claude Code executable.
**Caveat:** the monthly Agent SDK credit can run dry mid-month (the SDK then
returns a session-limit error); plan a fallback (a key/Cloud tier, or stealth on
a self-host) for production continuity.
## Warm Codex SDK backend (`ELIZA_CHAT_VIA_CLI=codex-sdk`)
The codex peer of `claude-sdk` (`src/codex-sdk-session.ts`). Runs the brain on a
ChatGPT/Codex subscription via `@openai/codex-sdk` (loaded by variable dynamic
import; reads `~/.codex/auth.json` itself). A `CodexSdkSession` keeps ONE warm
`Thread` (`codex.startThread()` once, `thread.run()` per turn) instead of the
`codex exec` cold-spawn-per-call. Two modes:
- **TEXT** (`generate`): `thread.run(body)` with `sandboxMode:"read-only"`,
`approvalPolicy:"never"`, `networkAccessEnabled:false` → a warm completion
engine; returns the turn's `finalResponse`.
- **ROUTE** (`route`): codex NATIVE structured output (`outputSchema`) constrains
the turn to `{action, params}` (params as a JSON string for OpenAI strict mode),
reliable at scale. REQUIRES `ELIZA_CLI_CODEX_BIN` pointing at the system codex —
the SDK bundles an old codex (0.80.0) that rejects current models/structured output.
codex-sdk has no thread-level system prompt, so the system is folded into the
body and ONE warm thread per `(model, mode)` serves every system prompt. Per-tier
models: `ELIZA_CLI_CODEX_PLANNER_MODEL` + `ELIZA_CLI_CODEX_MODEL`;
`ELIZA_CLI_CODEX_REASONING_EFFORT` sets `modelReasoningEffort`.
**Status:** LIVE-VERIFIED in the bot on a ChatGPT/Codex sub — btc \$59,527, eth
\$1,566, weather, identity, knows-user, 8×8=64; live-info routes to WEB_FETCH and
synthesizes the real fetched value (after the canonical-contentToText fix). Needs
`ELIZA_CLI_CODEX_BIN`=system codex. 12 fake-SDK unit tests.
## Layout
```
plugins/plugin-cli-inference/
index.ts Plugin entry — gates + registers large-tier handlers; init double-activation guard
index.node.ts Node re-export
index.browser.ts Browser stub (node-only plugin; empty models)
auto-enable.ts shouldEnable = ELIZA_CHAT_VIA_CLI is claude|claude-sdk|codex
src/
claude-cli.ts ClaudeCli — spawns `claude --print`; __setSpawnForTests seam
codex-cli-exec.ts CodexCli — spawns `codex exec --json`; JSONL last-assistant parse
prompt-flatten.ts system/developer -> system slot; user/assistant/tool -> body; nothing dropped
sandbox.ts SOC2 helpers copied from plugin-sub-agent-claude-code (filterEnv/resolveSafeCwd/resolveSafeBinary/SENSITIVE_ENV_RE)
__tests__/
cli-inference.test.ts Unit tests (mock spawn): argv, token-absence, threading, parse, throw-on-error, large-tier-only
build.ts vitest.config.ts tsconfig*.json biome.json
```
## GenerateTextParams -> CLI mapping (HARD REQ: forward BOTH system AND messages/prompt)
- **claude:** `[claude, -p <flattened body>, --system-prompt <params.system FULL REPLACE>, --exclude-dynamic-system-prompt-sections, --output-format text, --model <ELIZA_CLI_CLAUDE_MODEL || claude-opus-4-8>]`, stdin `/dev/null`, cwd = isolated empty tmpdir, env = `filterEnv(process.env)`.
- **codex:** `[codex, exec, -m <ELIZA_CLI_CODEX_MODEL || gpt-5.5>, -s read-only, --skip-git-repo-check, -C <cwd>, --color never, --json, <system folded on top of flattened body>]`.
`prompt-flatten` re-routes system/developer roles to the system slot and flattens user/assistant/tool turns into the body; messages are NEVER dropped (would strip skills/memory/recent-convo/grammar).
## Config / env vars
| Var | Required | Default | Description |
|---|---|---|---|
| `ELIZA_CHAT_VIA_CLI` | — | (unset = inert) | `claude`, `claude-sdk`, or `codex` — the single enable gate |
| `ELIZA_CLI_CLAUDE_MODEL` | No | `claude-opus-4-8` | claude large-tier model (`--model` / SDK large tier) |
| `ELIZA_CLI_CLAUDE_PLANNER_MODEL` | No | (falls back to large) | `claude-sdk` small/planner tier model (e.g. sonnet) |
| `ELIZA_CLI_CLAUDE_BIN` | No | (SDK default / allowlist lookup) | path to the claude executable: drives the `claude-sdk` session AND pins the cold `claude` spawn (deploys outside the SOC2 launcher allowlist) |
| `ELIZA_CLI_SDK_RESTART_AFTER_TURNS` | No | `20` | `claude-sdk`: restart a warm session after N turns (bounds context) |
| `ELIZA_CLI_CODEX_MODEL` | No | `gpt-5.5` | codex large-tier model (`codex exec -m` / SDK large tier) |
| `ELIZA_CLI_CODEX_PLANNER_MODEL` | No | (falls back to large) | `codex-sdk` small/planner tier model |
| `ELIZA_CLI_CODEX_REASONING_EFFORT` | No | (sdk default) | `codex-sdk`: `modelReasoningEffort` (minimal..xhigh) |
| `ELIZA_CLI_CODEX_BIN` | No | (sdk bundled / allowlist lookup) | path to the system codex binary: REQUIRED for `codex-sdk` (bundled 0.80.0 rejects current models); also pins the cold `codex` spawn |
| `ELIZA_CLI_TIMEOUT_MS` | No | `120000` | per-call spawn timeout (SIGTERM on expiry; CLI backends) |
## Errors
Handlers THROW on non-zero exit / timeout (`+SIGTERM`) / empty stdout so `useModel` + AccountPool failover treat them as provider failures — never swallow-and-return-empty. stderr is redacted via `SENSITIVE_ENV_RE` before it reaches the error message or log.
## Commands
```bash
bun run --cwd plugins/plugin-cli-inference test # vitest (mocks spawn; no real CLI)
bun run --cwd plugins/plugin-cli-inference typecheck
bun run --cwd plugins/plugin-cli-inference lint:check
bun run --cwd plugins/plugin-cli-inference build
```
## Conventions / gotchas
- **Node-only.** `index.browser.ts` is a stub; the real handlers use `node:child_process`.
- **Double-activation guard.** `ELIZA_CHAT_VIA_CLI=claude` + `ELIZA_ENABLE_CLAUDE_STEALTH` both set throws in `init()` (two colliding claude routes). The guard lives in THIS plugin because `credentials.ts` is skip-worktree on the live branch.
- **Isolated cwd per call.** Created with `mkdtemp` under `tmpdir()`, validated by `resolveSafeCwd`, removed in a `finally`. Keeps the CLI out of real projects (suppresses Claude Code repo-context identity).
- **`/dev/null` stdin is REQUIRED** — without it the CLI waits ~3s for stdin.
- **sandbox.ts is a copy.** Keep in sync with `packages/plugin-remote-manifest/src/sub-agent-claude-code/sandbox.ts` if `SENSITIVE_ENV_RE` / `SAFE_ENV_KEYS` change upstream.
- **Multi-account pool auth + rotation (SDK backends only).** The `claude-sdk` / `codex-sdk` chat brain consults the shared `CODING_AGENT_SELECTOR_BRIDGE_SYMBOL` bridge accessor from `@elizaos/core` (in `src/account-rotation.ts`) POOL-FIRST: the FIRST warm-session auth selects a healthy pooled account and materializes its subprocess-only SDK env (`CLAUDE_CODE_OAUTH_TOKEN` / per-account `CODEX_HOME`), so an app-connected subscription is used immediately — the ambient `~/.claude` / `CLAUDE_CODE_OAUTH_TOKEN` credential is only the fallback when the pool is empty or selection fails. On a subscription-limit throw it then rotates to the next healthy pooled account before falling to provider failover — see issue #11180. Rotation evicts the warm session so it re-auths as the new account and retries transparently without mutating the parent `process.env`. Only rate-limit-class errors rotate; non-limit errors rethrow straight to failover. Default ON when a pool is present; opt out with `ELIZA_CLI_INFERENCE_ACCOUNT_ROTATION=0`. The COLD `claude --print` / `codex exec` CLIs still own one on-disk cred set (pool auth is SDK-only; the bare-CLI shim is issue #11180 Gap B).
- See the root `AGENTS.md` for repo-wide architecture rules, logger conventions, and ESM requirements.
<!-- 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 — model provider:**
- A trajectory from a **live** call to this provider (not the proxy, not a mock): full request, raw response, token usage, finish reason, and streamed chunks.
- Proof of tool/function-calling and structured-output parsing against the real model.
- The error paths exercised: bad key, model-not-found, oversized context, timeout, rate-limit, mid-stream disconnect — plus latency and cost from the real call.
- If no key is available in CI, attach the documented live-run transcript as evidence — never a mocked client passed off as a pass.
<!-- END: evidence-and-e2e-mandate -->
@@ -0,0 +1,466 @@
import { CODING_AGENT_SELECTOR_BRIDGE_SYMBOL } from "@elizaos/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildRotatedSubprocessEnv,
isSubscriptionLimitError,
type RotationAccountSelection,
resetRotationStateForTests,
rotationAgentTypeForBackend,
rotationEnabled,
withAccountRotation,
} from "../src/account-rotation";
import { ProviderApiError } from "../src/provider-errors";
/**
* Issue #11180 Gap A: the chat brain must (1) authenticate its FIRST warm
* session from the account pool when one is present — an app-connected
* subscription is used immediately, not stored-but-unused until a limit error;
* ambient stays the fallback when the pool is empty — and (2) rotate to the
* next healthy pooled account on a subscription limit, and ONLY on a
* subscription limit — a non-limit error must fall straight through to the
* caller's provider-failover chain.
*
* These drive the pure rotation logic with a FAKE coding-agent selector bridge
* installed on the `globalThis` symbol (the real bridge lives in app-core; the
* plugin only reads the contract off the symbol). No real pool, no real SDK, no
* second live account needed to prove the logic — exactly as the issue's test
* plan requires.
*/
const BRIDGE_SYMBOL = CODING_AGENT_SELECTOR_BRIDGE_SYMBOL;
interface FakeBridge {
select: ReturnType<typeof vi.fn>;
markRateLimited: ReturnType<typeof vi.fn>;
recordUsage: ReturnType<typeof vi.fn>;
}
function installFakeBridge(selections: Array<RotationAccountSelection | null>): FakeBridge {
let i = 0;
const bridge: FakeBridge = {
select: vi.fn(async () => {
const next = i < selections.length ? selections[i] : null;
i += 1;
return next;
}),
markRateLimited: vi.fn(async () => undefined),
recordUsage: vi.fn(async () => undefined),
};
(globalThis as Record<symbol, unknown>)[BRIDGE_SYMBOL] = bridge;
return bridge;
}
function uninstallBridge(): void {
delete (globalThis as Record<symbol, unknown>)[BRIDGE_SYMBOL];
}
function account(id: string): RotationAccountSelection {
return {
providerId: "anthropic-subscription",
accountId: id,
label: id,
source: "oauth",
strategy: "least-used",
envPatch: { CLAUDE_CODE_OAUTH_TOKEN: `tok-${id}` },
};
}
const enabledGetter = () => undefined;
afterEach(() => {
uninstallBridge();
resetRotationStateForTests();
vi.restoreAllMocks();
});
describe("isSubscriptionLimitError", () => {
it("classifies the session handler's own limit throw", () => {
expect(
isSubscriptionLimitError(
new Error(
"[cli-inference:sdk] subscription rate limit reached: You've hit your session limit"
)
)
).toBe(true);
});
it("classifies 429 / 529 status envelopes", () => {
expect(
isSubscriptionLimitError(new ProviderApiError("upstream API Error: 429", { statusCode: 429 }))
).toBe(true);
expect(
isSubscriptionLimitError(new ProviderApiError("upstream API Error: 529", { statusCode: 529 }))
).toBe(true);
expect(isSubscriptionLimitError(new Error("API Error: 429 rate limited"))).toBe(true);
});
it("classifies provider quota / rate-limit vocabulary", () => {
expect(isSubscriptionLimitError(new Error("usage limit reached"))).toBe(true);
expect(isSubscriptionLimitError(new Error("quota exhausted for this key"))).toBe(true);
expect(isSubscriptionLimitError(new Error("too many requests"))).toBe(true);
});
it("classifies OpenAI's classic quota envelope (inverted word order, no 429 literal)", () => {
// The real envelope: message text alone, no statusCode on the thrown error —
// the exact shape a codex-sdk turn surfaces. Must rotate, not tier-failover.
expect(
isSubscriptionLimitError(
new Error(
"You exceeded your current quota, please check your plan and billing details. " +
"For more information on this error, read the docs: https://platform.openai.com/docs/guides/error-codes/api-errors."
)
)
).toBe(true);
// Truncated variants: either envelope half alone still classifies.
expect(isSubscriptionLimitError(new Error("You exceeded your current quota"))).toBe(true);
expect(isSubscriptionLimitError(new Error("please check your plan and billing details"))).toBe(
true
);
// The machine-readable error code from the JSON envelope body.
expect(
isSubscriptionLimitError(
new Error('{"error":{"type":"insufficient_quota","code":"insufficient_quota"}}')
)
).toBe(true);
});
it("does NOT classify prose that merely talks about quotas / billing", () => {
expect(
isSubscriptionLimitError(
new Error("the user asked how quotas work and whether billing resets monthly")
)
).toBe(false);
expect(
isSubscriptionLimitError(new Error("your quota looks fine; billing details are unchanged"))
).toBe(false);
});
it("does NOT classify non-limit errors (would burn a healthy account)", () => {
expect(
isSubscriptionLimitError(new Error("[cli-inference:sdk] empty completion (subtype=success)"))
).toBe(false);
expect(
isSubscriptionLimitError(
new ProviderApiError("API Error: 400 messages: text content blocks must be non-empty", {
statusCode: 400,
})
)
).toBe(false);
expect(isSubscriptionLimitError(new Error("401 unauthorized"))).toBe(false);
expect(isSubscriptionLimitError(new Error("route: model emitted no decision"))).toBe(false);
});
});
describe("rotationAgentTypeForBackend", () => {
it("maps only the SDK backends to a rotation agent type", () => {
expect(rotationAgentTypeForBackend("claude-sdk")).toBe("claude");
expect(rotationAgentTypeForBackend("codex-sdk")).toBe("codex");
// Cold CLIs read the single on-disk login — out of scope (Gap B / CLI shim).
expect(rotationAgentTypeForBackend("claude")).toBeNull();
expect(rotationAgentTypeForBackend("codex")).toBeNull();
});
});
describe("rotationEnabled", () => {
it("defaults ON and honors the opt-out flag", () => {
expect(rotationEnabled(() => undefined)).toBe(true);
expect(rotationEnabled(() => "1")).toBe(true);
for (const off of ["0", "false", "no", "off", "OFF", " Off "]) {
expect(rotationEnabled(() => off)).toBe(false);
}
});
});
describe("buildRotatedSubprocessEnv", () => {
it("keeps ambient process env intact while selected account auth wins in subprocess env", () => {
const saved = {
CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN,
ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY,
CODEX_HOME: process.env.CODEX_HOME,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
};
process.env.CLAUDE_CODE_OAUTH_TOKEN = "ambient-claude-token";
process.env.ANTHROPIC_API_KEY = "ambient-anthropic-key";
process.env.CODEX_HOME = "/ambient/codex";
process.env.OPENAI_API_KEY = "ambient-openai-key";
try {
const claudeEnv = buildRotatedSubprocessEnv("claude", {
CLAUDE_CODE_OAUTH_TOKEN: "selected-claude-token",
});
expect(claudeEnv.CLAUDE_CODE_OAUTH_TOKEN).toBe("selected-claude-token");
expect(claudeEnv.ANTHROPIC_API_KEY).toBeUndefined();
expect(claudeEnv.PATH).toBe(process.env.PATH);
const codexEnv = buildRotatedSubprocessEnv("codex", { CODEX_HOME: "/selected/codex" });
expect(codexEnv.CODEX_HOME).toBe("/selected/codex");
expect(codexEnv.OPENAI_API_KEY).toBeUndefined();
expect(codexEnv.PATH).toBe(process.env.PATH);
expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("ambient-claude-token");
expect(process.env.ANTHROPIC_API_KEY).toBe("ambient-anthropic-key");
expect(process.env.CODEX_HOME).toBe("/ambient/codex");
expect(process.env.OPENAI_API_KEY).toBe("ambient-openai-key");
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
});
describe("withAccountRotation", () => {
const ctx = (overrides: Record<string, unknown> = {}) => ({
backend: "claude-sdk",
getValue: enabledGetter,
onRotate: vi.fn(),
...overrides,
});
it("uses a pooled account for the FIRST warm-session auth — no ambient token needed", async () => {
// THE app-connect regression: a machine with NO ambient CLI login but a
// pooled (app-connected) subscription must serve the very first turn from
// the pool, not fail / sit stored-but-unused until a limit error.
const savedToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
const bridge = installFakeBridge([account("b")]);
const attempt = vi.fn(async (env?: Record<string, string | undefined>) => {
expect(env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok-b");
return "first-turn-on-pooled-account";
});
const c = ctx();
try {
await expect(withAccountRotation(attempt, c as never)).resolves.toBe(
"first-turn-on-pooled-account"
);
expect(attempt).toHaveBeenCalledTimes(1);
// Pool consulted BEFORE the first attempt, without an exclude list.
expect(bridge.select).toHaveBeenCalledTimes(1);
expect(bridge.select.mock.calls[0][1]?.exclude).toBeUndefined();
expect(bridge.select.mock.invocationCallOrder[0]).toBeLessThan(
attempt.mock.invocationCallOrder[0]
);
// Any stale ambient-auth'd warm session is evicted before the attempt.
expect(c.onRotate).toHaveBeenCalledTimes(1);
// Usage recorded against the initially-selected account on success.
expect(bridge.recordUsage).toHaveBeenCalledWith("anthropic-subscription", "b", { ok: true });
// The pooled token never leaks into the parent process env.
expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
} finally {
if (savedToken !== undefined) process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken;
}
});
it("falls back to the ambient credential when the pool is empty (select → null)", async () => {
const bridge = installFakeBridge([null]);
const attempt = vi.fn(async (env?: Record<string, string | undefined>) => {
expect(env).toBeUndefined();
return "ambient-answer";
});
const c = ctx();
await expect(withAccountRotation(attempt, c as never)).resolves.toBe("ambient-answer");
expect(attempt).toHaveBeenCalledTimes(1);
expect(bridge.select).toHaveBeenCalledTimes(1);
expect(c.onRotate).not.toHaveBeenCalled();
});
it("falls back to the ambient credential when the initial pool selection throws", async () => {
const bridge = installFakeBridge([]);
bridge.select.mockRejectedValueOnce(new Error("pool store unavailable"));
const attempt = vi.fn(async (env?: Record<string, string | undefined>) => {
expect(env).toBeUndefined();
return "ambient-answer";
});
await expect(withAccountRotation(attempt, ctx() as never)).resolves.toBe("ambient-answer");
expect(attempt).toHaveBeenCalledTimes(1);
});
it("rotates on a subscription-limit error then succeeds on the next account", async () => {
const savedToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
const savedKey = process.env.ANTHROPIC_API_KEY;
process.env.CLAUDE_CODE_OAUTH_TOKEN = "ambient-token";
process.env.ANTHROPIC_API_KEY = "ambient-key";
const bridge = installFakeBridge([account("b"), account("c")]);
let calls = 0;
const seenEnv: Array<Record<string, string | undefined> | undefined> = [];
const attempt = vi.fn(async (env?: Record<string, string | undefined>) => {
seenEnv.push(env);
calls += 1;
if (calls === 1) throw new Error("subscription rate limit reached: session limit");
return "answer-on-account-c";
});
const c = ctx();
try {
await expect(withAccountRotation(attempt, c as never)).resolves.toBe("answer-on-account-c");
expect(attempt).toHaveBeenCalledTimes(2);
// First attempt already runs on the pool-selected account b (pool-first),
// with the ambient token/key stripped from the subprocess env.
expect(seenEnv[0]?.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok-b");
expect(seenEnv[0]?.ANTHROPIC_API_KEY).toBeUndefined();
// b limits → rotate to c.
expect(seenEnv[1]?.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok-c");
expect(seenEnv[1]?.ANTHROPIC_API_KEY).toBeUndefined();
expect(seenEnv[1]?.PATH).toBe(process.env.PATH);
expect(bridge.select).toHaveBeenCalledTimes(2);
// The limited account b was marked + excluded from the rotation select.
expect(bridge.markRateLimited).toHaveBeenCalledWith(
"anthropic-subscription",
"b",
expect.any(Number),
expect.any(String)
);
expect(bridge.select.mock.calls[1][1].exclude).toContain("b");
// Selected tokens are scoped to the subprocess env only.
expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("ambient-token");
expect(process.env.ANTHROPIC_API_KEY).toBe("ambient-key");
// Torn down once for the initial selection, once for the rotation.
expect(c.onRotate).toHaveBeenCalledTimes(2);
// Usage recorded against the account we rotated INTO on success.
expect(bridge.recordUsage).toHaveBeenCalledWith("anthropic-subscription", "c", { ok: true });
} finally {
if (savedToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
else process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken;
if (savedKey === undefined) delete process.env.ANTHROPIC_API_KEY;
else process.env.ANTHROPIC_API_KEY = savedKey;
}
});
it("reuses a selected subprocess env on later turns without reselecting or mutating process.env", async () => {
const savedToken = process.env.CLAUDE_CODE_OAUTH_TOKEN;
process.env.CLAUDE_CODE_OAUTH_TOKEN = "ambient-token";
const bridge = installFakeBridge([account("b")]);
try {
await expect(
withAccountRotation(
async (env?: Record<string, string | undefined>) => {
expect(env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok-b");
return "first-turn";
},
ctx({ sessionKey: "stable-session" }) as never
)
).resolves.toBe("first-turn");
const secondAttempt = vi.fn(async (env?: Record<string, string | undefined>) => {
expect(env?.CLAUDE_CODE_OAUTH_TOKEN).toBe("tok-b");
return "still-on-selected-account";
});
await expect(
withAccountRotation(secondAttempt, ctx({ sessionKey: "stable-session" }) as never)
).resolves.toBe("still-on-selected-account");
expect(secondAttempt).toHaveBeenCalledTimes(1);
expect(bridge.select).toHaveBeenCalledTimes(1);
expect(process.env.CLAUDE_CODE_OAUTH_TOKEN).toBe("ambient-token");
} finally {
if (savedToken === undefined) delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
else process.env.CLAUDE_CODE_OAUTH_TOKEN = savedToken;
}
});
it("rotates on OpenAI's classic quota envelope (the pre-fix silent tier-failover)", async () => {
const bridge = installFakeBridge([account("b"), account("c")]);
let calls = 0;
const attempt = vi.fn(async () => {
calls += 1;
if (calls === 1) {
throw new Error(
"You exceeded your current quota, please check your plan and billing details."
);
}
return "answer-on-account-c";
});
const c = ctx({ backend: "codex-sdk" });
await expect(withAccountRotation(attempt, c as never)).resolves.toBe("answer-on-account-c");
expect(attempt).toHaveBeenCalledTimes(2);
expect(bridge.select).toHaveBeenCalledTimes(2);
expect(c.onRotate).toHaveBeenCalledTimes(2);
});
it("does NOT rotate on a non-limit error — rethrows immediately to failover", async () => {
const bridge = installFakeBridge([account("b")]);
const attempt = vi.fn(async () => {
throw new Error("[cli-inference:sdk] empty completion (subtype=success)");
});
const c = ctx();
await expect(withAccountRotation(attempt, c as never)).rejects.toThrow("empty completion");
expect(attempt).toHaveBeenCalledTimes(1);
// Only the pool-first initial selection ran — no rotation select.
expect(bridge.select).toHaveBeenCalledTimes(1);
expect(bridge.markRateLimited).not.toHaveBeenCalled();
});
it("excludes already-tried accounts and rotates through several before succeeding", async () => {
const bridge = installFakeBridge([account("b"), account("c"), account("d")]);
let calls = 0;
const attempt = vi.fn(async () => {
calls += 1;
if (calls <= 2) throw new Error("429 too many requests");
return "answer-on-account-d";
});
await expect(withAccountRotation(attempt, ctx() as never)).resolves.toBe("answer-on-account-d");
expect(attempt).toHaveBeenCalledTimes(3);
expect(bridge.select).toHaveBeenCalledTimes(3);
// Each rotation select excludes every account already tried.
expect(bridge.select.mock.calls[1][1].exclude).toEqual(["b"]);
expect(bridge.select.mock.calls[2][1].exclude).toEqual(["b", "c"]);
});
it("falls through to provider failover (rethrows) when the pool is exhausted", async () => {
const bridge = installFakeBridge([account("b"), null]);
const attempt = vi.fn(async () => {
throw new Error("subscription rate limit reached: session limit");
});
// Pool-first start on b; b limits → select returns null → rethrow.
await expect(withAccountRotation(attempt, ctx() as never)).rejects.toThrow(
"subscription rate limit reached"
);
expect(bridge.select).toHaveBeenCalledTimes(2);
// The limited account b was marked rate-limited before the exhausted select.
expect(bridge.markRateLimited).toHaveBeenCalledWith(
"anthropic-subscription",
"b",
expect.any(Number),
expect.any(String)
);
});
it("single-account no-op: no bridge installed → single un-wrapped attempt, throw to failover", async () => {
uninstallBridge();
const attempt = vi.fn(async () => {
throw new Error("subscription rate limit reached: session limit");
});
const c = ctx();
await expect(withAccountRotation(attempt, c as never)).rejects.toThrow(
"subscription rate limit reached"
);
expect(attempt).toHaveBeenCalledTimes(1);
expect(c.onRotate).not.toHaveBeenCalled();
});
it("does not rotate when disabled via the opt-out flag", async () => {
const bridge = installFakeBridge([account("b")]);
const attempt = vi.fn(async () => {
throw new Error("subscription rate limit reached: session limit");
});
const c = ctx({ getValue: () => "0" });
await expect(withAccountRotation(attempt, c as never)).rejects.toThrow(
"subscription rate limit reached"
);
expect(attempt).toHaveBeenCalledTimes(1);
expect(bridge.select).not.toHaveBeenCalled();
});
it("non-rotatable backend (cold CLI) is a pass-through no-op", async () => {
const bridge = installFakeBridge([account("b")]);
const attempt = vi.fn(async () => {
throw new Error("subscription rate limit reached: session limit");
});
const c = ctx({ backend: "claude" });
await expect(withAccountRotation(attempt, c as never)).rejects.toThrow(
"subscription rate limit reached"
);
expect(attempt).toHaveBeenCalledTimes(1);
expect(bridge.select).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,328 @@
import { describe, expect, it } from "vitest";
import { ClaudeSdkSession, type SdkModule } from "../src/claude-sdk-session";
import { ProviderApiError } from "../src/provider-errors";
/**
* Unit tests for the warm Agent SDK session, driven by a FAKE SdkModule via the
* constructor's injectable `sdkModule` / `zodModule` seam (no real SDK, no real
* `claude` process). Each "turn script" describes what the fake SDK does for one
* turn: optionally invoke the in-process route tool handler (to set a decision),
* optionally stream assistant text, then emit a terminal `result` with a subtype.
*/
interface TurnScript {
/** Never yield a message, used to test the per-turn timeout budget. */
hang?: boolean;
/** ROUTE mode: invoke the captured tool handler with this decision. */
toolCall?: { action: unknown; params?: unknown };
/** Stream this as assistant text before the result. */
text?: string;
/** Terminal result subtype ("success" | "error_max_turns" | ...). */
subtype?: string;
/** The `result` echo string the SDK carries on the terminal message. */
resultText?: string;
/** Omit the terminal `result` message entirely (simulate a mid-turn death). */
noResult?: boolean;
}
type ToolHandler = (args: {
action?: unknown;
params?: unknown;
}) => Promise<{ content: Array<{ type: string; text: string }> }>;
/** Build a fake SdkModule that replays `scripts` turn-by-turn over one warm query. */
function makeFakeSdk(scripts: TurnScript[]): {
sdk: SdkModule;
starts: () => number;
queryOptions: () => Array<Record<string, unknown>>;
} {
let startCount = 0;
const startedOptions: Array<Record<string, unknown>> = [];
// Script progression is GLOBAL across query restarts: a self-heal/restart
// creates a fresh query() but should continue consuming the next scripted
// turn (mirroring a real warm session that gets fresh turns after a restart).
let turn = 0;
const sdk: SdkModule = {
tool: (_name, _desc, _schema, handler) => ({ handler }) as unknown,
createSdkMcpServer: (opts) => ({ tools: opts.tools }) as unknown,
query: ({ options }) => {
startCount += 1;
startedOptions.push(options);
// Reach the route tool handler the session registered (ROUTE mode only).
const servers = options.mcpServers as
| { eliza?: { tools?: Array<{ handler: ToolHandler }> } }
| undefined;
const handler = servers?.eliza?.tools?.[0]?.handler;
async function* gen() {
while (turn < scripts.length) {
const s = scripts[turn++];
if (s.hang) {
await new Promise(() => undefined);
}
if (s.toolCall && handler) {
await handler({ action: s.toolCall.action, params: s.toolCall.params });
}
if (s.text !== undefined) {
yield {
type: "assistant",
message: { content: [{ type: "text", text: s.text }] },
};
}
if (!s.noResult) {
yield {
type: "result",
subtype: s.subtype ?? "success",
result: s.resultText,
};
}
// One script entry == one turn; pause until the next sendAndRead pull.
}
}
const iter = gen();
return {
[Symbol.asyncIterator]: () => iter,
interrupt: async () => {},
} as unknown as ReturnType<SdkModule["query"]>;
},
};
return { sdk, starts: () => startCount, queryOptions: () => startedOptions };
}
const fakeZod = {
z: { string: () => ({}), any: () => ({}), record: () => ({}) },
};
function makeSession(
scripts: TurnScript[],
opts: {
router?: boolean;
restartAfterTurns?: number;
turnTimeoutMs?: number;
subprocessEnv?: Record<string, string | undefined>;
} = {}
) {
const { sdk, starts, queryOptions } = makeFakeSdk(scripts);
const session = new ClaudeSdkSession({
model: "test-model",
systemPrompt: "test system",
router: opts.router ?? false,
restartAfterTurns: opts.restartAfterTurns,
turnTimeoutMs: opts.turnTimeoutMs,
subprocessEnv: opts.subprocessEnv,
sdkModule: sdk,
zodModule: fakeZod,
});
return { session, starts, queryOptions };
}
describe("ClaudeSdkSession — TEXT mode", () => {
it("defaults to the current Opus model when no model is configured", async () => {
const { sdk, queryOptions } = makeFakeSdk([{ text: "hello world", subtype: "success" }]);
const session = new ClaudeSdkSession({
systemPrompt: "test system",
sdkModule: sdk,
zodModule: fakeZod,
});
expect(await session.generate("hi")).toBe("hello world");
expect(queryOptions()[0].model).toBe("claude-opus-4-8");
await session.dispose();
});
it("returns streamed assistant text", async () => {
const { session } = makeSession([{ text: "hello world", subtype: "success" }]);
expect(await session.generate("hi")).toBe("hello world");
await session.dispose();
});
it("passes rotated account env to the Claude SDK query options only", async () => {
const subprocessEnv = { PATH: "/bin", CLAUDE_CODE_OAUTH_TOKEN: "selected-token" };
const { session, queryOptions } = makeSession([{ text: "hello world", subtype: "success" }], {
subprocessEnv,
});
expect(await session.generate("hi")).toBe("hello world");
expect(queryOptions()[0].env).toBe(subprocessEnv);
await session.dispose();
});
it("falls back to result.result only on a clean success turn", async () => {
const { session } = makeSession([{ text: "", subtype: "success", resultText: "the answer" }]);
expect(await session.generate("hi")).toBe("the answer");
await session.dispose();
});
it("THROWS (fails over) on error_max_turns with no streamed text — never returns the SDK meta string", async () => {
const { session } = makeSession([
{ text: "", subtype: "error_max_turns", resultText: "Reached maximum turns" },
]);
await expect(session.generate("hi")).rejects.toThrow(/empty completion/);
await session.dispose();
});
it("THROWS when the generator ends before a result (session died mid-turn)", async () => {
const { session } = makeSession([{ text: "partial", noResult: true }]);
await expect(session.generate("hi")).rejects.toThrow(/session-ended|empty completion/);
await session.dispose();
});
it("THROWS when the subscription-limit envelope is only in result.result", async () => {
const { session } = makeSession([
{
text: "",
subtype: "success",
resultText: "You've hit your session limit · resets 9:30pm (UTC)",
},
]);
await expect(session.generate("hi")).rejects.toThrow(/subscription rate limit reached/);
await session.dispose();
});
it("throws a typed provider error instead of returning streamed API Error text", async () => {
const { session } = makeSession([
{
text: "API Error: 529 Overloaded. This is a server-side issue, check https://status.claude.com.",
subtype: "success",
},
]);
await expect(session.generate("hi")).rejects.toMatchObject({
name: "ProviderApiError",
statusCode: 529,
retryable: true,
});
await session.dispose();
});
it("bounds a hung SDK turn below connector timeouts", async () => {
const { session } = makeSession([{ hang: true }], { turnTimeoutMs: 5 });
const started = Date.now();
await expect(session.generate("hi")).rejects.toBeInstanceOf(ProviderApiError);
expect(Date.now() - started).toBeLessThan(1_000);
await session.dispose();
});
it("self-heals: a throwing turn disposes, the next call re-starts the session", async () => {
const { session, starts } = makeSession([
{ text: "", subtype: "error_max_turns" }, // turn 1 throws
{ text: "recovered", subtype: "success" }, // turn 2 ok (fresh start)
]);
await expect(session.generate("a")).rejects.toThrow();
expect(await session.generate("b")).toBe("recovered");
expect(starts()).toBe(2); // re-started after the failure
await session.dispose();
});
it("restarts the warm session after restartAfterTurns to bound context", async () => {
const { session, starts } = makeSession(
[
{ text: "one", subtype: "success" },
{ text: "two", subtype: "success" },
{ text: "three", subtype: "success" },
],
{ restartAfterTurns: 1 }
);
expect(await session.generate("1")).toBe("one");
expect(await session.generate("2")).toBe("two");
expect(starts()).toBeGreaterThanOrEqual(2); // restarted between turns
await session.dispose();
});
it("rejects an empty prompt body", async () => {
const { session } = makeSession([{ text: "x", subtype: "success" }]);
await expect(session.generate(" ")).rejects.toThrow(/empty prompt body/);
await session.dispose();
});
});
describe("ClaudeSdkSession — ROUTE mode", () => {
it("captures the tool decision and returns it as bare {action,params} JSON", async () => {
const { session } = makeSession(
[{ toolCall: { action: "WEB_FETCH", params: { url: "u" } }, subtype: "error_max_turns" }],
{ router: true }
);
const out = JSON.parse(await session.route("price?"));
expect(out).toEqual({ action: "WEB_FETCH", params: { url: "u" } });
await session.dispose();
});
it("the captured decision wins over any streamed text", async () => {
const { session } = makeSession(
[
{
toolCall: { action: "REPLY", params: { text: "real" } },
text: "I'll route this...", // agentic preamble, must be ignored
subtype: "error_max_turns",
},
],
{ router: true }
);
const out = JSON.parse(await session.route("hi"));
expect(out.action).toBe("REPLY");
expect(out.params.text).toBe("real");
await session.dispose();
});
it("the captured decision wins over residual subscription-limit text", async () => {
const { session } = makeSession(
[
{
toolCall: { action: "WEB_FETCH", params: { url: "https://example.test" } },
text: "You've hit your session limit · resets 9:30pm (UTC)",
subtype: "error_max_turns",
},
],
{ router: true }
);
const out = JSON.parse(await session.route("price?"));
expect(out).toEqual({
action: "WEB_FETCH",
params: { url: "https://example.test" },
});
await session.dispose();
});
it("does NOT surface an agentic preamble as a REPLY when the model skips the tool (error_max_turns)", async () => {
const { session } = makeSession(
[{ text: "I'll route this to WEB_FETCH...", subtype: "error_max_turns" }],
{ router: true }
);
// No decision + non-success subtype => throw, never leak the thought.
await expect(session.route("hi")).rejects.toThrow(/no decision/);
await session.dispose();
});
it("accepts a genuine terminal answer (clean success, no tool) as a REPLY", async () => {
const { session } = makeSession([{ text: "2 + 2 is 4.", subtype: "success" }], {
router: true,
});
const out = JSON.parse(await session.route("2+2?"));
expect(out).toEqual({ action: "REPLY", params: { text: "2 + 2 is 4." } });
await session.dispose();
});
it("coerces malformed params to {} but keeps the action", async () => {
const { session } = makeSession(
[{ toolCall: { action: "IGNORE", params: "not-an-object" }, subtype: "error_max_turns" }],
{ router: true }
);
const out = JSON.parse(await session.route("hi"));
expect(out).toEqual({ action: "IGNORE", params: {} });
await session.dispose();
});
});
describe("ClaudeSdkSession — serialization", () => {
it("serializes concurrent calls so decisions/text never interleave", async () => {
// Two route turns scripted; fire both without awaiting between them.
const { session } = makeSession(
[
{ toolCall: { action: "A", params: {} }, subtype: "error_max_turns" },
{ toolCall: { action: "B", params: {} }, subtype: "error_max_turns" },
],
{ router: true }
);
const [r1, r2] = await Promise.all([session.route("one"), session.route("two")]);
const actions = [JSON.parse(r1).action, JSON.parse(r2).action].sort();
expect(actions).toEqual(["A", "B"]); // both distinct, no cross-contamination
await session.dispose();
});
});
@@ -0,0 +1,614 @@
/**
* Unit and opportunistic real-binary tests for the CLI inference route: the
* `ELIZA_CHAT_VIA_CLI` auto-enable gate, backend resolution, the large-tier
* model map, and the claude/codex spawn plus JSONL-parse and prompt-flatten
* paths. The child-process spawn seam is mocked so no real model runs; the few
* real-binary cases are skipped unless `claude`/`codex` resolve through the SOC2
* allowlist on this box.
*/
import type { ChatMessage, PluginAutoEnableContext } from "@elizaos/core";
import { afterEach, describe, expect, it, vi } from "vitest";
import { shouldEnable } from "../auto-enable";
import {
buildModels,
ClaudeCli,
ClaudeSdkSession,
CodexSdkSession,
cliInferencePlugin,
LARGE_TIER_MODEL_TYPES,
resolveCliBackend,
} from "../index";
import {
__setSpawnForTests as __setClaudeSpawn,
defaultSpawn,
type SpawnOptions,
type SpawnResult,
} from "../src/claude-cli";
import {
__setSpawnForTests as __setCodexSpawn,
CodexCli,
parseCodexJsonl,
} from "../src/codex-cli-exec";
import { flattenPrompt } from "../src/prompt-flatten";
import { resolveSafeBinary } from "../src/sandbox";
/**
* True iff `bin` resolves THROUGH THE SOC2 ALLOWLIST on this box (gates the
* real-binary tests). Must probe via `resolveSafeBinary` — not the full `$PATH`
* — because a `claude`/`codex` install on `$PATH` but outside the allowlist (the
* common CI case) would otherwise leave the test un-skipped and then throw.
*/
function binaryOnPath(bin: string): boolean {
try {
resolveSafeBinary(bin);
return true;
} catch {
return false;
}
}
function autoEnableCtx(env: Record<string, string | undefined>): PluginAutoEnableContext {
return { env } as unknown as PluginAutoEnableContext;
}
// Pin fake binary paths so the SOC2 `resolveSafeBinary` allowlist check never
// touches the real filesystem (the real claude/codex symlinks resolve outside
// the whitelist on dev boxes). The spawn itself is fully mocked.
const FAKE_CLAUDE = "/usr/local/bin/claude";
const FAKE_CODEX = "/usr/local/bin/codex";
interface Captured {
argv: string[];
opts: SpawnOptions;
}
/** A mock spawner that records the call and returns a canned result. */
function recordingSpawn(result: Partial<SpawnResult>) {
const calls: Captured[] = [];
const fn = async (argv: string[], opts: SpawnOptions): Promise<SpawnResult> => {
calls.push({ argv, opts });
return {
code: result.code ?? 0,
signal: result.signal ?? null,
stdout: result.stdout ?? "ok",
stderr: result.stderr ?? "",
timedOut: result.timedOut ?? false,
};
};
return { calls, fn };
}
afterEach(() => {
delete process.env.ELIZA_CHAT_VIA_CLI;
delete process.env.ELIZA_PLANNER_NATIVE_TOOLS;
delete process.env.ELIZA_ENABLE_CLAUDE_STEALTH;
vi.restoreAllMocks();
});
describe("flattenPrompt", () => {
it("routes system/developer messages to the system slot and others to the body, dropping nothing", () => {
const messages: ChatMessage[] = [
{ role: "system", content: "be terse" },
{ role: "user", content: "what is 2+2?" },
{ role: "assistant", content: "4" },
{ role: "developer", content: "use the grammar" },
{ role: "user", content: "and 3+3?" },
];
const { system, body } = flattenPrompt({ system: "ROOT", messages });
expect(system).toContain("ROOT");
expect(system).toContain("be terse");
expect(system).toContain("use the grammar");
expect(body).toContain("what is 2+2?");
expect(body).toContain("4");
expect(body).toContain("and 3+3?");
// none of the user/assistant content was dropped
expect(body).toMatch(/User:/);
expect(body).toMatch(/Assistant:/);
});
it("appends a legacy prompt that is not already the message tail", () => {
const { body } = flattenPrompt({
messages: [{ role: "user", content: "first" }],
prompt: "second",
});
expect(body).toContain("first");
expect(body).toContain("second");
});
});
describe("claude CLI variant", () => {
it("assembles argv: -p<body>, --system-prompt<verbatim>, --output-format text, --model; stdin /dev/null; isolated cwd", async () => {
const { calls, fn } = recordingSpawn({ stdout: "hello world\n" });
const restore = __setClaudeSpawn(fn);
try {
const cli = new ClaudeCli({
model: "claude-opus-4-8",
env: { PATH: process.env.PATH },
binaryPath: FAKE_CLAUDE,
});
const out = await cli.generate({
system: "SYSTEM PROMPT VERBATIM",
messages: [{ role: "user", content: "hi there" }],
});
expect(out).toBe("hello world");
expect(calls).toHaveLength(1);
const { argv, opts } = calls[0];
// -p carries the flattened body (the messages)
const pIdx = argv.indexOf("-p");
expect(pIdx).toBeGreaterThanOrEqual(0);
expect(argv[pIdx + 1]).toContain("hi there");
// --system-prompt carries the system VERBATIM (full replace)
const sysIdx = argv.indexOf("--system-prompt");
expect(sysIdx).toBeGreaterThanOrEqual(0);
expect(argv[sysIdx + 1]).toBe("SYSTEM PROMPT VERBATIM");
// output format + model + dynamic-section suppression
const ofIdx = argv.indexOf("--output-format");
expect(argv[ofIdx + 1]).toBe("text");
expect(argv).toContain("--exclude-dynamic-system-prompt-sections");
const mIdx = argv.indexOf("--model");
expect(argv[mIdx + 1]).toBe("claude-opus-4-8");
// stdin from /dev/null, isolated tmpdir cwd
expect(opts.stdinPath).toBe("/dev/null");
expect(opts.cwd).toContain("eliza-cli-inference-");
} finally {
restore();
}
});
it("never forwards the subscription token to the child env", async () => {
const { calls, fn } = recordingSpawn({ stdout: "ok" });
const restore = __setClaudeSpawn(fn);
try {
const cli = new ClaudeCli({
binaryPath: FAKE_CLAUDE,
env: {
PATH: process.env.PATH,
HOME: "/home/test",
ANTHROPIC_API_KEY: "sk-ant-SHOULD-NOT-LEAK",
CLAUDE_CODE_OAUTH_TOKEN: "oat-SHOULD-NOT-LEAK",
},
});
await cli.generate({ prompt: "hi" });
const { opts } = calls[0];
expect(opts.env.ANTHROPIC_API_KEY).toBeUndefined();
expect(opts.env.CLAUDE_CODE_OAUTH_TOKEN).toBeUndefined();
expect(JSON.stringify(opts.env)).not.toContain("SHOULD-NOT-LEAK");
// allowlisted, non-sensitive keys survive
expect(opts.env.HOME).toBe("/home/test");
} finally {
restore();
}
});
it("threads system+user+assistant messages: system -> --system-prompt, rest -> body, none dropped", async () => {
const { calls, fn } = recordingSpawn({ stdout: "answer" });
const restore = __setClaudeSpawn(fn);
try {
const cli = new ClaudeCli({ env: { PATH: process.env.PATH }, binaryPath: FAKE_CLAUDE });
await cli.generate({
messages: [
{ role: "system", content: "SYS-A" },
{ role: "user", content: "USER-A" },
{ role: "assistant", content: "ASSIST-A" },
{ role: "user", content: "USER-B" },
],
});
const { argv } = calls[0];
const system = argv[argv.indexOf("--system-prompt") + 1];
const body = argv[argv.indexOf("-p") + 1];
expect(system).toContain("SYS-A");
expect(body).toContain("USER-A");
expect(body).toContain("ASSIST-A");
expect(body).toContain("USER-B");
} finally {
restore();
}
});
it("returns trimmed stdout", async () => {
const { fn } = recordingSpawn({ stdout: " ok \n" });
const restore = __setClaudeSpawn(fn);
try {
const cli = new ClaudeCli({ env: { PATH: process.env.PATH }, binaryPath: FAKE_CLAUDE });
expect(await cli.generate({ prompt: "x" })).toBe("ok");
} finally {
restore();
}
});
it("THROWS on non-zero exit, with redacted stderr", async () => {
const { fn } = recordingSpawn({
code: 1,
stdout: "",
stderr: "boom\nANTHROPIC_API_KEY=sk-ant-leak\nmore",
});
const restore = __setClaudeSpawn(fn);
try {
const cli = new ClaudeCli({ env: { PATH: process.env.PATH }, binaryPath: FAKE_CLAUDE });
await expect(cli.generate({ prompt: "x" })).rejects.toThrow(/exited 1/);
await expect(cli.generate({ prompt: "x" })).rejects.not.toThrow(/sk-ant-leak/);
} finally {
restore();
}
});
it("THROWS on timeout (SIGTERM)", async () => {
const { fn } = recordingSpawn({ code: null, signal: "SIGTERM", stdout: "", timedOut: true });
const restore = __setClaudeSpawn(fn);
try {
const cli = new ClaudeCli({
env: { PATH: process.env.PATH },
timeoutMs: 50,
binaryPath: FAKE_CLAUDE,
});
await expect(cli.generate({ prompt: "x" })).rejects.toThrow(/timed out/);
} finally {
restore();
}
});
it("THROWS on empty stdout", async () => {
const { fn } = recordingSpawn({ code: 0, stdout: " \n " });
const restore = __setClaudeSpawn(fn);
try {
const cli = new ClaudeCli({ env: { PATH: process.env.PATH }, binaryPath: FAKE_CLAUDE });
await expect(cli.generate({ prompt: "x" })).rejects.toThrow(/empty stdout/);
} finally {
restore();
}
});
});
describe("codex CLI variant", () => {
it("assembles argv: exec / -m / -s read-only / --skip-git-repo-check / -C / --color never / --json", async () => {
const jsonl = `{"type":"item.completed","item":{"type":"agent_message","text":"codex says hi"}}\n`;
const { calls, fn } = recordingSpawn({ stdout: jsonl });
const restore = __setCodexSpawn(fn);
try {
const cli = new CodexCli({
model: "gpt-5.5",
env: { PATH: process.env.PATH },
binaryPath: FAKE_CODEX,
});
const out = await cli.generate({ system: "SYS", prompt: "do a thing" });
expect(out).toBe("codex says hi");
const { argv, opts } = calls[0];
expect(argv).toContain("exec");
expect(argv[argv.indexOf("-m") + 1]).toBe("gpt-5.5");
expect(argv[argv.indexOf("-s") + 1]).toBe("read-only");
expect(argv).toContain("--skip-git-repo-check");
expect(argv).toContain("-C");
expect(argv[argv.indexOf("--color") + 1]).toBe("never");
expect(argv).toContain("--json");
// system is folded into the single positional prompt
const prompt = argv[argv.length - 1];
expect(prompt).toContain("SYS");
expect(prompt).toContain("do a thing");
expect(opts.stdinPath).toBe("/dev/null");
} finally {
restore();
}
});
it("THROWS on non-zero exit", async () => {
const { fn } = recordingSpawn({ code: 2, stdout: "", stderr: "nope" });
const restore = __setCodexSpawn(fn);
try {
const cli = new CodexCli({ env: { PATH: process.env.PATH }, binaryPath: FAKE_CODEX });
await expect(cli.generate({ prompt: "x" })).rejects.toThrow(/codex exited 2/);
} finally {
restore();
}
});
});
describe("parseCodexJsonl", () => {
it("concatenates assistant message fragments from JSONL in order", () => {
const jsonl = [
`{"type":"thread.started"}`,
`{"type":"item.completed","item":{"type":"agent_message","text":"first"}}`,
`{"type":"item.completed","item":{"type":"agent_message","text":"final answer"}}`,
].join("\n");
expect(parseCodexJsonl(jsonl)).toBe("first\nfinal answer");
});
it("ignores non-JSON banner lines", () => {
const jsonl = `codex 0.139.0 starting...\n{"type":"agent_message","message":"hi"}\n`;
expect(parseCodexJsonl(jsonl)).toBe("hi");
});
it("concatenates ALL assistant fragments in order (does not truncate to the last)", () => {
const jsonl = [
`{"type":"item.completed","item":{"type":"agent_message","text":"<response>part one"}}`,
`{"type":"item.completed","item":{"type":"agent_message","text":"part two</response>"}}`,
].join("\n");
expect(parseCodexJsonl(jsonl)).toBe("<response>part one\npart two</response>");
});
it("falls back to raw trimmed stdout ONLY when no line parsed as JSON", () => {
expect(parseCodexJsonl(" plain text answer ")).toBe("plain text answer");
});
it("THROWS (does not dump raw JSONL) when JSON parsed but no assistant event", () => {
const jsonl = [`{"type":"thread.started"}`, `{"type":"token_count","count":42}`].join("\n");
expect(() => parseCodexJsonl(jsonl)).toThrow(/no assistant message/);
});
});
describe("codex default spawner (fix 1: must default, not throw)", () => {
it("CodexCli uses the real defaultSpawn when no test seam is installed", async () => {
// No __setSpawnForTests call here: a production CodexCli must spawn via the
// module-default spawner, not throw "codex spawner not configured". We point
// it at `true` (exits 0, no stdout) so the spawn runs but yields empty
// stdout -> the "empty stdout" guard, NOT the "spawner not configured" error.
const cli = new CodexCli({ env: { PATH: process.env.PATH }, binaryPath: "/usr/bin/true" });
await expect(cli.generate({ prompt: "x" })).rejects.toThrow(/empty stdout/);
});
});
describe("resolveSafeBinary accepts real symlinked installs (fix 2)", () => {
const hasClaude = binaryOnPath("claude");
const hasCodex = binaryOnPath("codex");
it.skipIf(!hasClaude)("resolves a real `claude` install symlinked out of the allowlist", () => {
const resolved = resolveSafeBinary("claude");
expect(resolved.length).toBeGreaterThan(0);
});
it.skipIf(!hasCodex)("resolves a real `codex` install symlinked out of the allowlist", () => {
const resolved = resolveSafeBinary("codex");
expect(resolved.length).toBeGreaterThan(0);
});
it("still rejects an absolute path outside every allowlisted dir", () => {
// /etc exists but is not on the launcher allowlist, so an absolute path
// there must be refused even though the file exists.
expect(() => resolveSafeBinary("/etc/hostname")).toThrow(/Could not resolve/);
});
});
describe("defaultSpawn timeout escalation (fix 6: SIGKILL if SIGTERM ignored)", () => {
it("escalates to SIGKILL and rejects when the child ignores SIGTERM", async () => {
// A node child that traps SIGTERM and keeps running. Only SIGKILL (which
// cannot be trapped) can stop it. With the group-kill + 2s SIGKILL
// escalation, defaultSpawn must resolve as timedOut well before this 30s
// sleep would finish.
const script =
"process.on('SIGTERM',()=>{});setTimeout(()=>process.exit(0),30000);process.stderr.write('ready');";
const result = await defaultSpawn([process.execPath, "-e", script], {
cwd: process.cwd(),
env: { PATH: process.env.PATH ?? "" } as Record<string, string>,
timeoutMs: 300,
stdinPath: "/dev/null",
});
expect(result.timedOut).toBe(true);
// The child was force-killed: it never ran to its own exit(0), so it closed
// on a signal rather than code 0.
expect(result.code).not.toBe(0);
}, 10_000);
});
describe("plugin routing priority (fix 4)", () => {
it("registers an explicit high priority so it wins the tiers it serves", () => {
expect(cliInferencePlugin.priority).toBe(100);
});
});
describe("models map gating (large-tier only)", () => {
it("ELIZA_CHAT_VIA_CLI unset -> empty models map", () => {
expect(resolveCliBackend({})).toBeUndefined();
expect(buildModels({})).toEqual({});
});
it("ELIZA_CHAT_VIA_CLI=claude -> exactly the 3 large-tier handlers, NOT ACTION_PLANNER/TEXT_SMALL/NANO/MEDIUM", () => {
const models = buildModels({ ELIZA_CHAT_VIA_CLI: "claude" }) as Record<string, unknown>;
const keys = Object.keys(models).sort();
expect(keys).toEqual([...LARGE_TIER_MODEL_TYPES].sort());
expect(keys).toContain("TEXT_LARGE");
expect(keys).toContain("TEXT_MEGA");
expect(keys).toContain("RESPONSE_HANDLER");
// ACTION_PLANNER stays on the grammar/tool-honoring provider — the CLI
// cannot honor GBNF/native-tool/responseSchema enforcement.
expect(keys).not.toContain("ACTION_PLANNER");
expect(keys).not.toContain("TEXT_SMALL");
expect(keys).not.toContain("TEXT_NANO");
expect(keys).not.toContain("TEXT_MEDIUM");
});
it("ELIZA_CHAT_VIA_CLI=codex -> same large-tier-only set", () => {
const keys = Object.keys(
buildModels({ ELIZA_CHAT_VIA_CLI: "codex" }) as Record<string, unknown>
);
expect(keys.sort()).toEqual([...LARGE_TIER_MODEL_TYPES].sort());
});
it("ELIZA_CHAT_VIA_CLI=claude-sdk -> same large-tier-only set", () => {
const keys = Object.keys(
buildModels({ ELIZA_CHAT_VIA_CLI: "claude-sdk" }) as Record<string, unknown>
);
expect(keys.sort()).toEqual([...LARGE_TIER_MODEL_TYPES].sort());
});
it("routes cold Claude model handlers through the configured backend", async () => {
const { calls, fn } = recordingSpawn({ stdout: "from claude" });
const restore = __setClaudeSpawn(fn);
try {
const models = buildModels({ ELIZA_CHAT_VIA_CLI: "claude" }) as Record<
string,
(
runtime: { getSetting: (key: string) => string | undefined },
params: unknown
) => Promise<string>
>;
// A configured pin keeps model routing independent of the host's CLI layout.
const runtime = {
getSetting: (key: string) =>
key === "ELIZA_CHAT_VIA_CLI"
? "claude"
: key === "ELIZA_CLI_CLAUDE_BIN"
? FAKE_CLAUDE
: undefined,
};
await expect(models.TEXT_LARGE(runtime, { prompt: "hello" })).resolves.toBe("from claude");
const argv = calls[0].argv;
expect(argv[0]).toBe(FAKE_CLAUDE);
expect(argv[argv.indexOf("--model") + 1]).toBe("claude-opus-4-8");
} finally {
restore();
}
});
it("routes warm Claude SDK handlers through the current default Opus model", async () => {
let captured: { model?: string; body?: string } = {};
vi.spyOn(ClaudeSdkSession.prototype, "generate").mockImplementation(function (
this: ClaudeSdkSession,
body: string
) {
captured = {
model: (this as unknown as { model?: string }).model,
body,
};
return Promise.resolve("from sdk");
});
const models = buildModels({ ELIZA_CHAT_VIA_CLI: "claude-sdk" }) as Record<
string,
(
runtime: { getSetting: (key: string) => string | undefined },
params: unknown
) => Promise<string>
>;
const runtime = {
getSetting: (key: string) =>
key === "ELIZA_CHAT_VIA_CLI"
? "claude-sdk"
: key === "ELIZA_CLI_CLAUDE_MODEL"
? ""
: undefined,
};
await expect(models.TEXT_LARGE(runtime, { prompt: "hello" })).resolves.toBe("from sdk");
expect(captured.model).toBe("claude-opus-4-8");
expect(captured.body).toContain("hello");
});
it("registers and routes ACTION_PLANNER only in text-planner mode", async () => {
process.env.ELIZA_PLANNER_NATIVE_TOOLS = "0";
const { calls, fn } = recordingSpawn({ stdout: '{"action":"NONE","params":{}}' });
const restore = __setClaudeSpawn(fn);
try {
const models = buildModels({ ELIZA_CHAT_VIA_CLI: "claude" }) as Record<
string,
(
runtime: { getSetting: (key: string) => string | undefined },
params: unknown
) => Promise<string>
>;
// The planner and response handlers share the same operator-pinned executable.
const runtime = {
getSetting: (key: string) =>
key === "ELIZA_CHAT_VIA_CLI"
? "claude"
: key === "ELIZA_CLI_CLAUDE_BIN"
? FAKE_CLAUDE
: undefined,
};
expect(models.ACTION_PLANNER).toBeTypeOf("function");
await expect(models.ACTION_PLANNER(runtime, { prompt: "pick an action" })).resolves.toContain(
"NONE"
);
expect(calls).toHaveLength(1);
} finally {
restore();
}
});
it("routes cold Codex model handlers through the configured binary", async () => {
const { calls, fn } = recordingSpawn({
stdout: '{"type":"agent_message","message":"from codex"}',
});
const restore = __setCodexSpawn(fn);
try {
const models = buildModels({ ELIZA_CHAT_VIA_CLI: "codex" }) as Record<
string,
(
runtime: { getSetting: (key: string) => string | undefined },
params: unknown
) => Promise<string>
>;
const runtime = {
getSetting: (key: string) =>
key === "ELIZA_CHAT_VIA_CLI"
? "codex"
: key === "ELIZA_CLI_CODEX_BIN"
? FAKE_CODEX
: undefined,
};
await expect(models.TEXT_LARGE(runtime, { prompt: "hello" })).resolves.toBe("from codex");
expect(calls).toHaveLength(1);
expect(calls[0].argv[0]).toBe(FAKE_CODEX);
} finally {
restore();
}
});
it("routes warm Codex SDK handlers through the default Codex model", async () => {
let captured: { model?: string; body?: string } = {};
vi.spyOn(CodexSdkSession.prototype, "generate").mockImplementation(function (
this: CodexSdkSession,
body: string
) {
captured = {
model: (this as unknown as { model?: string }).model,
body,
};
return Promise.resolve("from codex sdk");
});
const models = buildModels({ ELIZA_CHAT_VIA_CLI: "codex-sdk" }) as Record<
string,
(
runtime: { getSetting: (key: string) => string | undefined },
params: unknown
) => Promise<string>
>;
const runtime = {
getSetting: (key: string) => (key === "ELIZA_CHAT_VIA_CLI" ? "codex-sdk" : undefined),
};
await expect(models.TEXT_LARGE(runtime, { prompt: "hello" })).resolves.toBe("from codex sdk");
expect(captured.model).toBe("gpt-5.5");
expect(captured.body).toContain("hello");
});
it("keeps init inert when disabled and rejects colliding Claude routes", async () => {
delete process.env.ELIZA_CHAT_VIA_CLI;
await expect(cliInferencePlugin.init?.({} as never)).resolves.toBeUndefined();
process.env.ELIZA_CHAT_VIA_CLI = "claude-sdk";
process.env.ELIZA_ENABLE_CLAUDE_STEALTH = "1";
await expect(cliInferencePlugin.init?.({} as never)).rejects.toThrow(/collides/);
});
it("resolveCliBackend accepts claude|codex|claude-sdk (case-insensitive)", () => {
expect(resolveCliBackend({ ELIZA_CHAT_VIA_CLI: "Claude" })).toBe("claude");
expect(resolveCliBackend({ ELIZA_CHAT_VIA_CLI: "CODEX" })).toBe("codex");
expect(resolveCliBackend({ ELIZA_CHAT_VIA_CLI: "Claude-SDK" })).toBe("claude-sdk");
expect(resolveCliBackend({ ELIZA_CHAT_VIA_CLI: "gemini" })).toBeUndefined();
expect(resolveCliBackend({ ELIZA_CHAT_VIA_CLI: "" })).toBeUndefined();
});
it("auto-enables for claude-sdk with the same trim/case normalization", () => {
expect(shouldEnable(autoEnableCtx({ ELIZA_CHAT_VIA_CLI: " Claude-SDK " }))).toBe(true);
expect(shouldEnable(autoEnableCtx({ ELIZA_CHAT_VIA_CLI: "gemini" }))).toBe(false);
});
});
@@ -0,0 +1,189 @@
import { describe, expect, it } from "vitest";
import { type CodexModule, CodexSdkSession } from "../src/codex-sdk-session";
/**
* Unit tests for the warm Codex SDK session, driven by a FAKE CodexModule via the
* constructor's injectable `codexModule` seam (no real `@openai/codex-sdk`, no
* real `codex` process). Each "turn script" describes the completed turn the fake
* thread returns: its `finalResponse` (or item text), or an error to throw.
*/
interface TurnScript {
finalResponse?: string;
itemText?: string;
throws?: string;
}
function makeFakeCodex(scripts: TurnScript[]): {
codexModule: CodexModule;
starts: () => number;
codexOptions: () => Array<Record<string, unknown>>;
} {
let startCount = 0;
let turn = 0;
const constructedOptions: Array<Record<string, unknown>> = [];
const codexModule = {
Codex: class {
constructor(options?: Record<string, unknown>) {
constructedOptions.push(options ?? {});
}
startThread() {
startCount += 1;
return {
run: async (_input: string, _turnOptions?: { outputSchema?: unknown }) => {
const s = scripts[turn++] ?? {};
if (s.throws) throw new Error(s.throws);
const items = s.itemText ? [{ type: "agent_message", text: s.itemText }] : [];
return { items, finalResponse: s.finalResponse, usage: null };
},
};
}
},
} as unknown as CodexModule;
return { codexModule, starts: () => startCount, codexOptions: () => constructedOptions };
}
function makeSession(
scripts: TurnScript[],
opts: {
router?: boolean;
restartAfterTurns?: number;
subprocessEnv?: Record<string, string | undefined>;
} = {}
) {
const fake = makeFakeCodex(scripts);
const session = new CodexSdkSession({
model: "gpt-test",
router: opts.router ?? false,
restartAfterTurns: opts.restartAfterTurns,
subprocessEnv: opts.subprocessEnv,
codexModule: fake.codexModule,
});
return { session, ...fake };
}
describe("CodexSdkSession — TEXT mode", () => {
it("returns the turn finalResponse", async () => {
const { session } = makeSession([{ finalResponse: "hello" }]);
expect(await session.generate("hi")).toBe("hello");
session.dispose();
});
it("passes rotated account env to the Codex SDK constructor only", async () => {
const subprocessEnv = { PATH: "/bin", CODEX_HOME: "/selected/codex" };
const { session, codexOptions } = makeSession([{ finalResponse: "hello" }], {
subprocessEnv,
});
expect(await session.generate("hi")).toBe("hello");
expect(codexOptions()[0].env).toBe(subprocessEnv);
session.dispose();
});
it("falls back to the last agent_message item text", async () => {
const { session } = makeSession([{ itemText: "from item" }]);
expect(await session.generate("hi")).toBe("from item");
session.dispose();
});
it("throws on an empty completion (fail over)", async () => {
const { session } = makeSession([{ finalResponse: "" }]);
await expect(session.generate("hi")).rejects.toThrow(/empty completion/);
session.dispose();
});
it("rejects an empty prompt body", async () => {
const { session } = makeSession([{ finalResponse: "x" }]);
await expect(session.generate(" ")).rejects.toThrow(/empty prompt body/);
session.dispose();
});
it("self-heals: a throwing turn disposes, the next call re-starts the thread", async () => {
const { session, starts } = makeSession([{ throws: "boom" }, { finalResponse: "recovered" }]);
await expect(session.generate("a")).rejects.toThrow(/boom/);
expect(await session.generate("b")).toBe("recovered");
expect(starts()).toBe(2);
session.dispose();
});
it("restarts after restartAfterTurns to bound context", async () => {
const { session, starts } = makeSession([{ finalResponse: "one" }, { finalResponse: "two" }], {
restartAfterTurns: 1,
});
expect(await session.generate("1")).toBe("one");
expect(await session.generate("2")).toBe("two");
expect(starts()).toBeGreaterThanOrEqual(2);
session.dispose();
});
});
describe("CodexSdkSession — ROUTE mode (native outputSchema)", () => {
it("parses a bare {action,params} JSON from the turn's finalResponse", async () => {
const { session } = makeSession(
[{ finalResponse: '{"action":"WEB_FETCH","params":"{\\"url\\":\\"u\\"}"}' }],
{ router: true }
);
const out = JSON.parse(await session.route("price?"));
expect(out).toEqual({ action: "WEB_FETCH", params: { url: "u" } });
session.dispose();
});
it("salvages a JSON object wrapped in prose", async () => {
const { session } = makeSession(
[
{
finalResponse:
'Sure, here is the action: {"action":"REPLY","params":"{\\"text\\":\\"4\\"}"} — let me know if you need anything else.',
},
],
{ router: true }
);
const out = JSON.parse(await session.route("2+2?"));
expect(out).toEqual({ action: "REPLY", params: { text: "4" } });
session.dispose();
});
it("coerces a non-object params to {} but keeps the action", async () => {
const { session } = makeSession(
[{ finalResponse: '{"action":"IGNORE","params":"not valid json"}' }],
{
router: true,
}
);
const out = JSON.parse(await session.route("hi"));
expect(out).toEqual({ action: "IGNORE", params: {} });
session.dispose();
});
it("throws when the structured output has no action", async () => {
const { session } = makeSession([{ finalResponse: '{"params":{}}' }], {
router: true,
});
await expect(session.route("hi")).rejects.toThrow(/missing action/);
session.dispose();
});
it("throws on non-JSON route output", async () => {
const { session } = makeSession([{ finalResponse: "not json at all" }], {
router: true,
});
await expect(session.route("hi")).rejects.toThrow(/non-JSON output/);
session.dispose();
});
});
describe("CodexSdkSession — serialization", () => {
it("serializes concurrent calls without interleaving", async () => {
const { session } = makeSession(
[
{ finalResponse: '{"action":"A","params":"{}"}' },
{ finalResponse: '{"action":"B","params":"{}"}' },
],
{ router: true }
);
const [r1, r2] = await Promise.all([session.route("one"), session.route("two")]);
const actions = [JSON.parse(r1).action, JSON.parse(r2).action].sort();
expect(actions).toEqual(["A", "B"]);
session.dispose();
});
});
@@ -0,0 +1,78 @@
import { mkdirSync, mkdtempSync, realpathSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { resolveSafeCwd } from "../src/sandbox.ts";
/**
* Tests for the sub-agent cwd sandbox guard (#8801 / #9943). `resolveSafeBinary`
* and `parseCodexJsonl` are already covered, but `resolveSafeCwd` — the guard
* that keeps a spawned coding sub-agent's working directory INSIDE an allowed
* workspace root — had zero assertions. It is a sandbox-escape boundary, so the
* prefix-bypass case (a sibling dir that merely shares a name prefix) matters.
*/
describe("resolveSafeCwd", () => {
let parent: string;
let root: string;
let nested: string;
let sibling: string;
let outside: string;
beforeAll(() => {
// Base the dirs under the repo (process.cwd()), NOT the OS temp dir —
// resolveSafeCwd implicitly allows anything under tmpdir, so the rejection
// cases must live outside it to be meaningful.
parent = realpathSync(mkdtempSync(join(process.cwd(), ".cli-inf-sandbox-")));
root = join(parent, "work");
nested = join(root, "sub", "deeper");
sibling = join(parent, "work-evil"); // shares the "work" prefix but is NOT under root
outside = join(parent, "elsewhere");
mkdirSync(nested, { recursive: true });
mkdirSync(sibling, { recursive: true });
mkdirSync(outside, { recursive: true });
});
afterAll(() => {
rmSync(parent, { recursive: true, force: true });
});
it("accepts a cwd that IS the allowed root", () => {
expect(resolveSafeCwd(root, [root])).toBe(realpathSync(root));
});
it("accepts a cwd nested under the allowed root", () => {
expect(resolveSafeCwd(nested, [root])).toBe(realpathSync(nested));
});
it("rejects a sibling that only shares a name prefix (no path-prefix bypass)", () => {
// "/…/work-evil" must NOT be treated as under "/…/work"
expect(() => resolveSafeCwd(sibling, [root])).toThrow(/not under any allowed/);
});
it("rejects a cwd outside every allowed root", () => {
expect(() => resolveSafeCwd(outside, [root])).toThrow(/not under any allowed/);
});
it("always allows the OS temp dir (the implicit sandbox)", () => {
const inTmp = realpathSync(mkdtempSync(join(tmpdir(), "cli-inf-tmp-")));
try {
expect(resolveSafeCwd(inTmp, [])).toBe(inTmp);
} finally {
rmSync(inTmp, { recursive: true, force: true });
}
});
it("rejects a relative cwd", () => {
expect(() => resolveSafeCwd("relative/dir", [root])).toThrow(/must be absolute/);
});
it("rejects an empty cwd", () => {
expect(() => resolveSafeCwd("", [root])).toThrow(/cwd is required/);
});
it("rejects a non-existent cwd", () => {
expect(() => resolveSafeCwd(join(parent, "does-not-exist"), [root])).toThrow(
/does not exist or is not a directory/
);
});
});
@@ -0,0 +1,48 @@
/**
* Static-source guard: every `SDK_PACKAGE` a session file lazily imports must be
* declared as an `optionalDependency` in package.json (scans the source text; no
* real SDK loaded), so the variable dynamic import can resolve when the backend
* is enabled while the plugin stays inert otherwise.
*/
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const pluginRoot = path.resolve(fileURLToPath(new URL("..", import.meta.url)));
async function readJson(filePath: string): Promise<Record<string, unknown>> {
return JSON.parse(await readFile(filePath, "utf8")) as Record<string, unknown>;
}
async function readSdkPackageConstants(): Promise<string[]> {
const sdkSourceFiles = ["src/claude-sdk-session.ts", "src/codex-sdk-session.ts"];
const sdkPackages = new Set<string>();
for (const sourceFile of sdkSourceFiles) {
const source = await readFile(path.join(pluginRoot, sourceFile), "utf8");
for (const match of source.matchAll(/const\s+SDK_PACKAGE\s*=\s*"([^"]+)"/g)) {
sdkPackages.add(match[1]);
}
}
return [...sdkPackages].sort();
}
describe("SDK lazy-import package metadata", () => {
it("declares every lazily imported SDK package as an optional dependency", async () => {
const packageJson = await readJson(path.join(pluginRoot, "package.json"));
const optionalDependencies = packageJson.optionalDependencies as
| Record<string, string>
| undefined;
expect(optionalDependencies).toBeTruthy();
expect(await readSdkPackageConstants()).toEqual([
"@anthropic-ai/claude-agent-sdk",
"@openai/codex-sdk",
]);
for (const sdkPackage of await readSdkPackageConstants()) {
expect(optionalDependencies).toHaveProperty(sdkPackage);
}
});
});
@@ -0,0 +1,85 @@
import { describe, expect, it } from "vitest";
import {
isClaudeSdkApiErrorMessage,
isClaudeSubscriptionLimitMessage,
} from "../src/claude-sdk-session.ts";
// Live regression (2026-07-01): when the Agent SDK monthly credit ran dry the SDK
// ended the turn cleanly but streamed the subscription-limit UI string as the
// assistant "answer", and the bot relayed it to Discord users verbatim
// ("You've hit your session limit · resets 9:30pm (UTC)") 7 times.
describe("isClaudeSubscriptionLimitMessage", () => {
it("matches the real leaked subscription-limit strings", () => {
expect(
isClaudeSubscriptionLimitMessage("You've hit your session limit · resets 9:30pm (UTC)")
).toBe(true);
expect(
isClaudeSubscriptionLimitMessage("You've hit your session limit · resets 11:30am (UTC)")
).toBe(true);
expect(
isClaudeSubscriptionLimitMessage("You've reached your usage limit for this month.")
).toBe(true);
});
it("matches known envelope variants (CLI epoch form, non-UTC interpunct)", () => {
// The classic Claude CLI limit string: "Claude AI usage limit reached|<epoch>".
expect(isClaudeSubscriptionLimitMessage("Claude AI usage limit reached|1735689600")).toBe(true);
// Interpunct separator variants without a "(UTC)" suffix.
expect(isClaudeSubscriptionLimitMessage("5-hour limit reached ∙ resets 3am")).toBe(true);
});
it("does not match genuine model answers, even ones discussing limits", () => {
for (const answer of [
"Bitcoin is at $58,546 USD right now (via CoinGecko).",
"Paris.",
"3 minutes. The machines run in parallel so the time never changes.",
// a real, long answer that happens to explain rate limits must NOT trip it
"The API rate limit is 60 requests per minute and it resets hourly; " +
"handle it with exponential backoff so you never exceed the quota in production.",
// adversarial-review probes: SHORT genuine answers about the user's limits.
"No, you haven't hit your rate limit yet.",
"Your API limit resets at midnight (UTC).",
"It means you hit your session limit for the subscription.",
"Yes — you hit the daily limit on that key; try tomorrow.",
// mid-sentence second-person phrase (envelope form is start-anchored)
"Yes — you've hit your daily limit on that key.",
]) {
expect(isClaudeSubscriptionLimitMessage(answer)).toBe(false);
}
});
});
// Second leak class (2026-07-02): the SDK streams upstream API failures as
// assistant text — "API Error: 400 messages: text content blocks must be
// non-empty" was relayed verbatim to Discord users 18x when empty relay lines
// produced an empty content block.
describe("isClaudeSdkApiErrorMessage", () => {
it("matches the SDK's API-error envelope", () => {
for (const s of [
"API Error: 400 messages: text content blocks must be non-empty",
"API Error: 429 rate limited",
"API Error: 529 overloaded",
" API Error: 500 internal server error",
// Non-numeric envelopes baked into the shipping CLI binary:
"API Error: Request was aborted.",
"API Error: Missing Tool Result Block",
"API Error",
]) {
expect(isClaudeSdkApiErrorMessage(s)).toBe(true);
}
});
it("does not match genuine answers that mention API errors", () => {
for (const s of [
"An API Error: 400 usually means your request body is malformed.",
"You're seeing 'API Error: 400' because the content block was empty.",
"The API returned an error: 400.",
"Error handling matters — retry on API Error status codes like 429.",
"API Error handling is a best practice for resilient clients.",
"API Errors come in many flavors; retry the retryable ones.",
"42",
]) {
expect(isClaudeSdkApiErrorMessage(s)).toBe(false);
}
});
});
@@ -0,0 +1,25 @@
// Auto-enable check for @elizaos/plugin-cli-inference.
//
// Plugin manifest entry-point — referenced by package.json's
// `elizaos.plugin.autoEnableModule`. Keep this module light: config/env reads
// only, no service init, no transitive imports of the full plugin runtime. The
// auto-enable engine loads dozens of these per boot.
import type { PluginAutoEnableContext } from "@elizaos/core";
/**
* Enable ONLY when `ELIZA_CHAT_VIA_CLI` is set to `claude`, `claude-sdk`,
* `codex`, or `codex-sdk`. This is the single env gate for the SAFE/CLOUD
* inference route — unset means the plugin is never added to the resolved set, so
* it cannot affect any existing running code path. Even if it were force-loaded,
* its models map is empty unless the same env var is set, so the cheap configured
* provider keeps serving every tier.
*/
export function shouldEnable(ctx: PluginAutoEnableContext): boolean {
const raw = ctx.env.ELIZA_CHAT_VIA_CLI?.trim().toLowerCase();
return (
raw === "claude" ||
raw === "claude-sdk" ||
raw === "codex" ||
raw === "codex-sdk"
);
}
+46
View File
@@ -0,0 +1,46 @@
{
"root": false,
"$schema": "https://biomejs.dev/schemas/2.5.1/schema.json",
"files": {
"includes": [
"build.ts",
"index.ts",
"index.browser.ts",
"index.node.ts",
"src/**",
"__tests__/**",
"!dist",
"!node_modules"
]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } },
"linter": {
"enabled": true,
"rules": {
"preset": "recommended",
"correctness": {
"noUnusedVariables": "error"
},
"a11y": {
"useButtonType": "off"
},
"style": {},
"complexity": {
"noCommaOperator": "off"
}
}
},
"formatter": {
"enabled": true,
"indentWidth": 2,
"indentStyle": "space",
"lineWidth": 100
},
"javascript": {
"formatter": {
"quoteStyle": "double",
"trailingCommas": "es5",
"semicolons": "always"
}
}
}
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bun
/**
* Build script for @elizaos/plugin-cli-inference (Node + Browser).
* Orchestration lives in the shared driver (plugins/plugin-build.ts); this lists
* only what differs. The emitted `dist/` is byte-identical to the previous
* hand-rolled build.
*/
import { buildPlugin } from "../plugin-build";
// Single-quoted re-export to keep the emitted .d.ts byte-stable.
const reexport = "export * from '../index';\nexport { default } from '../index';\n";
await buildPlugin({
name: "@elizaos/plugin-cli-inference",
targets: [
{
label: "Node",
entry: "index.node.ts",
outSubdir: "node",
target: "node",
format: "esm",
},
{
label: "Browser",
entry: "index.browser.ts",
outSubdir: "browser",
target: "browser",
format: "esm",
minify: true,
},
],
dtsProject: "tsconfig.build.json",
dtsShims: [
{ path: "node/index.d.ts", content: reexport },
{ path: "browser/index.d.ts", content: reexport },
],
});
@@ -0,0 +1,19 @@
import type { Plugin } from "@elizaos/core";
/**
* Browser stub. This plugin is node-only: it uses the `claude` CLI, Claude Agent
* SDK, or `codex` CLI from a local Node process and reads creds from disk. The
* browser bundle registers no models.
*/
export const cliInferencePlugin: Plugin = {
name: "cli-inference",
description:
"CLI inference is node-only (claude CLI, claude-sdk, or codex CLI). Inert in browser.",
config: {},
async init(): Promise<void> {
// Browser bundle intentionally does not import node:child_process.
},
models: {},
};
export default cliInferencePlugin;
@@ -0,0 +1,5 @@
/** Node entry point: re-exports the full plugin (the real CLI/SDK handlers). */
import pluginDefault from "./index";
export * from "./index";
export default pluginDefault;
+537
View File
@@ -0,0 +1,537 @@
import { createHash } from "node:crypto";
import type { GenerateTextParams, IAgentRuntime, Plugin } from "@elizaos/core";
import { logger, ModelType } from "@elizaos/core";
import {
type RotationSubprocessEnv,
rotationEnabled,
withAccountRotation,
} from "./src/account-rotation";
import { ClaudeCli } from "./src/claude-cli";
import { ClaudeSdkSession } from "./src/claude-sdk-session";
import {
appendTextDirective,
buildCleanRoutingParams,
buildRouterBody,
frameTextSystemPrompt,
ROUTER_SYSTEM_PROMPT,
} from "./src/clean-routing-planner";
import { CodexCli } from "./src/codex-cli-exec";
import { CodexSdkSession } from "./src/codex-sdk-session";
import { flattenPrompt } from "./src/prompt-flatten";
/**
* @elizaos/plugin-cli-inference — the TOS-clean SAFE/CLOUD inference route.
*
* Serves chat/planner inference through sanctioned local routes:
* - `claude --print` (reads ~/.claude/.credentials.json itself), or
* - a warm Claude Agent SDK session (reads the Claude subscription creds itself), or
* - `codex exec` (reads ~/.codex/auth.json itself).
*
* eliza never sees/forwards/logs the subscription token — the child env is
* filtered (allowlist + secret blocklist) and the CLI loads its own creds. This
* is the develop-shippable peer to the two never-commit, TOS-gray bypass paths
* (the claude-code-stealth fetch interceptor in
* `packages/agent/src/auth/credentials.ts` and plugin-codex-cli `postResponses`)
* which replay the consumer-subscription token in-process.
*
* The whole models map is INERT unless `ELIZA_CHAT_VIA_CLI` is `claude`,
* `claude-sdk`, or `codex`. We register TEXT_LARGE / TEXT_MEGA /
* RESPONSE_HANDLER only:
*
* - RESPONSE_HANDLER is the whole point — it generates the user-facing reply,
* which is exactly what "chat on the sub" means. That is one CLI spawn per
* turn that actually answers (~3-4s).
* - TEXT_LARGE / TEXT_MEGA cover other large free-text generations (e.g. the
* post-turn evaluator) — also occasional, also tolerant of plain text.
* - ACTION_PLANNER is registered ONLY in text-planner mode
* (`ELIZA_PLANNER_NATIVE_TOOLS=0`), where the planner emits an XML
* `<response><actions>` block the free-text CLI CAN produce. With native
* tools on (the default), the planner needs GBNF / native-tool /
* responseSchema enforcement the CLI cannot honor, so it stays on the
* grammar/tool-honoring provider (cerebras / zai / anthropic). Gating on
* `NATIVE_TOOLS=0` lets the SAFE route run STANDALONE (chat + planner +
* coding all on the subscription CLI) without ever hijacking a hybrid setup.
*
* High-frequency should-respond/triage (TEXT_SMALL/NANO/MEDIUM) is never
* registered, so per-turn CLI spawn cost stays bounded to the user-facing reply
* via RESPONSE_HANDLER, the planner (text mode only), and possibly the post-turn
* evaluator — not the cheap triage calls.
*/
/** Large-tier free-text model types this plugin registers (when enabled). */
const LARGE_TIER_MODEL_TYPES: readonly string[] = [
ModelType.TEXT_LARGE,
ModelType.TEXT_MEGA,
ModelType.RESPONSE_HANDLER,
];
/**
* The planner. Registered ONLY in text-planner mode so the SAFE/CLI route can
* run standalone. The free-text CLI can emit the XML `<actions>` block but cannot
* honor GBNF/native-tool enforcement, so this REQUIRES `ELIZA_PLANNER_NATIVE_TOOLS=0`.
*/
const PLANNER_MODEL_TYPES: readonly string[] = [ModelType.ACTION_PLANNER];
/** True when the runtime is set for the XML text planner the free-text CLI can serve. */
function textPlannerEnabled(): boolean {
return readEnv("ELIZA_PLANNER_NATIVE_TOOLS")?.trim() === "0";
}
// "claude" → cold `claude --print` per call (TOS-clean, but ~5-15s/call).
// "claude-sdk" → WARM Claude Agent SDK session (TOS-clean + sanctioned + fast
// ~2s/turn after warm-up); the recommended Claude route.
// "codex" → cold `codex exec` per call (TOS-clean ChatGPT OAuth).
// "codex-sdk" → WARM Codex SDK thread (TOS-clean ChatGPT OAuth + fast); the
// codex peer of claude-sdk. ROUTE mode uses native outputSchema.
type CliBackend = "claude" | "claude-sdk" | "codex" | "codex-sdk";
function readEnv(name: string): string | undefined {
if (typeof process === "undefined") return undefined;
return process.env[name];
}
function getSetting(runtime: IAgentRuntime, key: string): string | undefined {
const value = runtime.getSetting(key);
return value === undefined || value === null ? readEnv(key) : String(value);
}
/** Resolve the configured backend, or undefined when the plugin is inert. */
export function resolveCliBackend(source: { ELIZA_CHAT_VIA_CLI?: string }): CliBackend | undefined {
const raw = source.ELIZA_CHAT_VIA_CLI?.trim().toLowerCase();
if (raw === "claude" || raw === "claude-sdk" || raw === "codex" || raw === "codex-sdk") {
return raw;
}
return undefined;
}
// Persistent warm Agent SDK sessions. The SDK freezes `systemPrompt` + tool
// config at query() start (no mid-session reset — proven live, research
// wf_3199bde6), so we key by (model, mode, systemPrompt-hash): each distinct
// system prompt and each mode (text vs native router) gets its own warm process.
// Keying by model ALONE would share one frozen-system opus session across
// RESPONSE_HANDLER/TEXT_LARGE/TEXT_MEGA, bleeding context between tiers/rooms and
// intermittently returning empty turns. Lives for the plugin's lifetime; torn
// down in dispose().
const sdkSessions = new Map<string, ClaudeSdkSession>();
/**
* Upper bound on concurrently-cached warm sessions. Each session is a live
* Claude Code process, so the cache must not grow without bound as distinct
* system prompts appear. In practice only a handful of keys are hot (a few
* tiers × the stable system-prompt prefix), but a long-lived agent whose system
* prompt drifts could otherwise accumulate processes. When the cap is exceeded
* the least-recently-used session is disposed (LRU: Map preserves insertion
* order; a cache hit re-inserts to mark it most-recently-used).
*/
const MAX_SDK_SESSIONS = 8;
/** The Claude model for a given tier (planner/small can differ from large). */
function resolveSdkModel(runtime: IAgentRuntime, modelType: string): string {
const large = getSetting(runtime, "ELIZA_CLI_CLAUDE_MODEL");
const small = getSetting(runtime, "ELIZA_CLI_CLAUDE_PLANNER_MODEL");
const isSmallTier = modelType === ModelType.ACTION_PLANNER || modelType === ModelType.TEXT_SMALL;
return ((isSmallTier ? small : large) || large || "claude-opus-4-8").trim();
}
function shortHash(value: string): string {
return createHash("sha256").update(value).digest("hex").slice(0, 16);
}
/**
* Lazily create + cache a warm SDK session for a (model, mode, systemPrompt).
* `router=true` builds a native `route_action` MCP-tool session (the planner);
* `router=false` builds a plain text-generation session (reply/large tiers).
*/
function claudeSessionKey(model: string, systemPrompt: string, router: boolean): string {
return `${model}\u001f${router ? "route" : "text"}\u001f${shortHash(systemPrompt)}`;
}
/**
* Evict + dispose a warm Claude SDK session by its cache key so the NEXT
* `getSdkSession` for that key spins up a fresh process — which re-reads the
* (rotated) `CLAUDE_CODE_OAUTH_TOKEN` / `~/.claude` credential. Used by account
* rotation after a subscription limit. Best-effort: dispose is fire-and-forget.
*/
function evictSdkSession(key: string): void {
const existing = sdkSessions.get(key);
if (!existing) return;
sdkSessions.delete(key);
void existing.dispose();
}
function getSdkSession(
runtime: IAgentRuntime,
model: string,
systemPrompt: string,
router: boolean,
subprocessEnv?: RotationSubprocessEnv
): ClaudeSdkSession {
const key = claudeSessionKey(model, systemPrompt, router);
const existing = sdkSessions.get(key);
if (existing) {
// Mark most-recently-used: delete + re-insert moves it to the Map's tail.
sdkSessions.delete(key);
sdkSessions.set(key, existing);
return existing;
}
const session = new ClaudeSdkSession({
model,
systemPrompt,
router,
claudeExecutablePath: getSetting(runtime, "ELIZA_CLI_CLAUDE_BIN"),
restartAfterTurns: parseTimeout(getSetting(runtime, "ELIZA_CLI_SDK_RESTART_AFTER_TURNS")),
turnTimeoutMs:
parseTimeout(getSetting(runtime, "ELIZA_CLI_SDK_TURN_TIMEOUT_MS")) ??
parseTimeout(getSetting(runtime, "ELIZA_CLI_TIMEOUT_MS")),
subprocessEnv,
});
sdkSessions.set(key, session);
// Evict least-recently-used past the cap (each session is a live process).
// dispose() is best-effort fire-and-forget — the new session is already
// cached and returned synchronously.
while (sdkSessions.size > MAX_SDK_SESSIONS) {
const lruKey = sdkSessions.keys().next().value as string | undefined;
if (lruKey === undefined) break;
const lru = sdkSessions.get(lruKey);
sdkSessions.delete(lruKey);
void lru?.dispose();
}
return session;
}
/** Tear down all warm SDK sessions (plugin dispose). */
export async function disposeSdkSessions(): Promise<void> {
const all = [...sdkSessions.values()];
sdkSessions.clear();
await Promise.all(all.map((s) => s.dispose()));
const codex = [...codexSdkSessions.values()];
codexSdkSessions.clear();
for (const s of codex) s.dispose();
}
// Warm Codex SDK threads, keyed by (model, mode). codex-sdk has no thread-level
// system prompt (it's folded into the body), so ONE warm thread per (model, mode)
// serves every system prompt — simpler than the claude cache.
const codexSdkSessions = new Map<string, CodexSdkSession>();
/** The codex model for a given tier (planner/small can differ from large). */
function resolveCodexModel(runtime: IAgentRuntime, modelType: string): string {
const large = getSetting(runtime, "ELIZA_CLI_CODEX_MODEL");
const small = getSetting(runtime, "ELIZA_CLI_CODEX_PLANNER_MODEL");
const isSmallTier = modelType === ModelType.ACTION_PLANNER || modelType === ModelType.TEXT_SMALL;
return ((isSmallTier ? small : large) || large || "gpt-5.5").trim();
}
function codexSessionKey(model: string, router: boolean): string {
return `${model}\u001f${router ? "route" : "text"}`;
}
/**
* Evict + dispose a warm Codex SDK thread by its cache key so the next
* `getCodexSdkSession` re-starts it — re-reading the (rotated) per-account
* `CODEX_HOME`. Used by account rotation after a subscription limit.
*/
function evictCodexSdkSession(key: string): void {
const existing = codexSdkSessions.get(key);
if (!existing) return;
codexSdkSessions.delete(key);
existing.dispose();
}
/** Lazily create + cache a warm Codex SDK thread for a (model, mode). */
function getCodexSdkSession(
runtime: IAgentRuntime,
model: string,
router: boolean,
subprocessEnv?: RotationSubprocessEnv
): CodexSdkSession {
const key = codexSessionKey(model, router);
let session = codexSdkSessions.get(key);
if (!session) {
session = new CodexSdkSession({
model,
router,
reasoningEffort: getSetting(runtime, "ELIZA_CLI_CODEX_REASONING_EFFORT"),
codexBinPath: getSetting(runtime, "ELIZA_CLI_CODEX_BIN"),
restartAfterTurns: parseTimeout(getSetting(runtime, "ELIZA_CLI_SDK_RESTART_AFTER_TURNS")),
subprocessEnv,
});
codexSdkSessions.set(key, session);
}
return session;
}
function parseTimeout(value: string | undefined): number | undefined {
if (value === undefined) return undefined;
const n = Number.parseInt(value, 10);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
// One binary setting covers both warm and cold backends so container images can
// pin executables outside the launcher allowlist. Blank settings retain the
// allowlisted PATH lookup used by unmanaged installations.
function resolveBinaryPin(runtime: IAgentRuntime, key: string): string | undefined {
const value = getSetting(runtime, key)?.trim();
return value ? value : undefined;
}
function buildClaude(runtime: IAgentRuntime): ClaudeCli {
return new ClaudeCli({
model: getSetting(runtime, "ELIZA_CLI_CLAUDE_MODEL"),
timeoutMs: parseTimeout(getSetting(runtime, "ELIZA_CLI_TIMEOUT_MS")),
binaryPath: resolveBinaryPin(runtime, "ELIZA_CLI_CLAUDE_BIN"),
});
}
function buildCodex(runtime: IAgentRuntime): CodexCli {
return new CodexCli({
model: getSetting(runtime, "ELIZA_CLI_CODEX_MODEL"),
timeoutMs: parseTimeout(getSetting(runtime, "ELIZA_CLI_TIMEOUT_MS")),
binaryPath: resolveBinaryPin(runtime, "ELIZA_CLI_CODEX_BIN"),
});
}
async function generateViaCli(
runtime: IAgentRuntime,
params: GenerateTextParams,
modelType: string
): Promise<string> {
const backend = resolveCliBackend({
ELIZA_CHAT_VIA_CLI: getSetting(runtime, "ELIZA_CHAT_VIA_CLI"),
});
if (!backend) {
// Should be unreachable: the handlers are only registered when a backend is
// set. Throw so useModel/AccountPool treat it as a provider failure rather
// than silently returning empty.
throw new Error(
"[cli-inference] ELIZA_CHAT_VIA_CLI is not set to claude|claude-sdk|codex|codex-sdk"
);
}
const generateParams = {
system: params.system,
prompt: params.prompt,
messages: params.messages,
};
logger.debug(`[cli-inference] ${modelType} via ${backend}`);
if (backend === "claude-sdk") {
// Warm, persistent Agent SDK session. The SDK freezes `systemPrompt` at start,
// so we flatten system+messages here and hand the session a STABLE system
// (so all turns of a tier share one warm process) plus a self-contained body.
const model = resolveSdkModel(runtime, modelType);
const { system, body } = flattenPrompt(generateParams);
// Reframe the agentic SDK model as a pure completion engine (system) AND close
// the body with a directive that cancels any stale "call a tool" instruction,
// so it synthesizes the final reply from already-executed tool results instead
// of narrating intent ("I'll fetch it…"). Both are needed (proven live: 4/4 vs
// 2/4). Keying by the framed system keeps one warm process per tier.
const framedSystem = frameTextSystemPrompt(system);
const framedBody = appendTextDirective(body);
const key = claudeSessionKey(model, framedSystem, false);
// Pool-first auth: the FIRST warm session already auths as a healthy pooled
// Claude account when one exists (ambient ~/.claude is the fallback). On a
// subscription limit, rotate to the next healthy pooled account (evicting
// the warm session so it re-auths as the new account), then retry; fall
// through to provider failover only when the pool is exhausted.
return withAccountRotation(
(env) => getSdkSession(runtime, model, framedSystem, false, env).generate(framedBody),
{
backend,
getValue: (k) => getSetting(runtime, k),
sessionKey: `cli-inference:${key}`,
onRotate: () => evictSdkSession(key),
}
);
}
if (backend === "codex-sdk") {
// Warm Codex SDK thread. codex-sdk folds the system into the body, so frame
// the body the same way (the SDK model is agentic too) and run TEXT mode.
const model = resolveCodexModel(runtime, modelType);
const { system, body } = flattenPrompt(generateParams);
const framedBody = appendTextDirective(`${frameTextSystemPrompt(system)}\n\n${body}`);
const key = codexSessionKey(model, false);
return withAccountRotation(
(env) => getCodexSdkSession(runtime, model, false, env).generate(framedBody),
{
backend,
getValue: (k) => getSetting(runtime, k),
sessionKey: `cli-inference:${key}`,
onRotate: () => evictCodexSdkSession(key),
}
);
}
const cli = backend === "claude" ? buildClaude(runtime) : buildCodex(runtime);
return cli.generate(generateParams);
}
/**
* ACTION_PLANNER handler for the SAFE/CLI route.
*
* Two implementations, picked by backend:
*
* - **claude-sdk (native router):** the model is given ONE in-process MCP tool
* (`route_action`) and emits a real `tool_use` the warm session captures as
* `{action, params}`. This matches the stealth/native path's full
* functionality (live-info → WEB_FETCH, sub-agents) with no free-text JSON
* parsing and no required-tool retry loop. The per-turn action menu +
* transcript + persona ride in the BODY (system stays the constant
* `ROUTER_SYSTEM_PROMPT` so one warm process serves every planner turn).
*
* - **claude / codex CLI:** the free-text CLI cannot honor native tools, so we
* rebuild the call into the proven CLEAN text-routing form (pick ONE action,
* emit compact `{action, params}` JSON) via `buildCleanRoutingParams`.
*
* Both return the bare `{action, params}` shape the loop's text-mode parser
* (`parseJsonPlannerOutput` → `normalizeBarePlannerAction`) accepts directly, so
* no core change is needed.
*/
async function planViaCli(runtime: IAgentRuntime, params: GenerateTextParams): Promise<string> {
const backend = resolveCliBackend({
ELIZA_CHAT_VIA_CLI: getSetting(runtime, "ELIZA_CHAT_VIA_CLI"),
});
if (backend === "claude-sdk") {
const model = resolveSdkModel(runtime, ModelType.ACTION_PLANNER);
const routerBody = buildRouterBody(params);
const key = claudeSessionKey(model, ROUTER_SYSTEM_PROMPT, true);
return withAccountRotation(
(env) => getSdkSession(runtime, model, ROUTER_SYSTEM_PROMPT, true, env).route(routerBody),
{
backend,
getValue: (k) => getSetting(runtime, k),
sessionKey: `cli-inference:${key}`,
onRotate: () => evictSdkSession(key),
}
);
}
if (backend === "codex-sdk") {
// codex routes via NATIVE structured output (outputSchema) for a reliable
// {action, params} shape. The clean-routing prompt (menu + transcript +
// persona) is folded into the body (codex-sdk has no thread-level system
// prompt); the session applies the schema + parses the result.
const model = resolveCodexModel(runtime, ModelType.ACTION_PLANNER);
const clean = buildCleanRoutingParams(params);
const routeBody = `${clean.system ?? ""}\n\n${clean.prompt ?? ""}`;
const key = codexSessionKey(model, true);
return withAccountRotation(
(env) => getCodexSdkSession(runtime, model, true, env).route(routeBody),
{
backend,
getValue: (k) => getSetting(runtime, k),
sessionKey: `cli-inference:${key}`,
onRotate: () => evictCodexSdkSession(key),
}
);
}
return generateViaCli(runtime, buildCleanRoutingParams(params), ModelType.ACTION_PLANNER);
}
/**
* Build the models map. When no backend is configured the map is EMPTY so the
* plugin registers nothing and the cheap configured provider keeps serving
* every tier.
*/
export function buildModels(
source: { ELIZA_CHAT_VIA_CLI?: string } = { ELIZA_CHAT_VIA_CLI: readEnv("ELIZA_CHAT_VIA_CLI") }
): Plugin["models"] {
if (!resolveCliBackend(source)) return {};
const models: Record<
string,
(runtime: IAgentRuntime, params: GenerateTextParams) => Promise<string>
> = {};
for (const modelType of LARGE_TIER_MODEL_TYPES) {
models[modelType] = (runtime, params) => generateViaCli(runtime, params, modelType);
}
if (textPlannerEnabled()) {
for (const modelType of PLANNER_MODEL_TYPES) {
models[modelType] = (runtime, params) => planViaCli(runtime, params);
}
}
return models as Plugin["models"];
}
export const cliInferencePlugin: Plugin = {
name: "cli-inference",
description:
"TOS-clean SAFE/CLOUD inference: serves large-tier model handlers through sanctioned claude, claude-sdk, or codex routes; each route reads its own creds. Inert unless ELIZA_CHAT_VIA_CLI=claude|claude-sdk|codex.",
// High priority so that, when ELIZA_CHAT_VIA_CLI is set, this plugin
// deterministically wins the tiers it registers (TEXT_LARGE / TEXT_MEGA /
// RESPONSE_HANDLER) over default-priority (0) model providers like
// plugin-anthropic that would otherwise tie and resolve non-deterministically.
priority: 100,
config: {
ELIZA_CHAT_VIA_CLI: readEnv("ELIZA_CHAT_VIA_CLI") ?? null,
ELIZA_CLI_CLAUDE_MODEL: readEnv("ELIZA_CLI_CLAUDE_MODEL") ?? null,
ELIZA_CLI_CLAUDE_PLANNER_MODEL: readEnv("ELIZA_CLI_CLAUDE_PLANNER_MODEL") ?? null,
ELIZA_CLI_CLAUDE_BIN: readEnv("ELIZA_CLI_CLAUDE_BIN") ?? null,
ELIZA_CLI_SDK_RESTART_AFTER_TURNS: readEnv("ELIZA_CLI_SDK_RESTART_AFTER_TURNS") ?? null,
ELIZA_CLI_SDK_TURN_TIMEOUT_MS: readEnv("ELIZA_CLI_SDK_TURN_TIMEOUT_MS") ?? null,
ELIZA_CLI_CODEX_MODEL: readEnv("ELIZA_CLI_CODEX_MODEL") ?? null,
ELIZA_CLI_CODEX_BIN: readEnv("ELIZA_CLI_CODEX_BIN") ?? null,
ELIZA_CLI_TIMEOUT_MS: readEnv("ELIZA_CLI_TIMEOUT_MS") ?? null,
ELIZA_CLI_INFERENCE_ACCOUNT_ROTATION: readEnv("ELIZA_CLI_INFERENCE_ACCOUNT_ROTATION") ?? null,
},
async init(): Promise<void> {
const backend = resolveCliBackend({ ELIZA_CHAT_VIA_CLI: readEnv("ELIZA_CHAT_VIA_CLI") });
if (!backend) {
logger.info("[cli-inference] ELIZA_CHAT_VIA_CLI unset — plugin inert (no models registered)");
return;
}
// Double-activation guard: the in-process claude-code-stealth interceptor and
// this CLI-spawn path are two colliding claude routes. This guard lives HERE
// (not in credentials.ts, which is skip-worktree on the live branch).
const stealth = readEnv("ELIZA_ENABLE_CLAUDE_STEALTH")?.trim().toLowerCase();
const stealthOn =
stealth === "1" || stealth === "true" || stealth === "yes" || stealth === "on";
if ((backend === "claude" || backend === "claude-sdk") && stealthOn) {
throw new Error(
`[cli-inference] ELIZA_CHAT_VIA_CLI=${backend} collides with ELIZA_ENABLE_CLAUDE_STEALTH. ` +
"Pick one claude inference route (CLI/SDK spawn vs in-process stealth interceptor)."
);
}
logger.info(
backend === "claude-sdk"
? "[cli-inference] enabled via ELIZA_CHAT_VIA_CLI=claude-sdk — WARM Agent SDK sessions (TOS-clean, sanctioned, fast)"
: `[cli-inference] enabled via ELIZA_CHAT_VIA_CLI=${backend} — large-tier handlers spawn the ${backend} CLI`
);
if (textPlannerEnabled()) {
logger.info(
"[cli-inference] text-planner mode (ELIZA_PLANNER_NATIVE_TOOLS=0) — ACTION_PLANNER also served via this route (standalone SAFE stack)"
);
}
if ((backend === "claude-sdk" || backend === "codex-sdk") && rotationEnabled(readEnv)) {
logger.info(
"[cli-inference] multi-account rotation ON — a subscription limit rotates to the next healthy pooled account before provider failover (opt out: ELIZA_CLI_INFERENCE_ACCOUNT_ROTATION=0)"
);
}
},
async dispose(): Promise<void> {
await disposeSdkSessions();
},
models: buildModels(),
};
export {
buildRotatedSubprocessEnv,
isSubscriptionLimitError,
rotationAgentTypeForBackend,
rotationEnabled,
withAccountRotation,
} from "./src/account-rotation";
export { ClaudeCli } from "./src/claude-cli";
export { ClaudeSdkSession } from "./src/claude-sdk-session";
export {
appendTextDirective,
buildCleanRoutingBody,
buildCleanRoutingParams,
buildCleanRoutingSystemPrompt,
buildRouterBody,
frameTextSystemPrompt,
ROUTER_SYSTEM_PROMPT,
TEXT_COMPLETION_DIRECTIVE,
TEXT_COMPLETION_FRAMING,
} from "./src/clean-routing-planner";
export { CodexCli, parseCodexJsonl } from "./src/codex-cli-exec";
export { CodexSdkSession } from "./src/codex-sdk-session";
export { flattenPrompt } from "./src/prompt-flatten";
export { LARGE_TIER_MODEL_TYPES, PLANNER_MODEL_TYPES, textPlannerEnabled };
export default cliInferencePlugin;
+146
View File
@@ -0,0 +1,146 @@
{
"name": "@elizaos/plugin-cli-inference",
"version": "2.0.3-beta.7",
"type": "module",
"main": "dist/node/index.node.js",
"module": "dist/node/index.node.js",
"types": "dist/index.d.ts",
"browser": "dist/browser/index.browser.js",
"sideEffects": false,
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"eliza-source": {
"types": "./index.node.ts",
"import": "./index.node.ts",
"default": "./index.node.ts"
},
"browser": {
"types": "./dist/browser/index.d.ts",
"import": "./dist/browser/index.browser.js",
"default": "./dist/browser/index.browser.js"
},
"node": {
"types": "./dist/node/index.d.ts",
"import": "./dist/node/index.node.js",
"default": "./dist/node/index.node.js"
},
"default": "./dist/node/index.node.js"
},
"./*.css": "./dist/*.css",
"./*": {
"types": "./dist/*.d.ts",
"import": "./dist/*.js",
"default": "./dist/*.js"
}
},
"files": [
"dist",
"auto-enable.ts"
],
"elizaos": {
"plugin": {
"autoEnableModule": "./auto-enable.ts",
"capabilities": [
"text-large"
]
}
},
"optionalDependencies": {
"@anthropic-ai/claude-agent-sdk": "0.3.200",
"@openai/codex-sdk": "0.80.0"
},
"devDependencies": {
"@biomejs/biome": "2.5.3",
"@elizaos/core": "workspace:*",
"@types/bun": "^1.3.8",
"@types/node": "^25.0.3",
"typescript": "^6.0.3",
"vitest": "^4.0.0"
},
"peerDependencies": {
"@elizaos/core": "workspace:*",
"zod": "^4.4.3"
},
"scripts": {
"build:ts": "bun run build.ts",
"dev": "bun --hot build.ts",
"lint": "bunx @biomejs/biome check --write --unsafe .",
"lint:check": "bunx @biomejs/biome check .",
"clean": "node ../../packages/scripts/rm-path-recursive.mjs dist .turbo",
"format": "bunx @biomejs/biome format --write .",
"format:check": "bunx @biomejs/biome format .",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "vitest run --config ./vitest.config.ts",
"test:ts": "vitest run --config ./vitest.config.ts",
"build": "bun run build.ts"
},
"publishConfig": {
"access": "public"
},
"agentConfig": {
"pluginType": "elizaos:plugin:1.0.0",
"pluginParameters": {
"ELIZA_CHAT_VIA_CLI": {
"type": "string",
"description": "Enable the SAFE/CLOUD inference route. Set to 'claude', 'claude-sdk', or 'codex' for large-tier inference. Unset = plugin inert.",
"required": false,
"sensitive": false
},
"ELIZA_CLI_CLAUDE_MODEL": {
"type": "string",
"description": "Model id passed to `claude --model`. Defaults to claude-opus-4-8.",
"required": false,
"default": "claude-opus-4-8",
"sensitive": false
},
"ELIZA_CLI_CLAUDE_PLANNER_MODEL": {
"type": "string",
"description": "Claude SDK planner/small-tier model. Falls back to ELIZA_CLI_CLAUDE_MODEL.",
"required": false,
"sensitive": false
},
"ELIZA_CLI_CLAUDE_BIN": {
"type": "string",
"description": "Path to the Claude Code executable used by the Claude Agent SDK backend.",
"required": false,
"sensitive": false
},
"ELIZA_CLI_SDK_RESTART_AFTER_TURNS": {
"type": "number",
"description": "Restart each warm Claude SDK session after this many turns. Defaults to 20.",
"required": false,
"default": 20,
"sensitive": false
},
"ELIZA_CLI_SDK_TURN_TIMEOUT_MS": {
"type": "number",
"description": "Per-turn Claude SDK timeout in milliseconds. Defaults to 90000 and falls back to ELIZA_CLI_TIMEOUT_MS when set.",
"required": false,
"default": 90000,
"sensitive": false
},
"ELIZA_CLI_CODEX_MODEL": {
"type": "string",
"description": "Model id passed to `codex exec -m`. Defaults to gpt-5.5.",
"required": false,
"default": "gpt-5.5",
"sensitive": false
},
"ELIZA_CLI_TIMEOUT_MS": {
"type": "number",
"description": "Per-call CLI spawn timeout in milliseconds (SIGTERM on expiry). Defaults to 120000.",
"required": false,
"default": 120000,
"sensitive": false
}
}
},
"eliza": {
"platforms": [
"node"
],
"runtime": "node"
}
}
@@ -0,0 +1,69 @@
{
"id": "cli-inference",
"name": "CLI Inference",
"description": "Node-only model provider that shells out to sanctioned local Claude or Codex CLIs.",
"npmName": "@elizaos/plugin-cli-inference",
"version": "2.0.0-beta.0",
"source": "bundled",
"tags": ["ai-provider", "llm", "cli", "claude", "codex"],
"config": {
"ELIZA_CHAT_VIA_CLI": {
"type": "select",
"required": false,
"sensitive": false,
"label": "CLI Backend",
"help": "Enable CLI-backed large-tier inference with claude or codex.",
"advanced": false,
"options": [
{ "value": "claude", "label": "Claude" },
{ "value": "codex", "label": "Codex" }
],
"autoEnableProvider": true
},
"ELIZA_CLI_CLAUDE_MODEL": {
"type": "string",
"required": false,
"sensitive": false,
"default": "claude-opus-4-8",
"label": "Claude CLI Model",
"help": "Model passed to claude --print.",
"advanced": true
},
"ELIZA_CLI_CODEX_MODEL": {
"type": "string",
"required": false,
"sensitive": false,
"default": "gpt-5.5",
"label": "Codex CLI Model",
"help": "Model passed to codex exec.",
"advanced": true
},
"ELIZA_CLI_TIMEOUT_MS": {
"type": "number",
"required": false,
"sensitive": false,
"default": "120000",
"label": "Timeout",
"help": "Per-call CLI spawn timeout in milliseconds.",
"advanced": true,
"min": 0,
"unit": "ms"
}
},
"render": {
"visible": true,
"pinTo": [],
"style": "card",
"icon": "Terminal",
"group": "models",
"groupOrder": 20,
"actions": ["enable", "configure"]
},
"resources": {
"homepage": "https://github.com/elizaOS/eliza",
"repository": "https://github.com/elizaOS/eliza"
},
"dependsOn": [],
"kind": "plugin",
"subtype": "ai-provider"
}
@@ -0,0 +1,431 @@
/**
* Chat-brain multi-account rotation for the SAFE/CLI inference route (issue
* #11180 Gap A).
*
* The problem: without this module `plugin-cli-inference` authenticates the warm
* claude-sdk / codex-sdk session from the machine's single ambient credential
* (`~/.claude` / `CLAUDE_CODE_OAUTH_TOKEN`, or `~/.codex` / `CODEX_HOME`). Two
* consequences: (a) an app-connected subscription stored in the account pool is
* never used for chat — on a machine with no ambient CLI login the route fails
* outright even though a healthy pooled account exists; (b) when the active
* account hits its hourly/monthly subscription limit, the SDK ends the turn by
* streaming the limit envelope, the session handlers throw (per the
* throw-to-failover contract), and `useModel` skips straight to the next
* provider TIER (cloud / API key) — it never asks the pool for the next healthy
* *account* of the SAME provider first. A user with two Claude Max accounts
* still stalls the brain when account #1 limits, exactly like a solo account.
*
* The fix (this module): consume the coding-agent selector bridge
* (`CODING_AGENT_SELECTOR_BRIDGE_SYMBOL`, single-sourced in `@elizaos/core`) —
* the SAME bridge coding sub-agents rotate through, which maps backend →
* provider, pool-selects the next healthy account, and MATERIALIZES the exact
* env the subprocess needs: `CLAUDE_CODE_OAUTH_TOKEN` for claude, a per-account
* `CODEX_HOME` for codex) at TWO points:
*
* 1. **Pool-first initial auth.** BEFORE the session's first attempt, select a
* healthy pooled account and hand its subprocess-only env to the warm
* session. Without this, an app-connected subscription sits stored-but-
* unused while the SDK auths from the machine's ambient credential — and on
* a machine with NO ambient login the route fails outright even though a
* pooled account is present. Ambient stays the FALLBACK: an empty pool
* (select → null) or a failed selection preserves the pre-pool behavior
* (attempt with no env).
* 2. **Rotation on a subscription-limit-classed throw.** We mark the limited
* account, select the next healthy one, build its subprocess-only env,
* dispose the warm session so it re-auths as the new account on its next
* start, and retry the turn transparently. Only when the pool returns null
* (all accounts limited / no pool / single account) do we rethrow so the
* caller's existing provider-failover chain runs. Rotation (account A →
* account B, same provider) therefore composes with — and runs BEFORE —
* failover (claude → cloud → api).
*
* Design notes:
* - **Bridge over `globalThis`, not an app-core import.** The pool + credential
* store live in `@elizaos/app-core`; this plugin depends only on
* `@elizaos/core`. Like `plugin-agent-orchestrator/coding-account-selection.ts`
* we read the narrow contract off a `Symbol.for(...)` key. When no pool is
* configured the bridge is absent and this module is a pass-through no-op
* (single-account behavior is byte-for-byte unchanged).
* - **TOS invariant preserved.** The subscription token materialized by the
* bridge only ever lands in the first-party SDK subprocess env (`query
* options.env` / `new Codex({ env })`), never the runtime's shared
* `process.env`, never logs, never a third-party API. `CODEX_HOME` is a
* directory path, not a secret.
* - **Rotation is opt-out-able**, gated like the rest of the plugin's env
* conventions: `ELIZA_CLI_INFERENCE_ACCOUNT_ROTATION` (default ON when a pool
* is present; set `0`/`false`/`off` to disable and go straight to failover).
* - **Only rate-limit-class errors rotate.** A non-limit failure (timeout parsed
* as retryable is still a limit-shaped signal; a 400/empty-completion/auth
* error is NOT) rethrows immediately so we never burn the pool on a bug.
*
* @module plugin-cli-inference/account-rotation
*/
import {
type CodingAccountStrategy,
type CodingAgentSelectorBridge,
getCodingAgentSelectorBridge,
logger,
} from "@elizaos/core";
export type RotationAgentType = "claude" | "codex";
export type RotationSubprocessEnv = Record<string, string | undefined>;
interface RotationState {
selection: RotationAccountSelection;
subprocessEnv: RotationSubprocessEnv;
}
const rotationStateBySession = new Map<string, RotationState>();
/** A selected account plus the env the first-party subprocess needs to auth as it. */
export interface RotationAccountSelection {
providerId: string;
accountId: string;
label: string;
source: "oauth" | "api-key";
strategy: string;
/** Secrets / paths injected into the SDK subprocess env, never persisted or logged. */
envPatch: Record<string, string>;
}
/**
* Read the installed bridge, or null when no pool has been constructed. The
* bridge symbol + contract are single-sourced in `@elizaos/core`.
*/
export function getCodingAccountBridge(): CodingAgentSelectorBridge | null {
return getCodingAgentSelectorBridge();
}
/**
* The backends whose warm SDK sessions authenticate per pooled account. The
* cold `claude --print` / `codex exec` CLIs read the machine's single on-disk
* login and are out of scope for in-runtime rotation (they'd need the CLI shim,
* issue #11180 Gap B), so ONLY the SDK backends map to a rotation agent type.
*/
const BACKEND_TO_AGENT_TYPE: Readonly<Record<string, RotationAgentType>> = {
"claude-sdk": "claude",
"codex-sdk": "codex",
};
/** Map an inference backend to the coding-agent pool type, or null when unrotatable. */
export function rotationAgentTypeForBackend(backend: string): RotationAgentType | null {
return BACKEND_TO_AGENT_TYPE[backend] ?? null;
}
/** Default cool-off applied to an account that hit a subscription limit (15 min). */
export const ROTATION_RATE_LIMIT_COOLOFF_MS = 15 * 60_000;
/**
* True when this error is the subscription-limit / rate-limit class that should
* trigger account rotation. Deliberately CONSERVATIVE: the session handlers
* already narrow their throws (they only surface the limit envelope as a
* "subscription rate limit reached: …" message, or a `ProviderApiError` whose
* message carries the upstream status), so we anchor on those same signals plus
* a 429/529/quota vocabulary. A false positive burns a healthy account out of
* the pool for 15 min, so anything ambiguous (400, empty completion, plain auth
* failure, generic timeout) does NOT rotate — it rethrows to failover.
*/
export function isSubscriptionLimitError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err ?? "");
const statusCode = (err as { statusCode?: unknown })?.statusCode;
if (typeof statusCode === "number" && (statusCode === 429 || statusCode === 529)) {
return true;
}
const t = message.toLowerCase();
return (
// The claude-sdk session's own limit throw ("subscription rate limit reached: …").
t.includes("subscription rate limit reached") ||
// Explicit status the SDK envelope carries.
/\b(429|529)\b/.test(t) ||
// Provider limit / quota vocabulary. Kept tight: "rate limit" + "usage limit
// reached" + "quota exceeded/exhausted" + "too many requests" are the
// unambiguous provider signals (matches the orchestrator's classifier).
/rate[\s-]?limit(?:ed|ing)?/.test(t) ||
t.includes("usage limit reached") ||
/quota (?:exceeded|exhausted)/.test(t) ||
// OpenAI's CLASSIC quota envelope inverts the word order ("You exceeded
// your current quota, please check your plan and billing details") and
// carries the machine code `insufficient_quota` — none of which contain
// "quota exceeded" or a literal 429. Anchor on the provider's exact
// envelope phrases / error code, NOT generic quota/billing prose, so a
// model merely talking about quotas still does not rotate.
t.includes("exceeded your current quota") ||
t.includes("check your plan and billing details") ||
t.includes("insufficient_quota") ||
t.includes("too many requests")
);
}
/** Resolve whether rotation is enabled (default ON; opt-out via env/setting). */
export function rotationEnabled(getValue: (key: string) => string | undefined): boolean {
const raw = getValue("ELIZA_CLI_INFERENCE_ACCOUNT_ROTATION")?.trim().toLowerCase();
if (raw === "0" || raw === "false" || raw === "no" || raw === "off") return false;
return true;
}
/**
* Ambient auth vars that would compete with a rotated account's env patch.
* Both SDKs replace the subprocess environment when `env` is provided, so the
* merge spreads `process.env` (PATH/HOME survive) but drops these first;
* otherwise an operator's own ambient key/home could outrank the selected pool
* account.
*/
const COMPETING_AUTH_VARS: Readonly<Record<RotationAgentType, readonly string[]>> = {
claude: ["CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY"],
codex: ["CODEX_HOME", "OPENAI_API_KEY"],
};
/**
* Build the SDK subprocess env for a rotated account. Pure: it never mutates
* `process.env`, so pooled credentials cannot leak into the parent process or
* other in-process consumers.
*/
export function buildRotatedSubprocessEnv(
agentType: RotationAgentType,
envPatch: Record<string, string>
): RotationSubprocessEnv {
const env: RotationSubprocessEnv = typeof process === "undefined" ? {} : { ...process.env };
for (const key of COMPETING_AUTH_VARS[agentType]) {
delete env[key];
}
return { ...env, ...envPatch };
}
/** A description safe to log about a selection (label + provider, NEVER the token). */
function safeAccountLabel(sel: RotationAccountSelection): string {
return `${sel.providerId}/${sel.label}`;
}
export interface RotationContext {
/** The configured inference backend (claude-sdk / codex-sdk / claude / codex). */
backend: string;
/** Read a runtime setting/env value (rotation gate). */
getValue: (key: string) => string | undefined;
/** Stable key so pool session-affinity ties a conversation to one account. */
sessionKey?: string;
/**
* Called whenever the selected account changes — after the pool-first initial
* selection and after each successful rotation, BEFORE the (re)try — so the
* caller can tear down any warm SDK session bound to the previous credential;
* the fresh session then re-auths as the newly-selected account on its next
* start. A no-op when no warm session exists yet (the common initial case).
*/
onRotate: () => void | Promise<void>;
/** Selection strategy override (else the pool's default). */
strategy?: CodingAccountStrategy;
}
function rotationStateKey(ctx: RotationContext): string {
return `${ctx.backend}\u001f${ctx.sessionKey ?? "__default"}`;
}
/** Test seam: keeps per-test rotation state independent. */
export function resetRotationStateForTests(): void {
rotationStateBySession.clear();
}
/**
* Run `attempt` with pool-first auth + transparent account rotation on
* subscription-limit errors.
*
* Flow:
* 1. Pool-FIRST initial auth: when no account is selected for this session yet,
* select a healthy pooled account BEFORE the first attempt so a stored
* app-connected subscription serves the very first turn. Empty pool / failed
* selection → ambient fallback (attempt with no env, pre-pool behavior).
* 2. Try `attempt(env)`. On success, return it (and best-effort record usage on
* the currently-selected account).
* 3. If it throws and the error is NOT a subscription-limit (or rotation is
* disabled / no bridge / backend not rotatable), rethrow immediately →
* the caller's existing provider-failover chain handles it.
* 4. On a limit error: mark the current account rate-limited, select the next
* healthy account from the pool (excluding every account already tried),
* build its subprocess-only env, dispose the warm session (`onRotate`), and retry.
* Repeat until an attempt succeeds or the pool is exhausted; when the pool
* returns null, rethrow the LAST limit error so failover runs.
*
* A single structured `warn` is emitted per rotation (no credential/envelope
* leakage). At most `maxRotations` swaps are attempted to bound the loop even if
* the pool mis-reports health.
*/
export async function withAccountRotation(
attempt: (env?: RotationSubprocessEnv) => Promise<string>,
ctx: RotationContext,
maxRotations = 8
): Promise<string> {
const agentType = rotationAgentTypeForBackend(ctx.backend);
const bridge = agentType ? getCodingAccountBridge() : null;
// No rotation possible/desired → single, un-wrapped attempt (behavior
// identical to pre-rotation: any throw goes straight to the caller's failover).
if (!bridge || !agentType || !rotationEnabled(ctx.getValue)) {
return attempt();
}
const stateKey = rotationStateKey(ctx);
let state = rotationStateBySession.get(stateKey) ?? null;
// Pool-first initial auth: nothing selected for this session yet, so ask the
// pool BEFORE the first attempt instead of silently starting on the machine's
// ambient credential. An app user who connected their subscription expects it
// used immediately — and a machine with NO ambient login would otherwise fail
// despite a healthy pooled account. Ambient stays the fallback: select → null
// (empty pool) or a selection error changes nothing.
if (!state) {
let selection: RotationAccountSelection | null = null;
try {
selection = await bridge.select(agentType, {
...(ctx.sessionKey ? { sessionKey: ctx.sessionKey } : {}),
...(ctx.strategy ? { strategy: ctx.strategy } : {}),
});
} catch (selectErr) {
// error-policy:J4 explicit degrade — the account POOL is optional; if its
// select() fails we fall back to the ambient credential (documented degrade),
// the CLI still runs. Not a swallowed inference failure.
logger.warn(
{
src: "cli-inference:rotation",
backend: ctx.backend,
reason: "initial-select-failed",
},
`[cli-inference] initial pool selection failed (${String(selectErr)}) — falling back to the ambient credential`
);
}
if (selection) {
state = {
selection,
subprocessEnv: buildRotatedSubprocessEnv(agentType, selection.envPatch),
};
rotationStateBySession.set(stateKey, state);
// Evict any warm session that started on the ambient credential before
// the pool was installed, so the next start auths as the selected
// account. A no-op when no session exists yet (the common first turn).
try {
await ctx.onRotate();
} catch {
// error-policy:J6 best-effort teardown — the fresh start re-inits anyway.
}
logger.info(
{
src: "cli-inference:rotation",
backend: ctx.backend,
account: safeAccountLabel(selection),
strategy: selection.strategy,
},
`[cli-inference] pooled ${agentType} account selected for warm-session auth`
);
}
}
const tried: string[] = state ? [state.selection.accountId] : [];
let lastError: unknown;
for (let rotations = 0; rotations <= maxRotations; rotations += 1) {
try {
const result = await attempt(state?.subprocessEnv);
// Record a successful call against the account we rotated INTO so
// quota-aware selection reflects real usage (best-effort; never throws).
if (state) {
void bridge
// error-policy:J7 usage accounting is telemetry — a recordUsage failure
// must not fail the successful inference result being returned.
.recordUsage(state.selection.providerId, state.selection.accountId, { ok: true })
.catch(() => undefined);
}
return result;
} catch (err) {
lastError = err;
if (!isSubscriptionLimitError(err)) {
// Not a limit — a genuine failure. Do NOT rotate (would burn a healthy
// account); rethrow so the caller's provider-failover chain runs.
throw err;
}
// The active account just limited. Mark it (with its reset window) so
// quota-aware selection routes around it, then pick the next healthy one.
// Only for an account WE selected (the ambient credential is untracked).
if (state) {
void bridge
// error-policy:J7 marking the limit is best-effort bookkeeping; failure
// to persist it must not stop the rotation-to-next-account below.
.markRateLimited(
state.selection.providerId,
state.selection.accountId,
Date.now() + ROTATION_RATE_LIMIT_COOLOFF_MS,
"cli-inference subscription limit"
)
.catch(() => undefined);
rotationStateBySession.delete(stateKey);
state = null;
}
let selection: RotationAccountSelection | null = null;
try {
selection = await bridge.select(agentType, {
...(ctx.sessionKey ? { sessionKey: ctx.sessionKey } : {}),
...(ctx.strategy ? { strategy: ctx.strategy } : {}),
// Copy: `tried` keeps growing across rotations; the bridge must see
// the exclusions as of THIS call, not a live reference.
exclude: [...tried],
});
} catch (selectErr) {
// error-policy:J2 context-adding — pool selection itself failed; treat as
// pool-exhausted and rethrow the ORIGINAL limit error so the caller's
// provider-failover chain runs (does not fabricate a success).
logger.warn(
{
src: "cli-inference:rotation",
backend: ctx.backend,
reason: "select-failed",
},
`[cli-inference] account rotation select failed (${String(selectErr)}) — falling through to provider failover`
);
throw err;
}
if (!selection) {
// Pool exhausted: every healthy account tried, or none configured.
// Rethrow the limit error so the caller's failover chain (cloud / API)
// runs — rotation composes with, and yields to, failover.
logger.warn(
{
src: "cli-inference:rotation",
backend: ctx.backend,
tried: tried.length,
reason: "pool-exhausted",
},
`[cli-inference] all pooled ${agentType} accounts rate-limited (${tried.length} tried) — failing over to next provider tier`
);
throw err;
}
tried.push(selection.accountId);
state = {
selection,
subprocessEnv: buildRotatedSubprocessEnv(agentType, selection.envPatch),
};
rotationStateBySession.set(stateKey, state);
// Tear down the warm session so it re-auths as the newly-selected account.
try {
await ctx.onRotate();
} catch {
// error-policy:J6 best-effort teardown — the fresh start re-inits anyway.
}
logger.warn(
{
src: "cli-inference:rotation",
backend: ctx.backend,
account: safeAccountLabel(selection),
strategy: selection.strategy,
attempt: rotations + 1,
},
`[cli-inference] rotated to next ${agentType} account after subscription limit`
);
// loop retries the turn on the new account
}
}
// Exhausted maxRotations without success — fail over with the last error.
throw lastError instanceof Error
? lastError
: new Error(`[cli-inference] account rotation exhausted: ${String(lastError)}`);
}
@@ -0,0 +1,249 @@
import { spawn } from "node:child_process";
import { openSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { logger } from "@elizaos/core";
import { flattenPrompt } from "./prompt-flatten";
import { ProviderApiError, parseProviderApiErrorText } from "./provider-errors";
import { filterEnv, redactStderr, resolveSafeBinary, resolveSafeCwd } from "./sandbox";
/**
* Claude Code CLI inference variant (TOS-clean SAFE/CLOUD route).
*
* Spawns the sanctioned `claude --print` binary, which reads its OWN OAuth
* credentials from `~/.claude/.credentials.json`. eliza never sees, forwards,
* or logs the subscription token: the child env is run through `filterEnv`
* (allowlist + `SENSITIVE_ENV_RE` blocklist), and stderr is redacted before
* logging. Distinct from the in-process stealth fetch-interceptor at
* `packages/agent/src/auth/credentials.ts`, which replays the token in-process
* and stays on a never-commit branch.
*/
const DEFAULT_MODEL = "claude-opus-4-8";
const DEFAULT_TIMEOUT_MS = 120_000;
const CLAUDE_BINARY = "claude";
export interface SpawnResult {
code: number | null;
signal: NodeJS.Signals | null;
stdout: string;
stderr: string;
timedOut: boolean;
}
export type SpawnFn = (argv: string[], opts: SpawnOptions) => Promise<SpawnResult>;
export interface SpawnOptions {
cwd: string;
env: Record<string, string>;
timeoutMs: number;
/** Absolute path used as stdin (always `/dev/null` in production). */
stdinPath: string;
}
export interface ClaudeCliConfig {
model?: string;
timeoutMs?: number;
/** Override the env source (defaults to `process.env`). */
env?: NodeJS.ProcessEnv;
/**
* Pin the resolved binary path, skipping `resolveSafeBinary`. Used by unit
* tests (so they never touch the real filesystem) and by container deploys
* that pin the CLI outside the default allowlist. When unset, the binary is
* resolved from PATH against the SOC2 whitelist.
*/
binaryPath?: string;
}
export interface ClaudeGenerateParams {
system?: string;
prompt?: string;
messages?: Parameters<typeof flattenPrompt>[0]["messages"];
}
/** Grace window after SIGTERM before escalating to SIGKILL on the group. */
const SIGKILL_GRACE_MS = 2_000;
/**
* Default spawner: runs argv with `/dev/null` stdin, captures stdout/stderr,
* and enforces a hard timeout. On expiry it SIGTERMs the whole process group
* (the child is spawned `detached` so it leads its own group), then escalates
* to SIGKILL after a short grace window so a child that ignores SIGTERM cannot
* hang generate() past `timeoutMs + SIGKILL_GRACE_MS`. Pure I/O — overridable
* in tests via `__setSpawnForTests`.
*/
export const defaultSpawn: SpawnFn = (argv, opts) =>
new Promise<SpawnResult>((resolve, reject) => {
let stdinFd: number;
try {
// `/dev/null` stdin is REQUIRED: without it the CLI waits ~3s for stdin
// before falling through to the `-p` arg.
stdinFd = openSync(opts.stdinPath, "r");
} catch (err) {
// error-policy:J1 boundary — cannot open the required stdin fd; reject the
// spawn (fail closed, do not launch the CLI blind).
reject(err instanceof Error ? err : new Error(String(err)));
return;
}
const child = spawn(argv[0], argv.slice(1), {
cwd: opts.cwd,
env: opts.env,
stdio: [stdinFd, "pipe", "pipe"],
// Lead a new process group so we can signal the whole tree (the CLI may
// fork helpers) and so SIGKILL escalation reaches a child that traps
// SIGTERM.
detached: true,
});
let stdout = "";
let stderr = "";
let timedOut = false;
let killTimer: NodeJS.Timeout | undefined;
// Signal the process group (negative pid) when we have one; fall back to
// the child directly if the pid is unavailable.
const signalTree = (sig: NodeJS.Signals): void => {
const pid = child.pid;
try {
if (typeof pid === "number") {
process.kill(-pid, sig);
} else {
child.kill(sig);
}
} catch {
// error-policy:J6 best-effort signal — an ESRCH means the group already
// exited; there is nothing to signal, which is the intended terminal state.
}
};
const timer = setTimeout(() => {
timedOut = true;
signalTree("SIGTERM");
// Hard escalation: if the child ignores SIGTERM, SIGKILL the group so
// generate() cannot hang past timeoutMs + grace.
killTimer = setTimeout(() => signalTree("SIGKILL"), SIGKILL_GRACE_MS);
killTimer.unref?.();
}, opts.timeoutMs);
const clearTimers = (): void => {
clearTimeout(timer);
if (killTimer) clearTimeout(killTimer);
};
child.stdout?.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
child.stderr?.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
child.on("error", (err) => {
clearTimers();
reject(err);
});
child.on("close", (code, signal) => {
clearTimers();
resolve({ code, signal, stdout, stderr, timedOut });
});
});
let spawnImpl: SpawnFn = defaultSpawn;
/** Test seam: swap the child-process spawner. Returns a restore fn. */
export function __setSpawnForTests(fn: SpawnFn): () => void {
const prev = spawnImpl;
spawnImpl = fn;
return () => {
spawnImpl = prev;
};
}
export class ClaudeCli {
private readonly model: string;
private readonly timeoutMs: number;
private readonly env: NodeJS.ProcessEnv;
private readonly binaryPath?: string;
constructor(config: ClaudeCliConfig = {}) {
this.model = config.model ?? DEFAULT_MODEL;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.env = config.env ?? process.env;
this.binaryPath = config.binaryPath;
}
async generate(params: ClaudeGenerateParams): Promise<string> {
const { system, body } = flattenPrompt(params);
const binary = this.binaryPath ?? resolveSafeBinary(CLAUDE_BINARY, this.env);
// Isolated empty tmpdir cwd: keeps the CLI out of any real project so it
// never picks up repo context (which would inject Claude Code's own
// identity / file-aware behavior), and `resolveSafeCwd` rejects symlink
// escapes. Created + removed per call.
const rawCwd = await mkdtemp(join(tmpdir(), "eliza-cli-inference-"));
const cwd = resolveSafeCwd(rawCwd, [tmpdir()]);
try {
const argv = [binary, "-p", body];
// Only override the system prompt when the runtime actually supplied one.
// Passing `--system-prompt ''` together with
// `--exclude-dynamic-system-prompt-sections` would strip ALL steering
// (both ours and Claude Code's), leaving the model ungoverned. When the
// flattened system is empty we leave Claude Code's default sections in
// place rather than blank everything out.
if (system.trim().length > 0) {
argv.push(
// FULL REPLACE of the system prompt: suppresses Claude Code's own
// identity and lets the runtime grammar (`<response><thought><text>`
// + do-not-invent rules) take over.
"--system-prompt",
system,
// Drop Claude Code's dynamic system-prompt sections (repo/tool
// context) so only our system prompt governs the turn.
"--exclude-dynamic-system-prompt-sections"
);
}
argv.push("--output-format", "text", "--model", this.model);
const result = await spawnImpl(argv, {
cwd,
// NEVER inject the subscription token: filterEnv allowlists PATH/HOME/…
// and drops every `SENSITIVE_ENV_RE` key. The CLI reads its own creds
// from ~/.claude/.credentials.json.
env: filterEnv(this.env),
timeoutMs: this.timeoutMs,
stdinPath: "/dev/null",
});
if (result.timedOut) {
throw new Error(
`[cli-inference] claude timed out after ${this.timeoutMs}ms (SIGTERM): ${redactStderr(result.stderr)}`
);
}
if (result.code !== 0) {
throw new Error(
`[cli-inference] claude exited ${result.code} signal=${result.signal}: ${redactStderr(result.stderr)}`
);
}
const text = result.stdout.trim();
const apiError = parseProviderApiErrorText(text);
if (apiError) {
throw new ProviderApiError(`[cli-inference] claude upstream ${text.slice(0, 160)}`, {
statusCode: apiError.statusCode,
});
}
if (text.length === 0) {
throw new Error(
`[cli-inference] claude returned empty stdout: ${redactStderr(result.stderr)}`
);
}
return text;
} finally {
// error-policy:J6 best-effort teardown of the isolated cwd; logged at debug,
// must not mask the returned result / error.
await rm(rawCwd, { recursive: true, force: true }).catch((err) => {
logger.debug(`[cli-inference] failed to clean isolated cwd ${rawCwd}: ${String(err)}`);
});
}
}
}
@@ -0,0 +1,594 @@
/**
* Warm Claude Agent SDK inference session — the FAST, TOS-clean way to run an
* Eliza brain on a Claude Max subscription.
*
* Unlike `claude --print` (ClaudeCli), which cold-spawns a fresh process on
* EVERY model call (~5-15s startup each; the planner's ~4-8 calls/turn = 25-68s),
* this keeps ONE long-lived Claude Code process warm via the Agent SDK's
* streaming-input mode. The startup cost is paid once; subsequent turns are just
* inference (~1-2s, proven). Auth is the subscription's own OAuth — the SDK reads
* `~/.claude` or `CLAUDE_CODE_OAUTH_TOKEN` (from `claude setup-token`); eliza
* never sees the token. Effective 2026-06-15 this is OFFICIALLY sanctioned use
* of a Claude subscription (the monthly Agent SDK credit), so it is strictly
* cleaner than the in-process stealth token-replay.
*
* TWO MODES (the SDK fixes `systemPrompt` + `mcpServers` at query() start — see
* the live-proven research wf_3199bde6: there is NO mid-session setSystemPrompt
* or history-reset, so a session is created per frozen system prompt):
*
* - TEXT mode (`generate`): pure text generation for the reply / large tiers.
* `allowedTools: []` strips Claude Code's own tools so the SDK is a warm
* chat-completion engine. `maxTurns` defaults to 1 (a one-shot answer leaves
* no room for the agentic "I'll fetch it…" preamble-then-act pattern that
* leaks when >1), and the `result` envelope is inspected so an
* `error_max_turns`/empty turn falls back to `result.result` instead of
* throwing a spurious "empty completion".
*
* - ROUTE mode (`route`): the ACTION_PLANNER decision via NATIVE tool-calling.
* A single in-process MCP tool (`route_action`) is the ONLY allowed tool; the
* model emits a real `tool_use`, the SDK routes it to our handler in-process,
* and the handler captures `{action, params}` — Eliza executes the action,
* Claude Code never does. This matches the stealth/native path's full
* functionality (WEB_FETCH, sub-agents) with no free-text JSON parsing and no
* required-tool retry loop. The turn ends `subtype=error_max_turns` (normal
* for a tool-calling turn under `maxTurns: 1`); the captured decision — not
* the assistant text — is the result.
*
* One instance == one (model, systemPrompt, mode). Calls are SERIALIZED (one in
* flight per warm session); spin up multiple instances for concurrency. The
* session is RESTARTED after `restartAfterTurns` turns to bound the accumulating
* context window.
*
* @module plugin-cli-inference/claude-sdk-session
*/
import { logger } from "@elizaos/core";
import type { RotationSubprocessEnv } from "./account-rotation";
import { ProviderApiError, parseProviderApiErrorText } from "./provider-errors";
const DEFAULT_MODEL = "claude-opus-4-8";
const DEFAULT_RESTART_AFTER_TURNS = 20;
const DEFAULT_TURN_TIMEOUT_MS = 90_000;
/** Fully-qualified name the SDK assigns our in-process MCP tool. */
const ROUTE_TOOL = "mcp__eliza__route_action";
/**
* When the monthly Agent SDK credit runs dry (documented caveat: the subscription
* limit can be hit mid-month), the SDK ends the turn CLEANLY but streams the
* subscription limit UI string as the assistant text — e.g. "You've hit your
* session limit · resets 9:30pm (UTC)". Without this guard that meta-string is
* returned as the turn's completion and relayed verbatim to the user as the reply.
* Match the SDK's own limit envelope (a fixed provider string, NOT user content)
* so the caller can THROW to failover / a graceful rate-limit reply instead of
* leaking it. Kept narrow to the subscription-limit signature to avoid catching a
* genuine model answer that happens to discuss limits.
*/
export function isClaudeSubscriptionLimitMessage(text: string): boolean {
const t = text.trim().toLowerCase();
if (t.length > 160) return false; // the real limit string is short; a long answer isn't it
// A genuine short ANSWER about limits ("No, you haven't hit your rate limit
// yet.") shares vocabulary with the envelope; the envelope itself never
// contains negation.
if (/\b(no|not|haven't|havent|hasn't|hasnt|didn't|didnt|isn't|isnt|wasn't|wasnt)\b/.test(t)) {
return false;
}
return (
// The UI envelope's interpunct separator ("· resets 9:30pm (UTC)",
// "∙ resets 3am") — model prose doesn't join clauses with an interpunct.
/[·∙•]\s*resets\b/.test(t) ||
// The envelope IS the whole message and opens second-person: "You've hit
// your session limit …". Anchored at the start so a genuine answer that
// merely contains the phrase mid-sentence ("Yes — you've hit your daily
// limit on that key") does not match.
/^you'?ve (hit|reached|exceeded) your\b[^.]*\blimit\b/.test(t) ||
// The classic Claude CLI form: "Claude AI usage limit reached|<unix-epoch>".
/\bclaude( ai)? usage limit reached\s*\|/.test(t)
);
}
/**
* The Claude Code / Agent SDK surfaces API failures by STREAMING its error
* string as assistant text and terminating the turn cleanly — e.g.
* "API Error: 400 messages: text content blocks must be non-empty" (observed
* live 18x when empty relay lines produced an empty text content block).
* That format is the SDK's own error envelope, never a genuine completion:
* real answers don't open with "API Error: <status>". Detect it so callers
* throw to failover instead of relaying the raw error to the user.
*/
export function isClaudeSdkApiErrorMessage(text: string): boolean {
return parseProviderApiErrorText(text) !== null;
}
/** The model's captured routing decision (ROUTE mode). */
export interface RouteDecision {
action: string;
params: Record<string, unknown>;
}
type SdkUserMessage = {
type: "user";
message: { role: "user"; content: string };
parent_tool_use_id?: null;
};
type SdkContentBlock = { type: string; text?: string };
type SdkMessage = {
type: string;
subtype?: string;
result?: string;
message?: { content?: SdkContentBlock[]; stop_reason?: string };
};
type SdkQuery = AsyncIterable<SdkMessage> & {
interrupt?: () => Promise<void>;
};
type SdkQueryFn = (options: {
prompt: AsyncIterable<SdkUserMessage>;
options: Record<string, unknown>;
}) => SdkQuery;
type SdkToolFn = (
name: string,
description: string,
schema: Record<string, unknown>,
handler: (args: {
action?: unknown;
params?: unknown;
}) => Promise<{ content: Array<{ type: string; text: string }> }>
) => unknown;
type SdkMcpServerFn = (options: { name: string; version?: string; tools: unknown[] }) => unknown;
/** Minimal shape of the SDK module we load lazily. */
export interface SdkModule {
query: SdkQueryFn;
tool: SdkToolFn;
createSdkMcpServer: SdkMcpServerFn;
}
interface ZodModule {
z: {
string: () => unknown;
any: () => unknown;
record: (value: unknown) => unknown;
};
}
export interface ClaudeSdkSessionConfig {
model?: string | null;
/**
* Frozen system prompt for this session. The SDK resolves `systemPrompt` once
* at query() start, so the caller MUST key its session cache by this value
* (one warm process per distinct system prompt).
*/
systemPrompt?: string | null;
/** ROUTE mode (native `route_action` MCP tool) vs TEXT mode (plain generation). */
router?: boolean;
/** TEXT mode only: `maxTurns` for the SDK query (default 1 — one-shot answer,
* no agentic preamble-then-act). Ignored in ROUTE mode (always 1). */
textMaxTurns?: number;
/** Path to the Claude Code executable the SDK drives. */
claudeExecutablePath?: string | null;
/** Restart the warm session after this many turns (bounds context growth). */
restartAfterTurns?: number;
/** Hard wall-clock budget for one SDK turn. Defaults below common 120s connector timeouts. */
turnTimeoutMs?: number;
/**
* Optional subprocess-only env for a pooled account. Passed to the Claude SDK
* query options; never written to the parent process env.
*/
subprocessEnv?: RotationSubprocessEnv | null;
/** Injected for tests; defaults to the real SDK + zod. */
sdkModule?: SdkModule;
zodModule?: ZodModule;
}
// Resolved at runtime from the hoisted workspace node_modules (the Agent SDK
// ships with @agentclientprotocol/claude-agent-acp). Imported via a variable so
// the build does not require it as a static dependency — the plugin stays inert
// (and never imports the SDK) unless ELIZA_CHAT_VIA_CLI=claude-sdk is set.
const SDK_PACKAGE = "@anthropic-ai/claude-agent-sdk";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isSdkModule(value: unknown): value is SdkModule {
return (
isRecord(value) &&
typeof value.query === "function" &&
typeof value.tool === "function" &&
typeof value.createSdkMcpServer === "function"
);
}
function isZodModule(value: unknown): value is ZodModule {
if (!isRecord(value) || !isRecord(value.z)) {
return false;
}
const z = value.z;
return (
typeof z.string === "function" && typeof z.any === "function" && typeof z.record === "function"
);
}
async function loadSdk(): Promise<SdkModule> {
const sdk: unknown = await import(SDK_PACKAGE);
if (!isSdkModule(sdk)) {
throw new Error("[cli-inference:sdk] Claude Agent SDK module has an unexpected shape");
}
return sdk;
}
async function loadZod(): Promise<ZodModule> {
const zod: unknown = await import("zod");
if (!isZodModule(zod)) {
throw new Error("[cli-inference:sdk] zod module has an unexpected shape");
}
return zod;
}
/**
* A single warm Agent SDK session for one (model, systemPrompt, mode). Lazily
* starts on first call, serializes calls, and self-heals (restarts) on error or
* after `restartAfterTurns`.
*/
export class ClaudeSdkSession {
private readonly model: string;
private readonly systemPrompt: string | null;
private readonly router: boolean;
private readonly textMaxTurns: number;
private readonly claudeExecutablePath: string | null;
private readonly restartAfterTurns: number;
private readonly turnTimeoutMs: number;
private readonly subprocessEnv: RotationSubprocessEnv | null;
private readonly sdkOverride?: SdkModule;
private readonly zodOverride?: ZodModule;
private query: SdkQuery | null = null;
private feed: ((msg: SdkUserMessage) => void) | null = null;
private iterator: AsyncIterator<SdkMessage> | null = null;
private turns = 0;
private chain: Promise<unknown> = Promise.resolve();
// ROUTE mode: the MCP tool handler writes the current turn's decision here.
// Safe to share across turns because calls are serialized on `chain` and it is
// reset at the start of every `sendAndRead`.
private pendingDecision: RouteDecision | null = null;
constructor(config: ClaudeSdkSessionConfig) {
this.model = config.model?.trim() || DEFAULT_MODEL;
this.systemPrompt = config.systemPrompt?.trim() || null;
this.router = config.router === true;
this.textMaxTurns = config.textMaxTurns && config.textMaxTurns > 0 ? config.textMaxTurns : 1;
this.claudeExecutablePath = config.claudeExecutablePath?.trim() || null;
this.restartAfterTurns =
config.restartAfterTurns && config.restartAfterTurns > 0
? config.restartAfterTurns
: DEFAULT_RESTART_AFTER_TURNS;
this.turnTimeoutMs =
config.turnTimeoutMs && config.turnTimeoutMs > 0
? config.turnTimeoutMs
: DEFAULT_TURN_TIMEOUT_MS;
this.subprocessEnv = config.subprocessEnv ?? null;
this.sdkOverride = config.sdkModule;
this.zodOverride = config.zodModule;
}
/** TEXT mode: generate one completion's text. Serialized. */
generate(body: string): Promise<string> {
return this.enqueue(() => this.sendOnce(body, "text"));
}
/**
* ROUTE mode: return `JSON.stringify({action, params})` — the action the model
* picked via the native `route_action` tool. The planner loop's text-mode
* parser (`parseJsonPlannerOutput` → `normalizeBarePlannerAction`) consumes
* this bare shape directly, so no core change is needed.
*/
route(body: string): Promise<string> {
return this.enqueue(() => this.sendOnce(body, "route"));
}
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
// Serialize so only one turn is in flight per warm session (the streaming
// generator is a single conversation, and pendingDecision is shared).
const run = this.chain.then(fn, fn);
// error-policy:J5 the chain tail only serializes turns; the REAL result/error
// is returned to the caller via `run`. Swallowing here just stops a settled
// tail from raising an unhandled rejection — the caller still sees the error.
this.chain = run.catch(() => undefined);
return run;
}
private async sendOnce(body: string, mode: "text" | "route"): Promise<string> {
if (!body.trim()) {
throw new Error("[cli-inference:sdk] empty prompt body");
}
// Restart the warm session periodically to bound the accumulating window.
if (this.query && this.turns >= this.restartAfterTurns) {
await this.dispose();
}
if (!this.query) {
await this.start();
}
this.turns += 1;
try {
return await this.sendAndRead(body, mode);
} catch (err) {
// error-policy:J2 context-adding rethrow — self-heal (a dead/erroring session
// must not poison the next turn), then rethrow so the caller sees the failure.
await this.dispose();
throw err instanceof Error ? err : new Error(`[cli-inference:sdk] ${String(err)}`);
}
}
private async start(): Promise<void> {
const sdk = this.sdkOverride ?? (await loadSdk());
// A pull-based async generator the SDK drains; we push the next user message
// into it via `this.feed`.
const queue: SdkUserMessage[] = [];
let resolveWaiter: ((m: SdkUserMessage) => void) | null = null;
this.feed = (msg) => {
if (resolveWaiter) {
const r = resolveWaiter;
resolveWaiter = null;
r(msg);
} else {
queue.push(msg);
}
};
async function* promptStream(): AsyncIterable<SdkUserMessage> {
while (true) {
const next: SdkUserMessage = queue.length
? (queue.shift() as SdkUserMessage)
: await new Promise<SdkUserMessage>((res) => {
resolveWaiter = res;
});
yield next;
}
}
const options: Record<string, unknown> = {
model: this.model,
settingSources: [],
permissionMode: "bypassPermissions",
// `bypassPermissions` is the SDK's documented companion to skipping the
// permission prompt; pass the explicit flag so the contract is stable
// across SDK versions rather than relying on the mode alone.
allowDangerouslySkipPermissions: true,
// ROUTE: one tool call ends the turn (subtype=error_max_turns is normal).
// TEXT: default 1 — a one-shot answer leaves no room for the agentic
// "I'll fetch it…" preamble-then-act pattern that leaks when maxTurns>1.
maxTurns: this.router ? 1 : this.textMaxTurns,
};
if (this.subprocessEnv) {
options.env = this.subprocessEnv;
}
if (this.systemPrompt) options.systemPrompt = this.systemPrompt;
if (this.claudeExecutablePath) {
options.pathToClaudeCodeExecutable = this.claudeExecutablePath;
}
if (this.router) {
const { z } = this.zodOverride ?? (await loadZod());
const routeTool = sdk.tool(
"route_action",
"Pick exactly ONE Eliza action for this turn plus its params, chosen from " +
"the action menu in the user message. Call this EXACTLY ONCE, then stop — " +
"produce no other output.",
{ action: z.string(), params: z.record(z.any()) },
async (args) => {
if (
!this.pendingDecision &&
args &&
typeof args.action === "string" &&
args.action.trim()
) {
this.pendingDecision = {
action: args.action.trim(),
params:
args.params && typeof args.params === "object"
? (args.params as Record<string, unknown>)
: {},
};
}
return {
content: [{ type: "text", text: "ACK. Routing recorded. Stop now." }],
};
}
);
const mcp = sdk.createSdkMcpServer({
name: "eliza",
version: "1.0.0",
tools: [routeTool],
});
options.mcpServers = { eliza: mcp };
options.allowedTools = [ROUTE_TOOL];
options.tools = [ROUTE_TOOL];
} else {
// Pure text generation: NO tools at all. `tools: []` disables the SDK's
// built-in tool set (matching ROUTE mode, which sets it to the one MCP
// tool) — `allowedTools: []` alone only filters an otherwise-enabled set,
// so under `bypassPermissions` the agent could still reach the filesystem
// / shell. RESPONSE_HANDLER processes untrusted inbound text, so the SDK
// must be a pure completion engine with no tool surface.
options.tools = [];
options.allowedTools = [];
options.disallowedTools = [];
}
this.query = sdk.query({ prompt: promptStream(), options });
this.iterator = this.query[Symbol.asyncIterator]();
this.turns = 0;
logger.debug(
{
src: "cli-inference:sdk",
model: this.model,
mode: this.router ? "route" : "text",
},
"warm Claude Agent SDK session started"
);
}
private async nextWithTurnTimeout(): Promise<IteratorResult<SdkMessage>> {
const iterator = this.iterator;
if (!iterator) {
throw new Error("[cli-inference:sdk] session not started");
}
let timer: NodeJS.Timeout | undefined;
try {
return await Promise.race([
iterator.next(),
new Promise<IteratorResult<SdkMessage>>((_, reject) => {
timer = setTimeout(() => {
reject(
new ProviderApiError(
`[cli-inference:sdk] turn timed out after ${this.turnTimeoutMs}ms`,
{ retryable: true }
)
);
}, this.turnTimeoutMs);
timer.unref?.();
}),
]);
} catch (error) {
// error-policy:J2 context-adding rethrow — dispose on a provider/timeout
// error, then rethrow the original error unchanged.
if (error instanceof ProviderApiError) {
await this.dispose();
}
throw error;
} finally {
if (timer) clearTimeout(timer);
}
}
/** Push one user message and read the turn's assistant text + result envelope. */
private async sendAndRead(body: string, mode: "text" | "route"): Promise<string> {
if (!this.feed || !this.iterator) {
throw new Error("[cli-inference:sdk] session not started");
}
this.pendingDecision = null;
this.feed({
type: "user",
message: { role: "user", content: body },
parent_tool_use_id: null,
});
let text = "";
let resultText: string | undefined;
let resultSubtype: string | undefined;
let sawResult = false;
while (true) {
const { value, done } = await this.nextWithTurnTimeout();
if (done) {
// Generator ended WITHOUT a terminating `result` message — the session
// died mid-turn. Force a restart next turn; `sawResult` stays false so
// the handlers below fail over instead of trusting partial output.
this.query = null;
break;
}
const msg = value as SdkMessage;
if (msg.type === "assistant") {
for (const block of msg.message?.content ?? []) {
if (block.type === "text" && block.text) text += block.text;
}
}
// `result` marks the clean end of one turn and carries the terminal
// `subtype` (+ a `result` text the SDK echoes on success).
if (msg.type === "result") {
sawResult = true;
resultSubtype = msg.subtype;
if (typeof msg.result === "string") resultText = msg.result;
break;
}
}
if (mode === "route" && this.pendingDecision) {
// The decision is captured in the MCP handler the moment the model calls
// route_action — that IS the success signal (the turn then ends
// subtype=error_max_turns, which is normal). Return it regardless of how
// the turn terminated, and BEFORE the limit guard below: a validly
// captured decision must never be discarded because residual preamble
// text happened to mention limits.
return JSON.stringify(this.pendingDecision);
}
// A dried-up subscription credit ends the turn cleanly but surfaces the
// limit string as the "answer" — as streamed assistant text and/or as the
// result-envelope echo. Detect BOTH before either mode returns so it fails
// over / becomes a graceful rate-limit reply instead of leaking "You've hit
// your session limit ..." to the user (route mode would otherwise relay it
// as a REPLY, text mode as the completion). "rate limit" in the thrown
// message routes it through isRateLimitError.
const limitEnvelope = [text, resultText ?? ""].find((candidate) =>
isClaudeSubscriptionLimitMessage(candidate)
);
if (sawResult && limitEnvelope !== undefined) {
throw new Error(
`[cli-inference:sdk] subscription rate limit reached: ${limitEnvelope.trim().slice(0, 120)}`
);
}
// Same leak shape, different envelope: an upstream API failure ("API
// Error: 400 messages: text content blocks must be non-empty", 429s, 5xx)
// is streamed as assistant text and the turn terminates cleanly — without
// this guard it is returned as the completion and relayed verbatim to the
// user (observed live 18x). Throw per the failover contract; the message
// keeps the SDK's status text so isRateLimitError/isAuthError classify
// 429/401 correctly downstream.
const apiErrorEnvelope = [text, resultText ?? ""].find((candidate) =>
isClaudeSdkApiErrorMessage(candidate)
);
if (apiErrorEnvelope !== undefined) {
const parsed = parseProviderApiErrorText(apiErrorEnvelope);
throw new ProviderApiError(
`[cli-inference:sdk] upstream ${apiErrorEnvelope.trim().slice(0, 160)}`,
{ statusCode: parsed?.statusCode }
);
}
if (mode === "route") {
// No decision: the model went off-contract (it was told to call the tool
// and "produce no plain-text answer"), so any residual text is a planning
// preamble, not a finished reply — never surface it as a user REPLY. Only
// accept a genuine terminal answer (a clean `result` with subtype=success)
// as a REPLY; otherwise throw so the planner loop retries / fails over.
if (sawResult && resultSubtype === "success") {
const answer = text.trim() || resultText?.trim();
if (answer) {
return JSON.stringify({ action: "REPLY", params: { text: answer } });
}
}
throw new Error(
`[cli-inference:sdk] route: model emitted no decision (subtype=${resultSubtype ?? "?"})`
);
}
// TEXT mode: trust the streamed assistant text only when the turn TERMINATED
// CLEANLY (a `result` message arrived). A generator that ended without one
// means the session died mid-stream, so partial text is a truncated reply —
// throw instead of surfacing it. The streamed text is the model's real
// output regardless of subtype; the `result` echo is only trustworthy on
// success (on error_max_turns it may be an SDK meta-string like "Reached
// maximum turns", not the completion).
if (sawResult && text.trim()) return text.trim();
if (sawResult && resultSubtype === "success" && resultText?.trim()) {
return resultText.trim();
}
// No trustworthy text: THROW so useModel / AccountPool fails over, per the
// plugin's throw-to-failover contract — never return partial/meta output.
throw new Error(
`[cli-inference:sdk] empty completion (subtype=${resultSubtype ?? (sawResult ? "?" : "session-ended")})`
);
}
/** Tear down the warm session (on restart, error, or dispose). */
async dispose(): Promise<void> {
const q = this.query;
this.query = null;
this.iterator = null;
this.feed = null;
this.turns = 0;
this.pendingDecision = null;
if (q?.interrupt) {
try {
await q.interrupt();
} catch {
// error-policy:J6 best-effort teardown — interrupting an already-dead
// query on dispose; failure here does not matter (the session is discarded).
}
}
}
}
@@ -0,0 +1,284 @@
import type { ChatMessage, GenerateTextParams, JSONSchema, ToolDefinition } from "@elizaos/core";
import { contentToText } from "./prompt-flatten";
/**
* Clean-routing ACTION_PLANNER for the SAFE/CLI inference route.
*
* WHY THIS EXISTS
* ---------------
* The native-tool planner prompt (`packages/core/src/prompts/planner.ts` +
* the rendered `planner_stage:` block + the `<response>` grammar) is written
* for a provider that honors GBNF / native-tool / responseSchema enforcement.
* Fed verbatim to `claude --print`, that grammar-heavy prompt reliably
* confuses the model into ANSWERING the user's task inline (as Claude Code
* would) instead of EMITTING a routing decision — the model reads "task: Plan
* next native tool calls" plus a wall of decode-rules and treats it as a
* coding instruction.
*
* Proven live: `claude --print --model claude-opus-4-8` given a CLEAN routing
* prompt ("pick ONE action, output ONLY compact JSON {action,params}") routes
* every turn type correctly, with grounded params, in eliza's voice. So claude
* CAN plan — it just needs the routing task stated plainly and the action
* universe handed to it as a short menu rather than as native tool schemas it
* is told to "call".
*
* WHAT THIS DOES
* --------------
* Rebuilds the planner call into the proven clean form BEFORE it reaches the
* CLI:
* 1. Extracts the action universe from `params.tools` (the per-action
* `ToolDefinition[]` the planner loop always forwards — each tool's `name`
* is the action name, `parameters` is its arg schema).
* 2. Extracts the conversation transcript from `params.messages`, dropping
* the injected `planner_stage:` grammar block and any prior `<response>`
* scaffold so only real user/assistant/tool turns remain.
* 3. Synthesizes a CLEAN routing system prompt + body (the proven template)
* and returns them as a fresh `GenerateTextParams` for `generateViaCli`.
*
* The handler returns the model's compact JSON verbatim. The planner loop's
* text-mode parser (`parseJsonPlannerOutput` → `normalizeBarePlannerAction`)
* accepts the bare `{"action": NAME, "params": {...}}` shape directly: it reads
* the action name from `action`/`name` and the args from `params`/`parameters`,
* and resolves REPLY / IGNORE / STOP as the universal terminal sentinels. No
* change to core is required.
*/
/** Marker the planner loop injects ahead of the grammar-heavy instructions. */
const PLANNER_STAGE_MARKER = "planner_stage:";
/** Roles whose content is steering/grammar, not conversation. */
const STEERING_ROLES = new Set<string>(["system", "developer"]);
/** Render a tool's parameter schema as a terse `name: type` hint list. */
function renderParamHints(schema: JSONSchema | undefined): string {
if (!schema || typeof schema !== "object") return "(no params)";
const properties = (schema as { properties?: Record<string, JSONSchema> }).properties;
if (!properties || Object.keys(properties).length === 0) return "(no params)";
const requiredList = ((schema as { required?: unknown }).required as string[] | undefined) ?? [];
const required = new Set(requiredList);
const parts: string[] = [];
for (const [key, prop] of Object.entries(properties)) {
const type =
(prop as { type?: unknown }).type !== undefined
? String((prop as { type?: unknown }).type)
: "any";
const enumValues = (prop as { enum?: unknown }).enum;
const enumHint =
Array.isArray(enumValues) && enumValues.length > 0
? ` one of [${enumValues.map((v) => JSON.stringify(v)).join(", ")}]`
: "";
const flag = required.has(key) ? "" : "?";
parts.push(`${key}${flag}: ${type}${enumHint}`);
}
return parts.join(", ");
}
/** Render the action universe as a compact, plain-language menu. */
function renderActionMenu(tools: ReadonlyArray<ToolDefinition>): string {
const lines: string[] = [];
for (const tool of tools) {
if (!tool.name) continue;
const description = (tool.description ?? "").trim().replace(/\s+/g, " ");
const params = renderParamHints(tool.parameters);
lines.push(`- ${tool.name}${description} [params: ${params}]`);
}
return lines.join("\n");
}
/**
* Pull the real conversation transcript out of the planner's message array,
* dropping the injected `planner_stage:` grammar block and the steering system
* messages. Keeps user / assistant / tool turns in order.
*/
function renderTranscript(messages: ReadonlyArray<ChatMessage> | undefined): string {
if (!messages || messages.length === 0) return "";
const lines: string[] = [];
for (const message of messages) {
if (STEERING_ROLES.has(message.role)) continue;
let text = contentToText(message.content);
if (!text) continue;
// Strip the planner-stage grammar block when it rode in on a user message
// (the loop folds instructions into the stage user message in some paths).
const markerIndex = text.indexOf(PLANNER_STAGE_MARKER);
if (markerIndex !== -1) {
text = text.slice(0, markerIndex).trim();
if (!text) continue;
}
const label =
message.role === "assistant" ? "Assistant" : message.role === "tool" ? "Tool result" : "User";
lines.push(`${label}: ${text}`);
}
return lines.join("\n\n");
}
/**
* The clean routing system prompt — the proven template.
*
* `mustCallTool` mirrors the planner loop's `toolChoice: "required"` gate
* (`planner-loop.ts:211`, set when Stage 1 flagged the turn tool-required and a
* non-terminal action is exposed). The native-tool path enforces this with
* `tool_choice: required` against the provider; the clean-routing rewrite has no
* such grammar, so the requirement MUST be stated in the prompt — otherwise the
* model reads the default "answer directly if you can" rule and emits a terminal
* REPLY/IGNORE for live-info turns ("btc price?"), which the loop rejects up to
* `maxRequiredToolMisses` times and then surfaces a "hit a snag" apology. We
* steer by the GROUND TRUTH that separates the two cases — live/external data
* the agent cannot know from memory (needs a tool) vs static knowledge it can
* (REPLY is fine) — rather than a blind ban, so genuinely-tool-less turns still
* answer instead of force-calling an irrelevant action.
*/
export function buildCleanRoutingSystemPrompt(
actionMenu: string,
characterSystem: string | undefined,
mustCallTool = false
): string {
const voice = characterSystem?.trim()
? `Your persona / voice (use it for any user-facing text):\n${characterSystem.trim()}\n\n`
: "";
const replyRule = mustCallTool
? `- This turn was flagged as needing a tool. If the user's request depends on CURRENT, LIVE, or EXTERNAL information you cannot know with certainty from memory — current prices, weather, news, sports/markets, the contents of a URL or file, or running a task — you MUST choose the matching non-terminal action (e.g. WEB_FETCH with a concrete url, WEB_SEARCH with a query) and ground its params. Do NOT answer such requests from memory, and do NOT choose REPLY/IGNORE/STOP for them. Choose REPLY only if the request is static knowledge you can answer correctly with no tool at all (arithmetic, definitions, or facts about the conversation itself).`
: `- If you can answer the user directly from what you already know (no tool needed), choose REPLY and put the COMPLETE answer in params.text.`;
return `${voice}You are the action router for an AI agent. Your ONLY job this turn is to pick exactly ONE action for the agent to take next, given the conversation and the menu of available actions below.
Available actions:
${actionMenu}
Terminal actions (always available):
- REPLY — answer the user directly and end the turn. params: { text: string } (the full user-facing answer, in your voice).
- IGNORE — say nothing and end the turn. params: {} (use when no reply is appropriate).
- STOP — hard stop the turn. params: {} (use only on an explicit user stop).
Rules:
- Pick exactly ONE action by its exact name from the lists above.
- If a listed non-terminal action fulfills the user's request, choose it and fill its params, grounded in the conversation. Do not answer the task yourself — the action's handler does the work.
${replyRule}
- Never invent an action name that is not in the lists. Never invent required params you don't have grounds for.
- params must be a JSON object obeying that action's param hints (arrays as JSON arrays, not comma strings).
Output format (CRITICAL):
- Output ONLY a single compact JSON object, nothing else — no prose, no markdown, no code fences, no explanation.
- Shape: {"action": "<ACTION_NAME>", "params": { ... }}
- Example: {"action":"WEB_FETCH","params":{"url":"https://api.example.com/x"}}
- Example: {"action":"REPLY","params":{"text":"2 + 2 is 4."}}`;
}
/** The clean routing body — the live conversation, plainly stated. */
export function buildCleanRoutingBody(transcript: string): string {
const convo = transcript || "(no prior conversation)";
return `Conversation so far:
${convo}
Now output ONLY the compact JSON routing object for the single next action.`;
}
/**
* Framing prefix for the warm Agent SDK in TEXT mode (reply / large tiers).
*
* The SDK drives Claude Code, which is agentic by nature: fed eliza's flattened
* RESPONSE_HANDLER prompt — a message history with tool-calls + a stale "call a
* tool" instruction — opus CONTINUES it like an agent ("The user wants X. I'll
* fetch it...") instead of synthesizing the final reply from the tool RESULTS
* already present. The direct-API path doesn't do this because it isn't running
* the Claude Code agent loop. This prefix neutralizes that: it reframes the model
* as a pure completion engine that must emit ONLY the finished text and treat any
* tool results in the conversation as already-executed facts. General across all
* text tiers (reply, evaluator, summaries) — none of them want agentic preamble.
*/
export const TEXT_COMPLETION_FRAMING = `You are a text-generation engine, not an interactive agent. Output ONLY the final text the instructions below ask for — the finished content itself, in the requested voice and format, with no preamble and no sign-off about what you are doing. The conversation may include tool calls and their results that have ALREADY been executed; treat every such result as a given fact and use it directly. Never describe what you are about to do, never say you will fetch / check / look up / search for anything, never plan or call tools — that work is already done. Just write the final answer.`;
/** Prepend the text-completion framing to a (possibly empty) system prompt. */
export function frameTextSystemPrompt(system: string | undefined): string {
const base = system?.trim();
return base ? `${TEXT_COMPLETION_FRAMING}\n\n${base}` : TEXT_COMPLETION_FRAMING;
}
/**
* Closing directive appended to the END of a TEXT-mode body. The system framing
* alone is not reliably enough against eliza's large flattened prompt, whose LAST
* line is often a stale planner instruction ("Call at least one exposed
* non-terminal tool") that the agentic SDK model obeys ("I'll pull the data…").
* The model weights the final instruction most, so a closing directive that
* cancels any pending tool instruction and demands the finished answer is what
* reliably stops the narration leak (proven: with it, 4/4 correct + 0 narration +
* 0 empty across the live-shaped probe; without it, 2/4 + intermittent empties).
*/
export const TEXT_COMPLETION_DIRECTIVE = `
---
You are now writing the FINAL reply. Any tool calls shown above have ALREADY executed and their results are the authoritative source of truth — read the actual values out of those tool results and use them verbatim; do NOT invent, estimate, or guess a value when the tool result already contains it. Any earlier "do not answer from prior tool output / call a tool" policy applied only to the planning step, which is now COMPLETE — it no longer applies; there are NO tools left to call. Output ONLY the finished response now, in the agent's voice and the requested format. No preamble, no meta-commentary, no "I can't verify / let me pull a live quote", no description of what you are doing or about to do.`;
/** Append the text-completion directive to a TEXT-mode body. */
export function appendTextDirective(body: string): string {
return `${body}${TEXT_COMPLETION_DIRECTIVE}`;
}
/**
* STABLE system prompt for the NATIVE-TOOL router (warm Agent SDK, ROUTE mode).
*
* The model is given exactly ONE tool — `route_action({action, params})` — and
* must call it once per turn. This is the structural replacement for the
* free-text `{action,params}` text-planner: a real `tool_use` the SDK delivers
* to our in-process handler, so there is no JSON-from-prose parsing and no
* required-tool retry loop. Kept CONSTANT (per-turn variation — the action menu,
* transcript, persona — rides in the body) because the SDK freezes `systemPrompt`
* at session start, so a constant system keeps ONE warm process per model.
*/
export const ROUTER_SYSTEM_PROMPT = `You are the action router for an Eliza AI agent. For EVERY user message, call the route_action tool EXACTLY ONCE with the single best next action and its params, then stop — produce no plain-text answer.
How to choose:
- Pick a NON-TERMINAL action from the menu when the request needs one — especially WEB_FETCH / WEB_SEARCH for anything live, current, or external you cannot know from memory (prices, weather, news, scores, the contents of a URL or file), and the task/agent actions for builds and multi-step work.
- Pick REPLY (params: { text }) ONLY when you can answer correctly with no tool at all — ordinary chat, or static knowledge like arithmetic, definitions, or facts about this conversation. Put the COMPLETE user-facing answer, in the agent's voice, in params.text.
- Pick IGNORE (params: {}) when no response is appropriate.
- Use the exact action name from the menu, and fill params per that action's param hints (e.g. WEB_FETCH needs a concrete url). Never invent an action or a required param you have no grounds for.`;
/**
* Build the per-turn ROUTE-mode body: the persona (for REPLY text), the action
* menu with param hints, and the conversation — everything that varies per turn.
* Pairs with {@link ROUTER_SYSTEM_PROMPT} (the constant system slot).
*/
export function buildRouterBody(params: GenerateTextParams): string {
const tools = Array.isArray(params.tools) ? params.tools : [];
const actionMenu = renderActionMenu(tools);
const transcript = renderTranscript(params.messages) || "(no prior conversation)";
const voice = params.system?.trim()
? `Agent persona / voice (use it for any REPLY text):\n${params.system.trim()}\n\n`
: "";
return `${voice}Action menu (pick ONE by exact name; fill params per its hints):
${actionMenu}
Terminal actions (always available):
- REPLY — answer the user and end the turn. params: { text: string } (the full answer, in the agent's voice).
- IGNORE — say nothing. params: {}.
Conversation so far:
${transcript}
Call route_action now for the single next action.`;
}
/**
* Rewrite a grammar-heavy ACTION_PLANNER `GenerateTextParams` into the proven
* clean-routing form. Returns a fresh params object carrying only `system` +
* `prompt` (no `messages`, no `tools`) so `generateViaCli` / `flattenPrompt`
* forward exactly the clean prompt to the CLI and nothing else.
*/
export function buildCleanRoutingParams(params: GenerateTextParams): GenerateTextParams {
const tools = Array.isArray(params.tools) ? params.tools : [];
const actionMenu = renderActionMenu(tools);
const transcript = renderTranscript(params.messages);
// The loop sets `toolChoice: "required"` only while the turn's tool requirement
// is still unmet (planner-loop.ts:211); once a non-terminal tool has run it
// relaxes to "auto" so a terminal REPLY can synthesize the result. Carry that
// exact signal into the clean prompt so a live-info turn actually emits its
// tool instead of looping on terminal REPLY/IGNORE until the miss cap.
const mustCallTool = params.toolChoice === "required";
const system = buildCleanRoutingSystemPrompt(actionMenu, params.system, mustCallTool);
const prompt = buildCleanRoutingBody(transcript);
return {
system,
prompt,
// Drop `messages` and `tools` deliberately: the clean system+prompt is the
// entire instruction. Leaving `messages` would re-inject the grammar block
// (flattenPrompt appends every non-system message to the body).
};
}
@@ -0,0 +1,236 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { logger } from "@elizaos/core";
import {
__setSpawnForTests as __setClaudeSpawn,
type ClaudeGenerateParams,
defaultSpawn,
type SpawnFn,
type SpawnResult,
} from "./claude-cli";
import { flattenPrompt } from "./prompt-flatten";
import { filterEnv, redactStderr, resolveSafeBinary, resolveSafeCwd } from "./sandbox";
/**
* Codex CLI inference variant (TOS-clean SAFE/CLOUD route).
*
* Spawns the sanctioned `codex exec` binary, which reads its OWN OAuth creds
* from `~/.codex/auth.json`. As with the claude variant, eliza never sees or
* forwards the subscription token; the child env is `filterEnv`'d and stderr is
* redacted before logging. `codex exec` runs read-only in an isolated cwd.
*
* codex `exec` stdout is noisier than `claude --output-format text`: we request
* `--json` (JSONL events) and pull the LAST assistant message text, falling
* back to the raw trimmed stdout if no JSONL assistant event was emitted.
*/
const DEFAULT_MODEL = "gpt-5.5";
const DEFAULT_TIMEOUT_MS = 120_000;
const CODEX_BINARY = "codex";
// Reuse the spawner seam from claude-cli so tests can mock a single spawn fn.
// Defaults to the real `defaultSpawn` (mirrors the claude variant) so a
// production codex call works without any test seam being installed.
let spawnImpl: SpawnFn = defaultSpawn;
/** Test seam: swap the child-process spawner used by BOTH CLI variants. */
export function __setSpawnForTests(fn: SpawnFn): () => void {
const prev = spawnImpl;
spawnImpl = fn;
// Also route the claude variant through the same mock for a single seam.
const restoreClaude = __setClaudeSpawn(fn);
return () => {
spawnImpl = prev;
restoreClaude();
};
}
export interface CodexCliConfig {
model?: string;
timeoutMs?: number;
env?: NodeJS.ProcessEnv;
/**
* Pin the resolved binary path, skipping `resolveSafeBinary`. Used by unit
* tests and by container deploys that pin the CLI outside the default
* allowlist. When unset, the binary is resolved from PATH against the SOC2
* whitelist.
*/
binaryPath?: string;
}
export class CodexParseError extends Error {
constructor(message: string) {
super(message);
this.name = "CodexParseError";
}
}
/**
* Extract the assistant text from codex `--json` JSONL stdout. codex emits one
* JSON object per line; the assistant's answer arrives as an `agent_message` /
* `item.completed` (message) event, and a long answer can be split across
* several such events. We concatenate EVERY assistant fragment in emission
* order (truncating to the last one would drop split `<response>` blocks).
*
* Returns `{ ok: true, text }` when at least one assistant fragment was found,
* `{ ok: false, sawJson }` otherwise. `sawJson` is true when any line parsed as
* JSON — the caller falls back to raw stdout ONLY when zero lines were JSON
* (real banner-only output); if lines were JSON but none were assistant events,
* the caller throws rather than dumping the raw JSONL to the channel.
*/
function collectAssistantFragments(
stdout: string
): { ok: true; text: string } | { ok: false; sawJson: boolean } {
const lines = stdout.split(/\r?\n/);
const fragments: string[] = [];
let sawJson = false;
for (const line of lines) {
const trimmed = line.trim();
if (trimmed.length === 0 || trimmed[0] !== "{") continue;
let obj: unknown;
try {
obj = JSON.parse(trimmed);
} catch {
// error-policy:J3 stream parse — a non-JSON line in the codex NDJSON stream
// is skipped (not our record); if NO valid JSON is ever seen the caller
// throws (sawJson stays false). Not a swallowed data failure.
continue;
}
sawJson = true;
const text = extractAssistantText(obj);
if (text && text.trim().length > 0) fragments.push(text.trim());
}
if (fragments.length > 0) return { ok: true, text: fragments.join("\n") };
return { ok: false, sawJson };
}
/**
* Parse codex `--json` JSONL stdout into the assistant answer, concatenating
* all assistant fragments in order. Falls back to the raw trimmed stdout ONLY
* when no line was JSON at all (banner-only output). If lines parsed as JSON but
* none were assistant events, throws `CodexParseError` rather than leaking the
* raw JSONL to the channel.
*/
export function parseCodexJsonl(stdout: string): string {
const result = collectAssistantFragments(stdout);
if (result.ok) return result.text;
if (result.sawJson) {
throw new CodexParseError(
"[cli-inference] codex emitted JSONL events but no assistant message was found"
);
}
return stdout.trim();
}
function extractAssistantText(obj: unknown): string | undefined {
if (typeof obj !== "object" || obj === null) return undefined;
const record = obj as Record<string, unknown>;
// Newer codex: { type: "item.completed", item: { type: "message"|"agent_message", text } }
const item = record.item;
if (typeof item === "object" && item !== null) {
const itemRecord = item as Record<string, unknown>;
const itemType = itemRecord.type;
if (
itemType === "message" ||
itemType === "agent_message" ||
itemType === "assistant_message"
) {
const text = pickText(itemRecord);
if (text) return text;
}
}
// Flat shape: { type: "agent_message", message|text }
const type = record.type;
if (type === "agent_message" || type === "assistant_message" || type === "message") {
const text = pickText(record);
if (text) return text;
}
return undefined;
}
function pickText(record: Record<string, unknown>): string | undefined {
for (const key of ["text", "message", "content"]) {
const value = record[key];
if (typeof value === "string" && value.length > 0) return value;
}
return undefined;
}
export class CodexCli {
private readonly model: string;
private readonly timeoutMs: number;
private readonly env: NodeJS.ProcessEnv;
private readonly binaryPath?: string;
constructor(config: CodexCliConfig = {}) {
this.model = config.model ?? DEFAULT_MODEL;
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
this.env = config.env ?? process.env;
this.binaryPath = config.binaryPath;
}
async generate(params: ClaudeGenerateParams): Promise<string> {
const { system, body } = flattenPrompt(params);
// codex `exec` takes ONE positional prompt — fold the system block on top.
const prompt = system.trim().length > 0 ? `${system}\n\n${body}` : body;
const binary = this.binaryPath ?? resolveSafeBinary(CODEX_BINARY, this.env);
const rawCwd = await mkdtemp(join(tmpdir(), "eliza-cli-inference-"));
const cwd = resolveSafeCwd(rawCwd, [tmpdir()]);
try {
const argv = [
binary,
"exec",
"-m",
this.model,
"-s",
"read-only",
"--skip-git-repo-check",
"-C",
cwd,
"--color",
"never",
"--json",
prompt,
];
const result: SpawnResult = await spawnImpl(argv, {
cwd,
env: filterEnv(this.env),
timeoutMs: this.timeoutMs,
stdinPath: "/dev/null",
});
if (result.timedOut) {
throw new Error(
`[cli-inference] codex timed out after ${this.timeoutMs}ms (SIGTERM): ${redactStderr(result.stderr)}`
);
}
if (result.code !== 0) {
throw new Error(
`[cli-inference] codex exited ${result.code} signal=${result.signal}: ${redactStderr(result.stderr)}`
);
}
// parseCodexJsonl already handles the banner-only fallback and throws
// (rather than dumping raw JSONL) when JSON events were present but none
// were assistant messages.
const text = parseCodexJsonl(result.stdout);
if (text.length === 0) {
throw new Error(
`[cli-inference] codex returned empty stdout: ${redactStderr(result.stderr)}`
);
}
return text;
} finally {
// error-policy:J6 best-effort teardown of the isolated cwd; a cleanup
// failure is logged at debug and must not mask the returned result / error.
await rm(rawCwd, { recursive: true, force: true }).catch((err) => {
logger.debug(`[cli-inference] failed to clean isolated cwd ${rawCwd}: ${String(err)}`);
});
}
}
}
@@ -0,0 +1,307 @@
/**
* Warm Codex SDK inference session — the codex peer of {@link ClaudeSdkSession}.
*
* Runs an Eliza chat brain (chat + planner) on a personal ChatGPT/Codex
* subscription via `@openai/codex-sdk`, which wraps the bundled `codex` binary
* and reads its own `~/.codex/auth.json` (eliza never sees the token). Unlike
* `codex exec` (CodexCli), which cold-spawns a fresh process per call, this keeps
* ONE warm `Thread` alive (`codex.startThread()` once; `thread.run()` per turn),
* so the startup cost is paid once.
*
* TWO MODES:
* - TEXT mode (`generate`): pure completion for the reply / large tiers. The
* thread runs read-only, no network, no approvals — a warm completion engine.
* Returns the turn's `finalResponse`.
* - ROUTE mode (`route`): the ACTION_PLANNER decision via codex NATIVE structured
* output (`TurnOptions.outputSchema`). The schema constrains the turn to
* `{action, params}` with `params` as a JSON STRING (OpenAI strict mode forbids
* open-ended objects), which `normalizeRoute` parses back into the bare
* `{action, params}` shape the planner loop's text-mode parser accepts. This
* is reliable at scale (the model cannot drift off-shape) — unlike free-text
* JSON. REQUIRES the system codex binary (`codexBinPath`): the SDK's bundled
* binary is too old and rejects structured output ("requires a newer version").
*
* codex-sdk has NO thread-level system prompt (ThreadOptions carries none), so
* the system content is folded into the body per call — which also means ONE warm
* thread per (model, mode) can serve every system prompt (no per-systemPrompt
* keying needed, unlike claude). Calls are SERIALIZED; the session self-heals on
* error and restarts after `restartAfterTurns` to bound the thread's accumulating
* context.
*
* LIVE-VERIFIED on a ChatGPT/Codex subscription: btc/eth/weather route to
* WEB_FETCH and synthesize the real fetched value; math/chat/identity work. Needs
* the system codex binary via `codexBinPath` (the SDK's bundled 0.80.0 rejects
* gpt-5.5). Unit-tested via the injectable `codexModule` seam.
*
* @module plugin-cli-inference/codex-sdk-session
*/
import { logger } from "@elizaos/core";
import type { RotationSubprocessEnv } from "./account-rotation";
const DEFAULT_MODEL = "gpt-5.5";
const DEFAULT_RESTART_AFTER_TURNS = 20;
/** The model's captured routing decision (ROUTE mode). */
export interface CodexRouteDecision {
action: string;
params: Record<string, unknown>;
}
/**
* Output schema constraining ROUTE-mode output to `{action, params}` where
* `params` is a JSON STRING. OpenAI strict structured-output forbids open-ended
* objects (every nested object must declare all properties + additionalProperties:
* false), so an arbitrary params object is impossible — encoding params as a JSON
* string sidesteps that while still guaranteeing the shape. Requires the system
* codex binary (the SDK's bundled one rejects it / the model).
*/
const ROUTE_OUTPUT_SCHEMA = {
type: "object",
additionalProperties: false,
required: ["action", "params"],
properties: {
action: { type: "string" },
params: { type: "string", description: "JSON-encoded params object" },
},
} as const;
interface CodexTurn {
items?: Array<{ type?: string; text?: string }>;
finalResponse?: string;
usage?: unknown;
}
interface CodexThread {
run(input: string, turnOptions?: { outputSchema?: unknown }): Promise<CodexTurn>;
}
interface CodexInstance {
startThread(options?: Record<string, unknown>): CodexThread;
}
/** Minimal shape of the `@openai/codex-sdk` module we load lazily. */
export interface CodexModule {
Codex: new (options?: Record<string, unknown>) => CodexInstance;
}
export interface CodexSdkSessionConfig {
model?: string | null;
/** ROUTE mode (free-text `{action,params}` JSON) vs TEXT mode (plain completion). */
router?: boolean;
/** `modelReasoningEffort` for the thread (minimal|low|medium|high|xhigh). */
reasoningEffort?: string | null;
/**
* Path to the codex binary the SDK should drive (`codexPathOverride`). The SDK
* BUNDLES its own (often older) codex under `vendor/`; that bundled binary
* rejects newer models with "requires a newer version of Codex". Point this at
* the installed system codex (e.g. `~/.local/bin/codex`) so current models like
* gpt-5.5 work.
*/
codexBinPath?: string | null;
/** Restart the warm thread after this many turns (bounds context growth). */
restartAfterTurns?: number;
/**
* Optional subprocess-only env for a pooled account. Passed to the Codex SDK
* constructor; never written to the parent process env.
*/
subprocessEnv?: RotationSubprocessEnv | null;
/** Injected for tests; defaults to the real SDK. */
codexModule?: CodexModule;
}
const SDK_PACKAGE = "@openai/codex-sdk";
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isCodexModule(value: unknown): value is CodexModule {
return isRecord(value) && typeof value.Codex === "function";
}
async function loadCodex(): Promise<CodexModule> {
const codex: unknown = await import(SDK_PACKAGE);
if (!isCodexModule(codex)) {
throw new Error("[cli-inference:codex-sdk] Codex SDK module has an unexpected shape");
}
return codex;
}
/** Pull the assistant text out of a completed codex turn. */
function turnToText(turn: CodexTurn): string {
if (typeof turn.finalResponse === "string" && turn.finalResponse.trim()) {
return turn.finalResponse.trim();
}
// Fallback: the last agent_message item's text.
for (const item of [...(turn.items ?? [])].reverse()) {
if (item.type === "agent_message" && typeof item.text === "string" && item.text.trim()) {
return item.text.trim();
}
}
return "";
}
/**
* A single warm Codex SDK thread for one (model, mode). Lazily starts on first
* call, serializes calls, and self-heals (restarts) on error or after
* `restartAfterTurns`.
*/
export class CodexSdkSession {
private readonly model: string;
private readonly router: boolean;
private readonly reasoningEffort: string | null;
private readonly codexBinPath: string | null;
private readonly restartAfterTurns: number;
private readonly subprocessEnv: RotationSubprocessEnv | null;
private readonly codexOverride?: CodexModule;
private thread: CodexThread | null = null;
private turns = 0;
private chain: Promise<unknown> = Promise.resolve();
constructor(config: CodexSdkSessionConfig) {
this.model = config.model?.trim() || DEFAULT_MODEL;
this.router = config.router === true;
this.reasoningEffort = config.reasoningEffort?.trim() || null;
this.codexBinPath = config.codexBinPath?.trim() || null;
this.restartAfterTurns =
config.restartAfterTurns && config.restartAfterTurns > 0
? config.restartAfterTurns
: DEFAULT_RESTART_AFTER_TURNS;
this.subprocessEnv = config.subprocessEnv ?? null;
this.codexOverride = config.codexModule;
}
/** TEXT mode: generate one completion's text. Serialized. */
generate(body: string): Promise<string> {
return this.enqueue(() => this.sendOnce(body, "text"));
}
/**
* ROUTE mode: return `JSON.stringify({action, params})` — the action the model
* picked via codex's native structured output. Consumed directly by the planner
* loop's text-mode parser, so no core change is needed.
*/
route(body: string): Promise<string> {
return this.enqueue(() => this.sendOnce(body, "route"));
}
private enqueue<T>(fn: () => Promise<T>): Promise<T> {
const run = this.chain.then(fn, fn);
// error-policy:J5 the chain tail only serializes turns; the REAL result/error
// is returned to the caller via `run`. Swallowing here just stops a settled
// tail from raising an unhandled rejection — the caller still sees the error.
this.chain = run.catch(() => undefined);
return run;
}
private async sendOnce(body: string, mode: "text" | "route"): Promise<string> {
if (!body.trim()) {
throw new Error("[cli-inference:codex-sdk] empty prompt body");
}
if (this.thread && this.turns >= this.restartAfterTurns) {
this.dispose();
}
if (!this.thread) {
await this.start();
}
this.turns += 1;
try {
const thread = this.thread;
if (!thread) throw new Error("[cli-inference:codex-sdk] thread not started");
// ROUTE: constrain output to {action, params:json-string} via the codex
// native output schema (reliable shape; needs the system codex binary).
const turn = await thread.run(
body,
mode === "route" ? { outputSchema: ROUTE_OUTPUT_SCHEMA } : undefined
);
const text = turnToText(turn);
if (mode === "route") {
return this.normalizeRoute(text);
}
if (!text) {
throw new Error("[cli-inference:codex-sdk] empty completion");
}
return text;
} catch (err) {
// error-policy:J2 context-adding rethrow — self-heal (a dead/erroring thread
// must not poison the next turn), then rethrow so the caller sees the failure.
this.dispose();
throw err instanceof Error ? err : new Error(`[cli-inference:codex-sdk] ${String(err)}`);
}
}
/** Coerce the structured-output JSON into a bare {action, params} string. */
private normalizeRoute(text: string): string {
if (!text) {
throw new Error("[cli-inference:codex-sdk] route: empty structured output");
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch {
// error-policy:J3 untrusted model output — structured output SHOULD be valid
// JSON; if wrapped, salvage the first {...} block, else throw a typed
// "non-JSON output" (does not fabricate a valid route).
const match = text.match(/\{[\s\S]*\}/);
if (!match) {
throw new Error("[cli-inference:codex-sdk] route: non-JSON output");
}
parsed = JSON.parse(match[0]);
}
const obj = parsed as { action?: unknown; params?: unknown };
if (typeof obj.action !== "string" || !obj.action.trim()) {
throw new Error("[cli-inference:codex-sdk] route: missing action");
}
// `params` arrives as a JSON STRING (ROUTE_OUTPUT_SCHEMA encodes it that way
// for strict-mode), or already as an object on the free-text fallback path.
let params: Record<string, unknown> = {};
if (typeof obj.params === "string" && obj.params.trim()) {
try {
const p = JSON.parse(obj.params);
if (p && typeof p === "object") params = p as Record<string, unknown>;
} catch {
// error-policy:J3 untrusted model output — a malformed `params` JSON
// string degrades to {} (a valid "no params" route arg), the action
// itself is already validated above; not a swallowed required-data failure.
}
} else if (obj.params && typeof obj.params === "object") {
params = obj.params as Record<string, unknown>;
}
return JSON.stringify({
action: obj.action.trim(),
params,
});
}
private async start(): Promise<void> {
const { Codex } = this.codexOverride ?? (await loadCodex());
// Drive the system codex binary (not the SDK's bundled-and-often-stale one)
// when a path is configured, so current models work.
const codexOptions: Record<string, unknown> = {};
if (this.codexBinPath) codexOptions.codexPathOverride = this.codexBinPath;
if (this.subprocessEnv) codexOptions.env = this.subprocessEnv;
const codex = new Codex(codexOptions);
// Pure inference: read-only, no network, no approvals, no git-repo coupling —
// a warm completion engine, not a coding agent.
const options: Record<string, unknown> = {
model: this.model,
sandboxMode: "read-only",
approvalPolicy: "never",
networkAccessEnabled: false,
webSearchEnabled: false,
skipGitRepoCheck: true,
};
if (this.reasoningEffort) options.modelReasoningEffort = this.reasoningEffort;
this.thread = codex.startThread(options);
this.turns = 0;
logger.debug(
{ src: "cli-inference:codex-sdk", model: this.model, mode: this.router ? "route" : "text" },
"warm Codex SDK thread started"
);
}
/** Tear down the warm thread (on restart, error, or dispose). */
dispose(): void {
this.thread = null;
this.turns = 0;
}
}
@@ -0,0 +1,142 @@
import type { ChatMessage, ChatMessageContentPart } from "@elizaos/core";
/**
* Flatten `GenerateTextParams` (system + messages/prompt) into the two strings
* the sanctioned CLIs consume:
*
* - `system` → claude `--system-prompt` (full replace) / codex top instructions block.
* - `body` → claude `-p <body>` / codex `exec <body>` positional prompt.
*
* HARD REQ: both `params.system` AND `params.messages`/`params.prompt` must be
* forwarded. Dropping `messages` would strip skills/memory/recent-conversation/
* the `<response>` grammar that the runtime composes into the message array, so
* the model would answer blind. System/developer-role messages are re-routed to
* the system slot (joined with an explicit `params.system`); every other role is
* flattened, in order, into the body. Nothing is dropped.
*/
export interface FlattenedPrompt {
/** Goes to claude `--system-prompt` / codex instructions block. */
system: string;
/** Goes to claude `-p` / codex `exec` positional prompt. */
body: string;
}
/** Pull readable text out of a tool-result part's `output` (shape varies by
* provider: `{type:"text",value}`, a bare string, or an array of such parts). */
function toolOutputToText(output: unknown): string {
if (output == null) return "";
if (typeof output === "string") return output;
if (Array.isArray(output)) return output.map(toolOutputToText).filter(Boolean).join("\n");
if (typeof output === "object") {
const o = output as { value?: unknown; text?: unknown; content?: unknown };
if (typeof o.value === "string") return o.value;
if (typeof o.text === "string") return o.text;
if (o.content != null) return toolOutputToText(o.content);
return JSON.stringify(output);
}
return String(output);
}
/**
* Flatten a message's content into text, surfacing tool-call / tool-result parts
* (not just plain text). Canonical implementation — the clean-routing planner
* imports this so the two paths can't drift (a divergent copy that dropped tool
* results once caused the planner to hallucinate live-info answers).
*/
export function contentToText(content: ChatMessage["content"]): string {
if (content == null) return "";
if (typeof content === "string") return content;
if (!Array.isArray(content)) return "";
return content
.map((part: ChatMessageContentPart) => {
// Plain text part.
if (part.type === "text" && typeof part.text === "string") return part.text;
// Tool call / result carried INSIDE the content array (not on
// `message.toolCalls`). Eliza threads WEB_FETCH/etc. call+result this way;
// dropping them (the old behavior) blinded the SDK synthesis to every tool
// output, so the model fell back to its prior and hallucinated. Surface
// both so the flattened transcript carries the actual fetched data.
const p = part as {
type?: string;
toolName?: string;
input?: unknown;
output?: unknown;
};
if (p.type === "tool-call" || p.type === "tool_call") {
const args = typeof p.input === "string" ? p.input : JSON.stringify(p.input ?? {});
return `[tool_call ${p.toolName ?? "tool"} ${args}]`;
}
if (p.type === "tool-result" || p.type === "tool_result") {
const out = toolOutputToText(p.output);
return out ? `[tool_result ${p.toolName ?? "tool"}: ${out}]` : "";
}
return "";
})
.filter(Boolean)
.join("\n");
}
/** Render one non-system message as a labeled transcript block. */
function renderMessage(message: ChatMessage): string {
const text = contentToText(message.content);
// Surface assistant tool calls so a multi-turn transcript keeps the call/
// result pairing visible to the CLI model (it has no native tool-call slot
// here — everything is flattened text).
const toolCallLines =
message.role === "assistant" && message.toolCalls?.length
? message.toolCalls.map((call) => {
const args =
typeof call.arguments === "string"
? call.arguments
: JSON.stringify(call.arguments ?? {});
return `[tool_call ${call.name} ${args}]`;
})
: [];
const label =
message.role === "assistant" ? "Assistant" : message.role === "tool" ? "Tool result" : "User";
const lines = [text, ...toolCallLines].filter((line) => line.trim().length > 0);
if (lines.length === 0) return "";
return `${label}: ${lines.join("\n")}`;
}
export function flattenPrompt(params: {
system?: string;
prompt?: string;
messages?: ChatMessage[];
}): FlattenedPrompt {
const systemParts: string[] = [];
if (params.system && params.system.trim().length > 0) {
systemParts.push(params.system);
}
const bodyParts: string[] = [];
let lastBodyText = "";
for (const message of params.messages ?? []) {
if (message.role === "system" || message.role === "developer") {
const text = contentToText(message.content);
if (text.trim().length > 0) systemParts.push(text);
continue;
}
const rendered = renderMessage(message);
if (rendered.length > 0) {
bodyParts.push(rendered);
lastBodyText = contentToText(message.content);
}
}
// The legacy `prompt` string is appended only when it isn't already the tail
// of the message transcript (callers that pass `messages` usually leave it
// empty, but some still set both — avoid duplicating it).
if (params.prompt && params.prompt.trim().length > 0 && params.prompt !== lastBodyText) {
bodyParts.push(params.prompt);
}
return {
system: systemParts.join("\n\n"),
body: bodyParts.join("\n\n"),
};
}
@@ -0,0 +1,61 @@
/**
* Typed upstream-provider failure plus retryability classification, shared by the
* CLI and SDK handlers. `ProviderApiError` carries the upstream status so
* `useModel` / AccountPool failover classify 429/529/5xx as retryable.
* `parseProviderApiErrorText` recognizes the SDK's own streamed error envelope
* ("API Error: <status> …") that Claude Code emits as assistant text, so a leaked
* error string is thrown to failover instead of relayed to the user as a reply.
*/
export class ProviderApiError extends Error {
readonly statusCode?: number;
readonly retryable: boolean;
constructor(
message: string,
options: { statusCode?: number; retryable?: boolean; cause?: unknown } = {}
) {
super(message);
this.name = "ProviderApiError";
if (options.cause !== undefined) {
(this as Error & { cause?: unknown }).cause = options.cause;
}
this.statusCode = options.statusCode;
this.retryable = options.retryable ?? isRetryableProviderStatus(options.statusCode, message);
}
}
export function isRetryableProviderStatus(statusCode: number | undefined, message = ""): boolean {
if (statusCode === 429 || statusCode === 529) return true;
if (statusCode !== undefined && [500, 502, 503, 504].includes(statusCode)) return true;
const haystack = message.toLowerCase();
return (
haystack.includes("overloaded") ||
haystack.includes("rate limit") ||
haystack.includes("too many requests") ||
haystack.includes("temporarily unavailable") ||
haystack.includes("service unavailable") ||
haystack.includes("timeout") ||
haystack.includes("timed out")
);
}
export function parseProviderApiErrorText(
text: string
): { statusCode?: number; message: string } | null {
const trimmed = text.trim();
// The SDK also emits NON-numeric envelopes ("API Error: Request was
// aborted.", "API Error: Usage credits required for 1M context · …") — the
// CLI's own detector anchors on `startsWith("API Error")`, so match any
// "API Error"-prefixed envelope and parse the 3-digit status when present.
if (!/^API Error(:|$)/i.test(trimmed)) return null;
const status = /^API Error:\s*(\d{3})\b/i.exec(trimmed);
return {
statusCode: status ? Number.parseInt(status[1], 10) : undefined,
message: trimmed,
};
}
export function isProviderApiErrorText(text: string): boolean {
return parseProviderApiErrorText(text) !== null;
}
+238
View File
@@ -0,0 +1,238 @@
/**
* SOC2 sandbox helpers, copied from
* `packages/plugin-remote-manifest/src/sub-agent-claude-code/sandbox.ts` so
* this node-only model-provider plugin does not depend on the worker-bundled
* remote plugin.
*
* Keep in sync with the upstream copy if `SAFE_ENV_KEYS` / `SENSITIVE_ENV_RE`
* change. Additions here over the upstream copy: the `SENSITIVE_ENV_RE` export
* plus `redactSensitive` / `redactStderr`, used by the CLI handlers to redact
* subprocess stderr before logging.
*
* - `filterEnv` — explicit allowlist + token blocklist for child env.
* - `resolveSafeCwd` — realpath validation; cwd must be under workspace or /tmp.
* - `resolveSafeBinary` — PATH lookup constrained to an allowlisted launcher dir.
* - `redactSensitive` — strips key-named + value-shaped secrets from stderr.
* - `redactStderr` — `redactSensitive` + a length cap for error messages / logs.
*/
import { existsSync, realpathSync, statSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { delimiter, dirname, isAbsolute, resolve, sep } from "node:path";
/** Env keys that may be forwarded to a sub-agent verbatim. */
const SAFE_ENV_KEYS: ReadonlySet<string> = new Set([
"PATH",
"HOME",
"TMPDIR",
"LANG",
"LC_ALL",
"SHELL",
"TERM",
"USER",
"LOGNAME",
]);
/**
* Defensive blocklist applied to BOTH the inherited env subset (after
* allowlist) and `extraEnv`. If a key matches this regex it is dropped.
*/
export const SENSITIVE_ENV_RE =
/(TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL|DATABASE_URL|WALLET|PRIVATE|MNEMONIC|API_KEY)/i;
/** Whitelisted absolute directories that may host the sub-agent binary. */
const BINARY_DIR_ALLOWLIST: readonly string[] = [
"/usr/local/bin",
"/usr/bin",
"/opt/homebrew/bin",
`${homedir()}/.local/bin`,
`${homedir()}/.bun/bin`,
`${homedir()}/.cargo/bin`,
];
export function filterEnv(
source: NodeJS.ProcessEnv,
allow: ReadonlySet<string> = SAFE_ENV_KEYS,
extra: Record<string, string | undefined> = {}
): Record<string, string> {
const out: Record<string, string> = {};
for (const key of allow) {
const value = source[key];
if (value === undefined) continue;
if (SENSITIVE_ENV_RE.test(key)) continue;
out[key] = value;
}
for (const [key, value] of Object.entries(extra)) {
if (value === undefined) continue;
if (SENSITIVE_ENV_RE.test(key)) {
throw new Error(`Refusing to forward sensitive env var to sub-agent: ${key}`);
}
out[key] = value;
}
return out;
}
class SubAgentCwdError extends Error {
constructor(message: string) {
super(message);
this.name = "SubAgentCwdError";
}
}
/**
* Resolve `cwd` to its realpath and require it to live under one of
* the supplied workspace roots OR `/tmp`. Symlink escapes are rejected.
*/
export function resolveSafeCwd(cwd: string, workspaceRoots: readonly string[]): string {
if (!cwd || typeof cwd !== "string") {
throw new SubAgentCwdError("cwd is required");
}
if (!isAbsolute(cwd)) {
throw new SubAgentCwdError(`cwd must be absolute: ${cwd}`);
}
if (!existsSync(cwd) || !statSync(cwd).isDirectory()) {
throw new SubAgentCwdError(`cwd does not exist or is not a directory: ${cwd}`);
}
const real = realpathSync(cwd);
const tmpReal = realpathSync(tmpdir());
const candidates = [...workspaceRoots, tmpReal]
.filter((root): root is string => typeof root === "string" && root.length > 0)
.map((root) => {
try {
return realpathSync(root);
} catch {
// error-policy:J3 path canonicalization — a workspace root that does not
// resolve (not yet created) falls back to its absolute form; the caller
// still enforces the containment check below, so this never widens the
// allow-set to an unverified path.
return resolve(root);
}
});
for (const root of candidates) {
if (real === root || real.startsWith(root + sep)) return real;
}
throw new SubAgentCwdError(
`cwd ${real} is not under any allowed workspace root (${candidates.join(", ")})`
);
}
class SubAgentBinaryError extends Error {
constructor(message: string) {
super(message);
this.name = "SubAgentBinaryError";
}
}
/**
* Resolve `binary` (name or path) to an absolute path under the binary
* whitelist. PATH lookup uses `which` semantics constrained to safe dirs.
*/
export function resolveSafeBinary(binary: string, env: NodeJS.ProcessEnv = process.env): string {
if (!binary) throw new SubAgentBinaryError("binary is required");
const candidateDirs = BINARY_DIR_ALLOWLIST.slice();
// We validate the LAUNCHER path (the entry the agent will exec) against the
// allowlist, NOT its realpath target. Standard installs symlink the launcher
// from an allowlisted dir (e.g. ~/.local/bin/claude, ~/.local/bin/codex) into
// a versioned/node_modules location that is intentionally outside the
// allowlist. Trust is established by the launcher living in an allowlisted
// dir; following the symlink to its target and re-checking that against the
// allowlist would reject every real install. We still keep the symlink target
// (`real`) so the spawned argv is stable across version bumps.
let launcher: string | null = null;
if (isAbsolute(binary)) {
// An absolute path is only trusted if its own directory is allowlisted.
if (existsSync(binary)) {
const dir = dirname(binary);
if (candidateDirs.some((d) => dir === d || dir.startsWith(d + sep))) {
launcher = binary;
}
}
} else if (binary.includes("/")) {
throw new SubAgentBinaryError(`binary must be absolute or a bare name: ${binary}`);
} else {
// `which`-style lookup, restricted to whitelisted dirs only.
const pathDirs = (env.PATH ?? "").split(delimiter);
for (const dir of pathDirs) {
const real = (() => {
try {
return realpathSync(dir);
} catch {
// error-policy:J3 PATH-dir canonicalization — a non-resolvable PATH
// entry keeps its raw form; it is then matched against the whitelist
// (candidateDirs) so an unverified dir cannot slip the allow-list.
return dir;
}
})();
if (!candidateDirs.includes(real)) continue;
const guess = `${real}${sep}${binary}`;
if (existsSync(guess)) {
launcher = guess;
break;
}
}
// Last-resort: scan whitelist directly.
if (!launcher) {
for (const dir of candidateDirs) {
const guess = `${dir}${sep}${binary}`;
if (existsSync(guess)) {
launcher = guess;
break;
}
}
}
}
if (!launcher || !existsSync(launcher)) {
throw new SubAgentBinaryError(
`Could not resolve ${binary} under any whitelisted dir: ${candidateDirs.join(", ")}`
);
}
// The launcher lives in an allowlisted dir (validated above). Resolve it to
// its realpath for a stable argv, but do NOT re-check the target against the
// allowlist — symlinking out of the allowlist is exactly how the real
// claude/codex installs are laid out.
return realpathSync(launcher);
}
/**
* Value-shaped secret patterns. `SENSITIVE_ENV_RE` only catches lines that
* mention a sensitive KEY name (e.g. `ANTHROPIC_API_KEY=...`); these catch the
* raw secret VALUE even when it appears with no key (a stack trace, a curl
* echo, a leaked header). Applied to subprocess stderr before logging.
*/
const SENSITIVE_VALUE_RES: readonly RegExp[] = [
/sk-ant-[A-Za-z0-9_-]{8,}/g, // Anthropic API keys
/sk-proj-[A-Za-z0-9_-]{8,}/g, // OpenAI project keys
/sk-[A-Za-z0-9_-]{8,}/g, // generic OpenAI-style keys
/oat[_-][A-Za-z0-9_-]{8,}/gi, // OAuth access tokens (oat_...)
/eyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]+/g, // JWTs (header.payload.sig)
/gh[pousr]_[A-Za-z0-9]{8,}/g, // GitHub tokens (ghp_/gho_/ghu_/ghs_/ghr_)
];
const SECRET_PLACEHOLDER = "[REDACTED]";
/**
* Redact secrets out of arbitrary text (subprocess stderr) before logging:
* drop any line matching a sensitive KEY name, then mask value-shaped secrets
* (sk-ant-*, sk-proj-*, sk-*, oat*, JWTs, gh*_*) anywhere in the remaining
* lines.
*/
export function redactSensitive(text: string): string {
const lines = text.split(/\r?\n/).filter((line) => !SENSITIVE_ENV_RE.test(line));
let out = lines.join("\n");
for (const re of SENSITIVE_VALUE_RES) {
out = out.replace(re, SECRET_PLACEHOLDER);
}
return out;
}
/**
* Redact anything resembling a secret out of subprocess stderr before logging,
* then cap the length so a noisy CLI cannot blow up an error message / log line.
*/
export function redactStderr(stderr: string): string {
return redactSensitive(stderr).slice(0, 2000);
}
@@ -0,0 +1,32 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": true,
"ignoreDeprecations": "6.0",
"noEmit": false,
"paths": {
"@elizaos/core": [
"../../../packages/core/dist"
]
},
"types": [
"node",
"bun-types"
]
},
"include": [
"index.ts",
"index.node.ts",
"index.browser.ts"
],
"exclude": [
"node_modules",
"dist",
"__tests__",
"**/*.test.ts",
"build.ts"
]
}
@@ -0,0 +1,46 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": [
"ES2022",
"DOM"
],
"strict": true,
"strictNullChecks": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": false,
"allowImportingTsExtensions": true,
"types": [
"node",
"bun-types"
],
"paths": {
"@elizaos/core": [
"../../../packages/core/dist/node"
]
}
},
"include": [
"./**/*.ts"
],
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.e2e.test.ts",
"build.ts"
]
}
@@ -0,0 +1,25 @@
/**
* Vitest config for the unit suite (mock spawn / fake SDK — no real CLI process
* or live model). Aliases `@elizaos/core` to its built node bundle and excludes
* the live/real lanes.
*/
import path from "node:path";
import { defineConfig } from "vitest/config";
const elizaRoot = path.resolve(import.meta.dirname, "../..");
export default defineConfig({
resolve: {
alias: [
{
find: /^@elizaos\/core$/,
replacement: path.join(elizaRoot, "packages", "core", "dist", "node", "index.node.js"),
},
],
},
test: {
environment: "node",
include: ["__tests__/**/*.test.ts", "src/**/*.test.ts"],
exclude: ["**/node_modules/**", "**/dist/**", "**/*.live.test.ts", "**/*.real.test.ts"],
},
});