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
+190
View File
@@ -0,0 +1,190 @@
# @elizaos/plugin-coding-tools
Native Claude-Code-style coding tools (FILE, SHELL, WORKTREE) for Eliza agents running in code/terminal/automation contexts.
## Purpose / role
Adds filesystem operations, shell command execution, and git worktree management to an Eliza agent. The plugin is **opt-in**: it auto-enables when `config.features.codingTools` (or legacy `config.features["coding-agent"]`) is truthy and the runtime environment supports a terminal (disabled on `ELIZA_BUILD_VARIANT=store` and on iOS; Android only when `ELIZA_RUNTIME_MODE=local-yolo`). All actions are gated to `contexts: ["code", "terminal", "automation"]`. FILE and WORKTREE require `roleGate: minRole=ADMIN`; SHELL requires `roleGate: minRole=OWNER`.
## Plugin surface
### Actions
- **FILE** — umbrella for `read/write/edit/grep/glob/ls`. Dispatches to per-operation handlers. Supports `target=device` for `read/write/ls` through a `device_filesystem` bridge service (mobile). Similes: `FILE_OPERATION`, `FILE_IO`.
- **SHELL** — `action=run` executes a command via `/bin/bash -c`; `action=view_history`/`clear_history` read or clear per-conversation command history (backed by an external `shell` service when present). Per-call `timeout` (ms) is clamped to `[100, 600000]`, default `CODING_TOOLS_SHELL_TIMEOUT_MS` (120000). Similes: `BASH`, `EXEC`, `RUN_COMMAND`.
- **WORKTREE** — umbrella for `enter/exit` git worktrees. On enter, registers new root in `SandboxService` and pushes to `SessionCwdService` stack. On exit, pops. Similes: `GIT_WORKTREE`.
### Provider
- **AVAILABLE_CODING_TOOLS** — injects the list of available tool names (`FILE`, `SHELL`, `WORKTREE`) into agent state at position `-10`. Stable/agent-scoped cache.
### Services
| Service | `serviceType` constant | Purpose |
|---|---|---|
| `SandboxService` | `CODING_TOOLS_SANDBOX` | Path-blocklist policy. Validates every path before read/write. Defaults block `~/pvt`, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.docker`, `~/.kube`, `~/.netrc`, `~/Library`, plus per-OS system paths. Optional allow-roots via `CODING_TOOLS_WORKSPACE_ROOTS`. |
| `FileStateService` | `CODING_TOOLS_FILE_STATE` | Per-(conversation, file) mtime tracking. Write/Edit check that the file was not externally modified since the last Read. |
| `SessionCwdService` | `CODING_TOOLS_SESSION_CWD` | Per-conversation working directory. Defaults to `process.cwd()`. Glob/Grep/LS/Shell use this when no explicit `path`/`cwd` is given. Worktree push/pop mutates it. |
| `RipgrepService` | `CODING_TOOLS_RIPGREP` | Wraps `@vscode/ripgrep` binary. Used by `grep` operation. Always excludes VCS dirs. 30 s hard cap. |
### Other exports
- `CodingTaskExecutor` — task executor that maps task specs matching coding-related keywords into agent actions. Consumed externally by orchestration plugins.
- `coding-agent-context` (Zod schemas) — `FileOperationSchema`, `CommandResultSchema`, `CapturedErrorSchema`, etc. Used to validate structured outputs from coding loops.
## Layout
```
plugins/plugin-coding-tools/
src/
index.ts Plugin entry — exports codingToolsPlugin, all services, types
types.ts Service-type constants, ToolFailure/ToolResult types, CODING_TOOLS_CONTEXTS
actions/
file.ts FILE umbrella action — routes to per-op handlers
bash.ts SHELL action implementation
worktree.ts WORKTREE umbrella action
read.ts / write.ts / edit.ts FILE sub-handlers for read/write/edit
grep.ts / glob.ts / ls.ts FILE sub-handlers for grep/glob/ls
enter-worktree.ts / exit-worktree.ts WORKTREE sub-handlers
index.ts Re-exports all action handlers
providers/
available-tools.ts AVAILABLE_CODING_TOOLS provider
services/
sandbox-service.ts Path policy (blocklist + allow-roots)
file-state-service.ts Per-conversation file mtime tracking
session-cwd-service.ts Per-conversation working directory + worktree stack
ripgrep-service.ts @vscode/ripgrep wrapper
coding-task-executor.ts CodingTaskExecutor (external task delegation)
coding-agent-context.ts Zod schemas for coding-agent context types
index.ts Re-exports all services
lib/
format.ts Param readers (readStringParam, readNumberParam), successActionResult, failureToActionResult
path-utils.ts isAbsolutePath, isWithin, resolveRealPath, isUncPath
run-shell.ts runShell helper (child_process wrapper with timeout/streaming)
run-git-command.ts runGitCommand helper
terminal-capabilities.ts Platform capability detection
secrets.ts detectSecrets — flags AWS/GitHub/OpenAI/etc. tokens to gate WRITE/EDIT
auto-enable.ts Lightweight auto-enable module (env reads only; no plugin runtime imports)
AGENT_CONTRACT.md Implementation brief for action-writing agents
build.ts build script (Bun.build + tsc d.ts emit)
```
## Commands
Scripts are defined in `package.json`; run them from the repo root with `bun run --cwd`:
```bash
bun run --cwd plugins/plugin-coding-tools clean # remove build output
bun run --cwd plugins/plugin-coding-tools build # build package artifacts
bun run --cwd plugins/plugin-coding-tools dev # development build/watch lane
bun run --cwd plugins/plugin-coding-tools typecheck # TypeScript typecheck
bun run --cwd plugins/plugin-coding-tools check # package check alias
bun run --cwd plugins/plugin-coding-tools lint # mutating Biome check
bun run --cwd plugins/plugin-coding-tools lint:check # read-only Biome check
bun run --cwd plugins/plugin-coding-tools format # write formatting
bun run --cwd plugins/plugin-coding-tools format:check # read-only formatting check
bun run --cwd plugins/plugin-coding-tools test # run package tests
```
## Config / env vars
All settings are read via `runtime.getSetting(key)` or `process.env`. None are required.
| Env var | Default | Description |
|---|---|---|
| `CODING_TOOLS_WORKSPACE_ROOTS` | `process.cwd()` | Comma-separated absolute paths the tools may access. When set, paths outside these roots are rejected. |
| `CODING_TOOLS_BLOCKED_PATHS` | (built-in list) | Comma-separated absolute paths — **replaces** the default blocklist. |
| `CODING_TOOLS_BLOCKED_PATHS_ADD` | — | Comma-separated paths to **add** to the default blocklist. |
| `CODING_TOOLS_SHELL` | (auto-detected) | Override the shell binary used by SHELL action. Takes priority over `SHELL`. Useful on Android/AOSP where the default shell path may not be executable. |
| `CODING_TOOLS_SHELL_TIMEOUT_MS` | `120000` | Default SHELL timeout (ms); per-call `timeout` clamps to `[100, 600000]`. |
| `CODING_TOOLS_MAX_READ_LINES` | `2000` | Max lines returned by FILE action=read before truncation. |
| `CODING_TOOLS_MAX_FILE_SIZE_BYTES` | `262144` | Pre-stat byte cap on FILE action=read. Larger files are rejected. |
| `CODING_TOOLS_GREP_HEAD_LIMIT` | `250` | Default `head_limit` for GREP output. Set to 0 to disable. |
Auto-enable keys (in agent `config.features`):
- `config.features.codingTools` (canonical) — `true` or `{ enabled: true }`.
- `config.features["coding-agent"]` (legacy alias).
Runtime gating env vars (read by `auto-enable.ts` and `index.ts`):
- `ELIZA_BUILD_VARIANT` — if `store`, plugin is disabled.
- `ELIZA_PLATFORM``android`/`ios` disables unless local-yolo mode.
- `ELIZA_RUNTIME_MODE` / `RUNTIME_MODE` / `LOCAL_RUNTIME_MODE``local-yolo` enables on Android.
## How to extend
### Add a new FILE sub-operation
1. Create `src/actions/<op>.ts` exporting a `<op>Handler` function with the `FileHandler` signature (`(runtime, message, state, options, callback) => Promise<ActionResult>`).
2. Export it from `src/actions/index.ts`.
3. Add the op name to `FILE_OPERATIONS` in `src/actions/file.ts` and wire it into `FILE_ACTIONS`.
4. Validate paths through `SandboxService.validatePath` before any filesystem access.
5. Record reads via `FileStateService.recordRead` and check writability via `FileStateService.assertWritable` before write/edit ops.
### Add a new top-level action
1. Create `src/actions/<action>.ts` exporting a `const <name>Action: Action`.
2. Export from `src/actions/index.ts`.
3. Import and add to the `actions` array in `src/index.ts`.
4. Use `contexts: [...CODING_TOOLS_CONTEXTS]` and `contextGate: { anyOf: [...CODING_TOOLS_CONTEXTS] }` so the action only fires in coding contexts.
5. Use `roleGate: { minRole: "ADMIN" }` for FILE/WORKTREE actions, or `roleGate: { minRole: "OWNER" }` for SHELL-equivalent actions — match the role of the existing action you are most similar to.
### Add a new service
1. Extend `Service` from `@elizaos/core`. Implement `static async start(runtime)` and `async stop()`.
2. Assign a string constant as `serviceType` in `src/types.ts`.
3. Export from `src/services/index.ts`.
4. Add to `services` array in `src/index.ts` and handle `stop()` in the `dispose` hook.
## Conventions / gotchas
- **All file paths must be absolute** for READ/WRITE/EDIT operations. GLOB/GREP/LS accept absolute or default to `SessionCwdService.getCwd(message.roomId)`.
- **Always validate paths through `SandboxService.validatePath`** before any filesystem access. Never bypass this.
- **Read before write**: `FileStateService.assertWritable` will reject a write if the file was modified externally since the last read. The agent must re-read first.
- **`conversationId` = `message.roomId`** (string-coerced). Missing `roomId` is a hard failure.
- **Never throw from a handler** — return `failureToActionResult({ reason, message })` instead.
- The `@vscode/ripgrep` binary is resolved at `RipgrepService` start time; if that import fails it falls back to a system `rg` on `PATH`.
- The `device_filesystem` bridge (`target=device` on FILE) is provided by a separate service (`device_filesystem` service type) registered by a platform plugin (e.g. mobile). The coding-tools plugin does not register it — it only consumes it when present.
- Tests are co-located `*.test.ts` files beside their source in `src/actions/`, `src/services/`, and `src/lib/`. Integration tests live in `__tests__/plugin-integration.test.ts` at the package root. See `AGENT_CONTRACT.md` for the action implementation brief.
- Import paths must use the `.js` extension on relative imports (ESM requirement).
<!-- 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 — CLI / tooling:**
- The real command/flow invocation transcript (args in, stdout/stderr, exit code) and the artifacts it generated (files, scaffolds, manifests, screenshots/recordings).
- Failure paths: bad args, missing deps, partial state, permission/network errors.
- A recording/log of the actual run end to end — not a unit test of one helper.
- Any model interaction captured as a live trajectory and reviewed.
<!-- END: evidence-and-e2e-mandate -->
@@ -0,0 +1,143 @@
# Action implementation contract — plugin-coding-tools
This document is the brief every action-implementing agent operates from.
Read it once, then implement the action(s) you were assigned.
## Files you may write
- `src/actions/<your-action>.ts` — the action implementation
- `src/actions/__tests__/<your-action>.test.ts` — vitest tests
**Do not modify** anything else. The plugin scaffold (services, types,
providers, build config, index.ts) is already wired up and depends on the
exact export names listed in `src/actions/index.ts`.
## Required export shape
```ts
import type { Action } from "@elizaos/core";
export const <yourActionName>: Action = { /* ... */ };
```
The export name must match the entry in `src/actions/index.ts`.
## Action skeleton
```ts
import {
type Action,
type ActionResult,
type HandlerCallback,
type IAgentRuntime,
type Memory,
type State,
logger as coreLogger,
} from "@elizaos/core";
import {
failureToActionResult,
readNumberParam,
readStringParam,
successActionResult,
} from "../lib/format.js";
import { CODING_TOOLS_CONTEXTS, CODING_TOOLS_LOG_PREFIX } from "../types.js";
// import services from "../services/index.js" as needed
export const myAction: Action = {
name: "MY_ACTION",
contexts: [...CODING_TOOLS_CONTEXTS],
contextGate: { anyOf: ["code", "terminal", "automation"] },
similes: ["MY_SIMILE"],
description: "One-paragraph description for the planner.",
descriptionCompressed: "Short tagline (under 80 chars).",
parameters: [
{ name: "file_path", description: "Absolute path", required: true,
schema: { type: "string" } },
// ...
],
validate: async (runtime: IAgentRuntime, _message: Memory, _state?: State) => {
const disable = runtime.getSetting?.("CODING_TOOLS_DISABLE");
if (disable === true || disable === "true" || disable === "1") return false;
return true;
},
handler: async (
runtime: IAgentRuntime,
message: Memory,
state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
// 1. Parse params via readStringParam / readNumberParam / etc.
// 2. Validate via SandboxService etc.
// 3. Do the work.
// 4. Return successActionResult(text, data) | failureToActionResult(failure)
// 5. Optionally invoke callback({ text, source: "coding-tools" })
},
};
```
## Available services (resolve via `runtime.getService<T>(serviceType)`)
- `FileStateService` (`FILE_STATE_SERVICE`) — `recordRead`, `recordWrite`,
`assertWritable`, `invalidate`. Use this for mtime-gated Write/Edit.
- `SandboxService` (`SANDBOX_SERVICE`) — `validatePath(conversationId, abs)`,
`validateCommand(cmd)`, `addRoot`/`removeRoot`, `rootsFor`. **Always validate
paths through this before reading or writing.**
- `SessionCwdService` (`SESSION_CWD_SERVICE`) — `getCwd`, `setCwd`,
`pushWorktree`, `popWorktree`. Use as default when an optional `path`/`cwd`
param is omitted.
- `RipgrepService` (`RIPGREP_SERVICE`) — `search(options, mode)`. GREP only.
- `ShellTaskService` (`SHELL_TASK_SERVICE`) — `start_`, `get`, `waitFor`,
`stop_`. SHELL backgrounding + TASK_OUTPUT + TASK_STOP.
## Path & cwd rules (NON-NEGOTIABLE)
- **READ / WRITE / EDIT / NOTEBOOK_EDIT** — `file_path` is required and must
be an absolute path. Reject relative paths via `SandboxService.validatePath`.
- **GLOB / GREP / LS** — `path` parameter optional. When omitted, default to
`SessionCwdService.getCwd(conversationId)`. When provided, must be absolute,
validated through SandboxService.
- **SHELL** — runs with `cwd` defaulting to `SessionCwdService.getCwd(...)`.
Optional `cwd` parameter overrides; must be absolute and within roots.
- **WORKTREE action=enter** — call `SandboxService.addRoot` and
`SessionCwdService.pushWorktree` so the new path is reachable.
## Conversation ID
`conversationId` for service keys = `message.roomId` (string-coerced). If
`message.roomId` is missing, fail with `failureToActionResult({reason: "missing_param", message: "no roomId"})`.
## Error handling
Use `failureToActionResult` for all failures. Don't throw out of the handler.
Always return `ActionResult` with `success: true | false` and a `text` field.
## Tests
Use vitest. Mock the runtime where needed via a small helper or inline. Test
both happy path and one or two failure cases (path outside roots, file not
read first, etc.). Tests live in `src/actions/__tests__/<name>.test.ts`.
Run with `bun run test --filter=<your-action>` from the plugin dir.
## Imports
- `@elizaos/core` for types, logger, Service.
- `node:fs/promises`, `node:path`, `node:child_process`, `node:crypto` for
Node APIs. No external deps beyond what package.json already declares
(`@vscode/ripgrep` is the only runtime dep).
- Always use `.js` extension on relative imports (ESM rule).
## Style
- Match the style of the existing action implementations in `src/actions/`.
- Use `coreLogger.debug` / `.warn` for diagnostic logs prefixed with
`CODING_TOOLS_LOG_PREFIX`.
- No comments narrating obvious code. Only comment non-obvious WHY.
## When in doubt
Read the spec for your action in this brief, look at how Claude Code does it
(`claude-code/src/tools/<ToolName>/`), then implement the simplest correct
thing that satisfies both. Do not invent extra parameters.
+190
View File
@@ -0,0 +1,190 @@
# @elizaos/plugin-coding-tools
Native Claude-Code-style coding tools (FILE, SHELL, WORKTREE) for Eliza agents running in code/terminal/automation contexts.
## Purpose / role
Adds filesystem operations, shell command execution, and git worktree management to an Eliza agent. The plugin is **opt-in**: it auto-enables when `config.features.codingTools` (or legacy `config.features["coding-agent"]`) is truthy and the runtime environment supports a terminal (disabled on `ELIZA_BUILD_VARIANT=store` and on iOS; Android only when `ELIZA_RUNTIME_MODE=local-yolo`). All actions are gated to `contexts: ["code", "terminal", "automation"]`. FILE and WORKTREE require `roleGate: minRole=ADMIN`; SHELL requires `roleGate: minRole=OWNER`.
## Plugin surface
### Actions
- **FILE** — umbrella for `read/write/edit/grep/glob/ls`. Dispatches to per-operation handlers. Supports `target=device` for `read/write/ls` through a `device_filesystem` bridge service (mobile). Similes: `FILE_OPERATION`, `FILE_IO`.
- **SHELL** — `action=run` executes a command via `/bin/bash -c`; `action=view_history`/`clear_history` read or clear per-conversation command history (backed by an external `shell` service when present). Per-call `timeout` (ms) is clamped to `[100, 600000]`, default `CODING_TOOLS_SHELL_TIMEOUT_MS` (120000). Similes: `BASH`, `EXEC`, `RUN_COMMAND`.
- **WORKTREE** — umbrella for `enter/exit` git worktrees. On enter, registers new root in `SandboxService` and pushes to `SessionCwdService` stack. On exit, pops. Similes: `GIT_WORKTREE`.
### Provider
- **AVAILABLE_CODING_TOOLS** — injects the list of available tool names (`FILE`, `SHELL`, `WORKTREE`) into agent state at position `-10`. Stable/agent-scoped cache.
### Services
| Service | `serviceType` constant | Purpose |
|---|---|---|
| `SandboxService` | `CODING_TOOLS_SANDBOX` | Path-blocklist policy. Validates every path before read/write. Defaults block `~/pvt`, `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.docker`, `~/.kube`, `~/.netrc`, `~/Library`, plus per-OS system paths. Optional allow-roots via `CODING_TOOLS_WORKSPACE_ROOTS`. |
| `FileStateService` | `CODING_TOOLS_FILE_STATE` | Per-(conversation, file) mtime tracking. Write/Edit check that the file was not externally modified since the last Read. |
| `SessionCwdService` | `CODING_TOOLS_SESSION_CWD` | Per-conversation working directory. Defaults to `process.cwd()`. Glob/Grep/LS/Shell use this when no explicit `path`/`cwd` is given. Worktree push/pop mutates it. |
| `RipgrepService` | `CODING_TOOLS_RIPGREP` | Wraps `@vscode/ripgrep` binary. Used by `grep` operation. Always excludes VCS dirs. 30 s hard cap. |
### Other exports
- `CodingTaskExecutor` — task executor that maps task specs matching coding-related keywords into agent actions. Consumed externally by orchestration plugins.
- `coding-agent-context` (Zod schemas) — `FileOperationSchema`, `CommandResultSchema`, `CapturedErrorSchema`, etc. Used to validate structured outputs from coding loops.
## Layout
```
plugins/plugin-coding-tools/
src/
index.ts Plugin entry — exports codingToolsPlugin, all services, types
types.ts Service-type constants, ToolFailure/ToolResult types, CODING_TOOLS_CONTEXTS
actions/
file.ts FILE umbrella action — routes to per-op handlers
bash.ts SHELL action implementation
worktree.ts WORKTREE umbrella action
read.ts / write.ts / edit.ts FILE sub-handlers for read/write/edit
grep.ts / glob.ts / ls.ts FILE sub-handlers for grep/glob/ls
enter-worktree.ts / exit-worktree.ts WORKTREE sub-handlers
index.ts Re-exports all action handlers
providers/
available-tools.ts AVAILABLE_CODING_TOOLS provider
services/
sandbox-service.ts Path policy (blocklist + allow-roots)
file-state-service.ts Per-conversation file mtime tracking
session-cwd-service.ts Per-conversation working directory + worktree stack
ripgrep-service.ts @vscode/ripgrep wrapper
coding-task-executor.ts CodingTaskExecutor (external task delegation)
coding-agent-context.ts Zod schemas for coding-agent context types
index.ts Re-exports all services
lib/
format.ts Param readers (readStringParam, readNumberParam), successActionResult, failureToActionResult
path-utils.ts isAbsolutePath, isWithin, resolveRealPath, isUncPath
run-shell.ts runShell helper (child_process wrapper with timeout/streaming)
run-git-command.ts runGitCommand helper
terminal-capabilities.ts Platform capability detection
secrets.ts detectSecrets — flags AWS/GitHub/OpenAI/etc. tokens to gate WRITE/EDIT
auto-enable.ts Lightweight auto-enable module (env reads only; no plugin runtime imports)
AGENT_CONTRACT.md Implementation brief for action-writing agents
build.ts build script (Bun.build + tsc d.ts emit)
```
## Commands
Scripts are defined in `package.json`; run them from the repo root with `bun run --cwd`:
```bash
bun run --cwd plugins/plugin-coding-tools clean # remove build output
bun run --cwd plugins/plugin-coding-tools build # build package artifacts
bun run --cwd plugins/plugin-coding-tools dev # development build/watch lane
bun run --cwd plugins/plugin-coding-tools typecheck # TypeScript typecheck
bun run --cwd plugins/plugin-coding-tools check # package check alias
bun run --cwd plugins/plugin-coding-tools lint # mutating Biome check
bun run --cwd plugins/plugin-coding-tools lint:check # read-only Biome check
bun run --cwd plugins/plugin-coding-tools format # write formatting
bun run --cwd plugins/plugin-coding-tools format:check # read-only formatting check
bun run --cwd plugins/plugin-coding-tools test # run package tests
```
## Config / env vars
All settings are read via `runtime.getSetting(key)` or `process.env`. None are required.
| Env var | Default | Description |
|---|---|---|
| `CODING_TOOLS_WORKSPACE_ROOTS` | `process.cwd()` | Comma-separated absolute paths the tools may access. When set, paths outside these roots are rejected. |
| `CODING_TOOLS_BLOCKED_PATHS` | (built-in list) | Comma-separated absolute paths — **replaces** the default blocklist. |
| `CODING_TOOLS_BLOCKED_PATHS_ADD` | — | Comma-separated paths to **add** to the default blocklist. |
| `CODING_TOOLS_SHELL` | (auto-detected) | Override the shell binary used by SHELL action. Takes priority over `SHELL`. Useful on Android/AOSP where the default shell path may not be executable. |
| `CODING_TOOLS_SHELL_TIMEOUT_MS` | `120000` | Default SHELL timeout (ms); per-call `timeout` clamps to `[100, 600000]`. |
| `CODING_TOOLS_MAX_READ_LINES` | `2000` | Max lines returned by FILE action=read before truncation. |
| `CODING_TOOLS_MAX_FILE_SIZE_BYTES` | `262144` | Pre-stat byte cap on FILE action=read. Larger files are rejected. |
| `CODING_TOOLS_GREP_HEAD_LIMIT` | `250` | Default `head_limit` for GREP output. Set to 0 to disable. |
Auto-enable keys (in agent `config.features`):
- `config.features.codingTools` (canonical) — `true` or `{ enabled: true }`.
- `config.features["coding-agent"]` (legacy alias).
Runtime gating env vars (read by `auto-enable.ts` and `index.ts`):
- `ELIZA_BUILD_VARIANT` — if `store`, plugin is disabled.
- `ELIZA_PLATFORM``android`/`ios` disables unless local-yolo mode.
- `ELIZA_RUNTIME_MODE` / `RUNTIME_MODE` / `LOCAL_RUNTIME_MODE``local-yolo` enables on Android.
## How to extend
### Add a new FILE sub-operation
1. Create `src/actions/<op>.ts` exporting a `<op>Handler` function with the `FileHandler` signature (`(runtime, message, state, options, callback) => Promise<ActionResult>`).
2. Export it from `src/actions/index.ts`.
3. Add the op name to `FILE_OPERATIONS` in `src/actions/file.ts` and wire it into `FILE_ACTIONS`.
4. Validate paths through `SandboxService.validatePath` before any filesystem access.
5. Record reads via `FileStateService.recordRead` and check writability via `FileStateService.assertWritable` before write/edit ops.
### Add a new top-level action
1. Create `src/actions/<action>.ts` exporting a `const <name>Action: Action`.
2. Export from `src/actions/index.ts`.
3. Import and add to the `actions` array in `src/index.ts`.
4. Use `contexts: [...CODING_TOOLS_CONTEXTS]` and `contextGate: { anyOf: [...CODING_TOOLS_CONTEXTS] }` so the action only fires in coding contexts.
5. Use `roleGate: { minRole: "ADMIN" }` for FILE/WORKTREE actions, or `roleGate: { minRole: "OWNER" }` for SHELL-equivalent actions — match the role of the existing action you are most similar to.
### Add a new service
1. Extend `Service` from `@elizaos/core`. Implement `static async start(runtime)` and `async stop()`.
2. Assign a string constant as `serviceType` in `src/types.ts`.
3. Export from `src/services/index.ts`.
4. Add to `services` array in `src/index.ts` and handle `stop()` in the `dispose` hook.
## Conventions / gotchas
- **All file paths must be absolute** for READ/WRITE/EDIT operations. GLOB/GREP/LS accept absolute or default to `SessionCwdService.getCwd(message.roomId)`.
- **Always validate paths through `SandboxService.validatePath`** before any filesystem access. Never bypass this.
- **Read before write**: `FileStateService.assertWritable` will reject a write if the file was modified externally since the last read. The agent must re-read first.
- **`conversationId` = `message.roomId`** (string-coerced). Missing `roomId` is a hard failure.
- **Never throw from a handler** — return `failureToActionResult({ reason, message })` instead.
- The `@vscode/ripgrep` binary is resolved at `RipgrepService` start time; if that import fails it falls back to a system `rg` on `PATH`.
- The `device_filesystem` bridge (`target=device` on FILE) is provided by a separate service (`device_filesystem` service type) registered by a platform plugin (e.g. mobile). The coding-tools plugin does not register it — it only consumes it when present.
- Tests are co-located `*.test.ts` files beside their source in `src/actions/`, `src/services/`, and `src/lib/`. Integration tests live in `__tests__/plugin-integration.test.ts` at the package root. See `AGENT_CONTRACT.md` for the action implementation brief.
- Import paths must use the `.js` extension on relative imports (ESM requirement).
<!-- 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 — CLI / tooling:**
- The real command/flow invocation transcript (args in, stdout/stderr, exit code) and the artifacts it generated (files, scaffolds, manifests, screenshots/recordings).
- Failure paths: bad args, missing deps, partial state, permission/network errors.
- A recording/log of the actual run end to end — not a unit test of one helper.
- Any model interaction captured as a live trajectory and reviewed.
<!-- END: evidence-and-e2e-mandate -->
+69
View File
@@ -0,0 +1,69 @@
# @elizaos/plugin-coding-tools
Native Claude-Code-style coding tools for elizaOS agents. Adds filesystem operations (read, write, edit, search, glob, ls), shell command execution, and git worktree management to any Eliza agent running in a code or terminal context.
## What it does
The plugin registers three umbrella actions and a set of supporting services:
| Action | Operations | Description |
|---|---|---|
| **FILE** | `read`, `write`, `edit`, `grep`, `glob`, `ls` | All file and search operations. Paths must be absolute. Optional `target=device` routes through a device filesystem bridge for mobile. |
| **SHELL** | `run`, `clear_history`, `view_history` | `run` executes a command via `/bin/bash -c` with a per-call timeout (clamped to `[100, 600000]` ms, default 120000). `view_history`/`clear_history` read or clear per-conversation command history. |
| **WORKTREE** | `enter`, `exit` | Creates and tears down git worktrees, updating the agent's session cwd and sandbox roots automatically. |
Supporting services (automatically started):
- **SandboxService** — path policy engine. Blocks user-private and OS-system paths by default; optionally constrains access to configured workspace roots.
- **FileStateService** — tracks file mtimes per conversation so write/edit operations are rejected if the file was externally modified since the agent last read it.
- **SessionCwdService** — per-conversation working directory. Defaults to `process.cwd()`; updated by WORKTREE operations.
- **RipgrepService** — wraps the `@vscode/ripgrep` binary for fast regex search.
## Enabling the plugin
The plugin is **opt-in**. Enable it by setting `features.codingTools` in the agent configuration:
```json
{
"features": {
"codingTools": true
}
}
```
The legacy key `features["coding-agent"]` is also accepted.
The plugin is automatically disabled when:
- `ELIZA_BUILD_VARIANT=store`
- Running on iOS
- Running on Android without `ELIZA_RUNTIME_MODE=local-yolo`
## Configuration
All settings are optional. Configure via environment variables or agent settings:
| Setting | Default | Description |
|---|---|---|
| `CODING_TOOLS_WORKSPACE_ROOTS` | `process.cwd()` | Comma-separated absolute paths the tools may access. Files outside these roots are rejected. |
| `CODING_TOOLS_BLOCKED_PATHS` | (built-in) | Comma-separated absolute paths to block — replaces the default blocklist. |
| `CODING_TOOLS_BLOCKED_PATHS_ADD` | — | Paths to add to the default blocklist. |
| `CODING_TOOLS_SHELL_TIMEOUT_MS` | `120000` | Default SHELL timeout (ms); per-call `timeout` clamps to `[100, 600000]`. |
| `CODING_TOOLS_MAX_READ_LINES` | `2000` | Max lines returned by FILE action=read. |
| `CODING_TOOLS_MAX_FILE_SIZE_BYTES` | `262144` | File size cap for reads (bytes). Larger files are rejected. |
| `CODING_TOOLS_GREP_HEAD_LIMIT` | `250` | Max output lines for GREP. Set to 0 to disable. |
## Default path blocklist
The following paths are blocked by default (plus platform-specific system directories):
- `~/pvt`, `~/Library`
- `~/.ssh`, `~/.aws`, `~/.gnupg`, `~/.docker`, `~/.kube`, `~/.netrc`
Override with `CODING_TOOLS_BLOCKED_PATHS` (replace) or `CODING_TOOLS_BLOCKED_PATHS_ADD` (extend).
## Requirements
- Node.js runtime only (`eliza.platforms: ["node"]`).
- FILE and WORKTREE require `roleGate: minRole=ADMIN`; SHELL requires `roleGate: minRole=OWNER`.
- All actions are restricted to `contexts: ["code", "terminal", "automation"]`.
@@ -0,0 +1,269 @@
/**
* Integration tests for the assembled `codingToolsPlugin` — service registration,
* action wiring, and auto-enable gating — exercised in-process against the real
* filesystem.
*/
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { IAgentRuntime, Memory, Service, UUID } from "@elizaos/core";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import * as pluginModule from "../src/index.js";
import codingToolsPlugin, {
availableToolsProvider,
CODING_TOOLS_CONTEXTS,
FILE_STATE_SERVICE,
FileStateService,
RIPGREP_SERVICE,
RipgrepService,
SANDBOX_SERVICE,
SandboxService,
SESSION_CWD_SERVICE,
SessionCwdService,
} from "../src/index.js";
const EXPECTED_ACTIONS = ["FILE", "SHELL", "WORKTREE"];
describe("@elizaos/plugin-coding-tools — plugin export shape", () => {
it("exports a Plugin with the expected name", () => {
expect(codingToolsPlugin.name).toBe("coding-tools");
expect(codingToolsPlugin.description).toBeTruthy();
});
it("registers the consolidated top-level coding actions", () => {
const actions = codingToolsPlugin.actions ?? [];
const names = actions.map((a) => a.name).sort();
expect(names).toEqual([...EXPECTED_ACTIONS].sort());
});
it("does not register legacy leaf actions as planner-facing actions", () => {
const names = new Set((codingToolsPlugin.actions ?? []).map((a) => a.name));
for (const legacyName of [
"READ",
"WRITE",
"EDIT",
"BASH",
"GREP",
"GLOB",
"LS",
"WEB_FETCH",
"ASK_USER_QUESTION",
"ENTER_WORKTREE",
"EXIT_WORKTREE",
]) {
expect(names.has(legacyName), legacyName).toBe(false);
}
});
it("FILE exposes only canonical file umbrella similes", () => {
const fileAction = (codingToolsPlugin.actions ?? []).find(
(action) => action.name === "FILE",
);
expect(fileAction?.similes).toEqual(["FILE_OPERATION", "FILE_IO"]);
});
it("each action has the required fields", () => {
for (const action of codingToolsPlugin.actions ?? []) {
expect(action.name, action.name).toBeTruthy();
expect(action.description, action.name).toBeTruthy();
expect(action.handler, action.name).toBeTypeOf("function");
expect(action.validate, action.name).toBeTypeOf("function");
}
});
it("action names are unique", () => {
const names = (codingToolsPlugin.actions ?? []).map((a) => a.name);
expect(new Set(names).size).toBe(names.length);
});
it("exports the 4 active services", () => {
const services = codingToolsPlugin.services ?? [];
expect(services).toContain(FileStateService);
expect(services).toContain(SandboxService);
expect(services).toContain(SessionCwdService);
expect(services).toContain(RipgrepService);
expect(services.length).toBe(4);
});
it("does not export removed actions or service constants", () => {
expect("bashAction" in pluginModule).toBe(false);
expect("BASH_AST_SERVICE" in pluginModule).toBe(false);
expect("OS_SANDBOX_SERVICE" in pluginModule).toBe(false);
expect("SHELL_TASK_SERVICE" in pluginModule).toBe(false);
expect("ShellTaskService" in pluginModule).toBe(false);
expect("notebookEditAction" in pluginModule).toBe(false);
expect("taskOutputAction" in pluginModule).toBe(false);
expect("taskStopAction" in pluginModule).toBe(false);
expect("todoWriteAction" in pluginModule).toBe(false);
});
it("exports the available-tools provider at position -10 with code/terminal/automation gates", () => {
expect(codingToolsPlugin.providers ?? []).toContain(availableToolsProvider);
expect(availableToolsProvider.position).toBe(-10);
expect(availableToolsProvider.contexts).toEqual([...CODING_TOOLS_CONTEXTS]);
});
it("validate ignores CODING_TOOLS_DISABLE — kill switch was removed", async () => {
const runtime = {
agentId: "00000000-0000-0000-0000-000000000000",
getSetting: (key: string) =>
key === "CODING_TOOLS_DISABLE" ? true : undefined,
getService: () => null,
} as IAgentRuntime;
const message = { roomId: "r" } as Memory;
for (const action of codingToolsPlugin.actions ?? []) {
const ok = await action.validate?.(runtime, message);
expect(ok, action.name).toBe(true);
}
});
});
describe("@elizaos/plugin-coding-tools — end-to-end smoke", () => {
let tmpDir: string;
let runtime: IAgentRuntime;
let services: Map<string, Service>;
const cleanup: Array<() => Promise<void>> = [];
beforeAll(async () => {
tmpDir = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), "ct-integ-")),
);
await fs.writeFile(
path.join(tmpDir, "needle.txt"),
"this file contains the NEEDLE word\n",
"utf8",
);
await fs.writeFile(
path.join(tmpDir, "other.md"),
"# heading\n\nbody text\n",
"utf8",
);
services = new Map();
runtime = {
agentId: "00000000-0000-0000-0000-000000000000" as UUID,
getSetting: (_key: string) => undefined,
getService: (key: string) => services.get(key) ?? null,
} as IAgentRuntime;
const fileState = await FileStateService.start(runtime);
const sandbox = await SandboxService.start(runtime);
const session = await SessionCwdService.start(runtime);
const rg = await RipgrepService.start(runtime);
services.set(FILE_STATE_SERVICE, fileState);
services.set(SANDBOX_SERVICE, sandbox);
services.set(SESSION_CWD_SERVICE, session);
services.set(RIPGREP_SERVICE, rg);
cleanup.push(() => fileState.stop());
cleanup.push(() => sandbox.stop());
cleanup.push(() => session.stop());
cleanup.push(() => rg.stop());
session.setCwd("smoke-room", tmpDir);
});
afterAll(async () => {
for (const fn of cleanup) {
try {
await fn();
} catch {
// ignore
}
}
await fs.rm(tmpDir, { recursive: true, force: true });
});
function findAction(name: string) {
const actions = codingToolsPlugin.actions ?? [];
const a = actions.find((x) => x.name === name);
if (!a) throw new Error(`action ${name} not found`);
return a;
}
function makeMessage(): Memory {
return { roomId: "smoke-room" } as Memory;
}
it("FILE action=read returns a known file's contents", async () => {
const action = findAction("FILE");
const result = await action.handler?.(runtime, makeMessage(), undefined, {
parameters: {
action: "read",
file_path: path.join(tmpDir, "needle.txt"),
},
});
expect(result.success).toBe(true);
expect(result.text).toContain("NEEDLE");
});
it("FILE action=write creates a new file", async () => {
const action = findAction("FILE");
const target = path.join(tmpDir, "smoke-out.txt");
const result = await action.handler?.(runtime, makeMessage(), undefined, {
parameters: { action: "write", file_path: target, content: "smoke ok" },
});
expect(result.success).toBe(true);
const written = await fs.readFile(target, "utf8");
expect(written).toBe("smoke ok");
});
it("SHELL echo hello", async () => {
const action = findAction("SHELL");
const result = await action.handler?.(runtime, makeMessage(), undefined, {
parameters: { command: "echo smoke-bash-ok", cwd: tmpDir },
});
expect(result.success).toBe(true);
expect(result.text).toContain("smoke-bash-ok");
expect(result.text).toContain("[exit 0]");
});
it("FILE action=glob lists *.txt files", async () => {
const action = findAction("FILE");
const result = await action.handler?.(runtime, makeMessage(), undefined, {
parameters: { action: "glob", pattern: "*.txt", path: tmpDir },
});
expect(result.success).toBe(true);
expect(result.text).toContain("needle.txt");
});
it("FILE action=ls shows fixture entries", async () => {
const action = findAction("FILE");
const result = await action.handler?.(runtime, makeMessage(), undefined, {
parameters: { action: "ls", path: tmpDir },
});
expect(result.success).toBe(true);
expect(result.text).toContain("needle.txt");
expect(result.text).toContain("other.md");
});
it("GREP finds the NEEDLE token (skip when ripgrep absent)", async () => {
const rg = services.get(RIPGREP_SERVICE) as RipgrepService | undefined;
if (!rg) return;
const fs2 = await import("node:fs");
const initial = rg.binary();
if (!fs2.existsSync(initial)) {
const candidates = [
"/opt/homebrew/bin/rg",
"/usr/local/bin/rg",
"/usr/bin/rg",
];
const sys = candidates.find((p) => fs2.existsSync(p));
if (!sys) return;
(rg as { rgPath: string }).rgPath = sys;
}
const action = findAction("FILE");
const result = await action.handler?.(runtime, makeMessage(), undefined, {
parameters: { action: "grep", pattern: "NEEDLE", path: tmpDir },
});
expect(result.success).toBe(true);
expect(result.text).toContain("needle.txt");
});
it("WORKTREE action=enter in a non-git dir fails cleanly", async () => {
const action = findAction("WORKTREE");
const result = await action.handler?.(runtime, makeMessage(), undefined, {
parameters: { action: "enter" },
});
expect(result.success).toBe(false);
expect(result.text).toContain("io_error");
});
});
@@ -0,0 +1,54 @@
// Auto-enable check for @elizaos/plugin-coding-tools.
//
// Plugin manifest entry-point — referenced by package.json's
// `elizaos.plugin.autoEnableModule`. Keep this module light: 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";
function isFeatureEnabled(
config: PluginAutoEnableContext["config"],
key: string,
): boolean {
const f = (config.features as Record<string, unknown> | undefined)?.[key];
if (f === true) return true;
if (f && typeof f === "object" && f !== null) {
return (f as Record<string, unknown>).enabled !== false;
}
return false;
}
function terminalSupportedByEnv(ctx: PluginAutoEnableContext): boolean {
const env = ctx.env;
const variant = (env.ELIZA_BUILD_VARIANT ?? "").trim().toLowerCase();
if (variant === "store") return false;
const platform = env.ELIZA_PLATFORM?.trim().toLowerCase();
const mobile =
platform === "android" ||
platform === "ios" ||
Boolean(env.ANDROID_ROOT || env.ANDROID_DATA);
if (!mobile) return true;
const mode = (
env.ELIZA_RUNTIME_MODE ??
env.RUNTIME_MODE ??
env.LOCAL_RUNTIME_MODE ??
""
)
.trim()
.toLowerCase();
return platform === "android" && mode === "local-yolo";
}
/**
* Enable when `config.features.codingTools` is truthy, or via the legacy
* `config.features["coding-agent"]` key.
*/
export function shouldEnable(ctx: PluginAutoEnableContext): boolean {
return (
(isFeatureEnabled(ctx.config, "codingTools") ||
isFeatureEnabled(ctx.config, "coding-agent")) &&
terminalSupportedByEnv(ctx)
);
}
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bun
/**
* Build script for @elizaos/plugin-coding-tools (Node). Orchestration lives in the shared
* driver (plugins/plugin-build.ts); this lists only what differs.
*/
import { buildPlugin } from "../plugin-build";
await buildPlugin({
name: "@elizaos/plugin-coding-tools",
clean: true,
externals: [
"@elizaos/core",
"@elizaos/shared",
"@vscode/ripgrep",
"phonemizer",
"figlet",
],
targets: [
{
label: "Node",
entry: "./src/index.ts",
outSubdir: "",
target: "node",
format: "esm",
},
],
dtsProject: "tsconfig.build.json",
dtsEmitDeclarationOnly: true,
});
+151
View File
@@ -0,0 +1,151 @@
{
"name": "@elizaos/plugin-coding-tools",
"version": "2.0.3-beta.7",
"description": "Native Claude-Code-style coding tools (Read, Write, Edit, Bash, Grep, Glob, etc.) for the Eliza agent.",
"type": "module",
"main": "dist/index.js",
"module": "dist/index.js",
"types": "dist/index.d.ts",
"sideEffects": false,
"repository": {
"type": "git",
"url": "https://github.com/elizaos/eliza.git"
},
"exports": {
"./package.json": "./package.json",
".": {
"types": "./dist/index.d.ts",
"eliza-source": {
"types": "./src/index.ts",
"import": "./src/index.ts",
"default": "./src/index.ts"
},
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./*.css": "./dist/*.css",
"./*": {
"types": "./dist/*.d.ts",
"eliza-source": {
"types": "./src/*.ts",
"import": "./src/*.ts",
"default": "./src/*.ts"
},
"import": "./dist/*.js",
"default": "./dist/*.js"
}
},
"files": [
"registry-entry.json",
"dist",
"auto-enable.ts"
],
"elizaos": {
"plugin": {
"autoEnableModule": "./auto-enable.ts",
"capabilities": [
"coding-tools"
]
},
"scripts": {
"coreBuild": true
}
},
"keywords": [
"eliza",
"plugin",
"coding",
"tools",
"claude-code",
"agent"
],
"author": "elizaOS",
"license": "MIT",
"dependencies": {
"@elizaos/core": "workspace:*",
"@elizaos/shared": "workspace:*",
"@vscode/ripgrep": "^1.15.9",
"zod": "^4.4.3"
},
"devDependencies": {
"@types/node": "^25.0.3",
"typescript": "^6.0.3",
"vitest": "^4.0.0"
},
"scripts": {
"build": "bun run build.ts",
"dev": "bun --hot build.ts",
"clean": "node ../../packages/scripts/rm-path-recursive.mjs dist .turbo",
"test": "vitest run",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"lint": "bunx @biomejs/biome check --write --unsafe .",
"lint:check": "bunx @biomejs/biome check .",
"format": "bunx @biomejs/biome format --write .",
"format:check": "bunx @biomejs/biome format .",
"check": "bun run typecheck && bun run test"
},
"publishConfig": {
"access": "public"
},
"agentConfig": {
"pluginType": "elizaos:plugin:1.0.0",
"pluginParameters": {
"CODING_TOOLS_WORKSPACE_ROOTS": {
"type": "string",
"description": "Comma-separated absolute paths the coding tools may read/write under. Defaults to the runtime's process.cwd() when unset.",
"required": false,
"sensitive": false
},
"CODING_TOOLS_DENY_COMMANDS": {
"type": "string",
"description": "Comma-separated command-prefix patterns the SHELL action must refuse (e.g. 'rm -rf /,sudo,curl|wget'). Combined with built-in denylist.",
"required": false,
"sensitive": false
},
"CODING_TOOLS_SHELL_TIMEOUT_MS": {
"type": "number",
"description": "Default SHELL timeout in milliseconds. Hard ceiling enforced before backgrounding kicks in.",
"required": false,
"default": 120000,
"sensitive": false
},
"CODING_TOOLS_SHELL_BG_BUDGET_MS": {
"type": "number",
"description": "Foreground budget. Shell invocations exceeding this auto-background and return a task_id (matches Claude's 15s ASSISTANT_BLOCKING_BUDGET_MS).",
"required": false,
"default": 15000,
"sensitive": false
},
"CODING_TOOLS_MAX_READ_LINES": {
"type": "number",
"description": "Max lines returned by FILE action=read in a single call before truncation. Mirrors Claude's MAX_LINES_TO_READ.",
"required": false,
"default": 2000,
"sensitive": false
},
"CODING_TOOLS_MAX_FILE_SIZE_BYTES": {
"type": "number",
"description": "Pre-stat byte cap on FILE action=read. Files larger than this are rejected with a message instructing the agent to read in chunks.",
"required": false,
"default": 262144,
"sensitive": false
},
"CODING_TOOLS_GREP_HEAD_LIMIT": {
"type": "number",
"description": "Default head_limit for GREP. Set to 0 to disable.",
"required": false,
"default": 250,
"sensitive": false
}
}
},
"eliza": {
"platforms": [
"node"
],
"runtime": "node",
"platformDetails": {
"node": "Default export (Node.js)"
}
}
}
@@ -0,0 +1,72 @@
{
"id": "coding-tools",
"name": "Coding Tools",
"description": "Native coding tools (FILE, SHELL, WORKTREE) running directly in the agent. AST-validated commands, mtime-gated writes, sealed workspace roots, optional per-OS process sandboxing (sandbox-exec on macOS, bwrap on Linux).",
"npmName": "@elizaos/plugin-coding-tools",
"shortIds": ["coding-agent", "codingAgent", "coding-tools", "codingTools"],
"version": "0.1.0",
"source": "bundled",
"tags": ["coding", "filesystem", "shell", "git", "code", "claude-code"],
"config": {
"CODING_TOOLS_WORKSPACE_ROOTS": {
"type": "string",
"required": false,
"sensitive": false,
"label": "Workspace roots",
"help": "Comma-separated absolute paths the coding tools may read/write under. Defaults to the runtime's cwd.",
"advanced": false
},
"CODING_TOOLS_DENY_COMMANDS": {
"type": "string",
"required": false,
"sensitive": false,
"label": "Forbidden command patterns",
"help": "Comma-separated regex patterns the SHELL action must refuse. Combined with the built-in denylist and AST analyzer.",
"advanced": true
},
"CODING_TOOLS_SHELL_TIMEOUT_MS": {
"type": "number",
"required": false,
"sensitive": false,
"label": "Shell timeout",
"help": "Default SHELL timeout in milliseconds.",
"advanced": false,
"min": 0,
"unit": "ms"
},
"CODING_TOOLS_SHELL_BG_BUDGET_MS": {
"type": "number",
"required": false,
"sensitive": false,
"label": "Foreground budget",
"help": "Shell invocations exceeding this auto-background and return a task_id.",
"advanced": true,
"min": 1000,
"unit": "ms"
},
"CODING_TOOLS_OS_SANDBOX": {
"type": "boolean",
"required": false,
"sensitive": false,
"label": "OS-level sandbox",
"help": "Wrap bash invocations in sandbox-exec (macOS) or bwrap (Linux) when available. AST analysis runs regardless.",
"advanced": true
}
},
"render": {
"visible": true,
"pinTo": [],
"style": "card",
"icon": "Puzzle",
"group": "devtools",
"groupOrder": 5,
"actions": ["enable", "configure"]
},
"resources": {
"homepage": "https://github.com/elizaos/eliza#readme",
"repository": "https://github.com/elizaos/eliza"
},
"dependsOn": [],
"kind": "plugin",
"subtype": "devtools"
}
@@ -0,0 +1,87 @@
/**
* Shared setup for the action test files: builds a temp workspace and a minimal
* runtime stub wired with the SandboxService and FileStateService the handlers
* require, over the real filesystem.
*/
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import type { IAgentRuntime, Memory, Service } from "@elizaos/core";
import { FileStateService } from "../services/file-state-service.js";
import { SandboxService } from "../services/sandbox-service.js";
import { FILE_STATE_SERVICE, SANDBOX_SERVICE } from "../types.js";
export interface TestEnv {
runtime: IAgentRuntime;
fileState: FileStateService;
sandbox: SandboxService;
message: Memory;
tmpDir: string;
/**
* Absolute path the SandboxService is configured to refuse. Tests can
* read/write under this path to verify blocklist enforcement. Lives inside
* tmpDir so it's removed automatically by `cleanup`.
*/
blockedPath: string;
cleanup: () => Promise<void>;
}
export async function makeTempDir(prefix: string): Promise<string> {
const created = await fs.mkdtemp(path.join(os.tmpdir(), `${prefix}-`));
return await fs.realpath(created);
}
export interface SetupOptions {
/** Optional pre-existing tmp dir to use instead of mkdtemp. */
rootsPath?: string;
/** Additional runtime settings to merge in. */
extraSettings?: Record<string, unknown>;
/** Override the blocked path; defaults to <tmpDir>/_blocked. */
blockedPath?: string;
}
export async function setupEnv(
prefix: string,
options: SetupOptions = {},
): Promise<TestEnv> {
const tmpDir = options.rootsPath ?? (await makeTempDir(prefix));
const blockedPath = options.blockedPath ?? path.join(tmpDir, "_blocked");
await fs.mkdir(blockedPath, { recursive: true });
const settings: Record<string, unknown> = {
CODING_TOOLS_BLOCKED_PATHS: blockedPath,
...options.extraSettings,
};
const services = new Map<string, Service>();
const runtime = {
agentId: "test-agent",
getSetting: (key: string) => settings[key],
getService: (key: string) => services.get(key) ?? null,
} as IAgentRuntime;
const sandbox = await SandboxService.start(runtime);
const fileState = await FileStateService.start(runtime);
services.set(SANDBOX_SERVICE, sandbox);
services.set(FILE_STATE_SERVICE, fileState);
const message = {
roomId: "test-room",
entityId: "test-entity",
} as Memory;
return {
runtime,
fileState,
sandbox,
message,
tmpDir,
blockedPath,
cleanup: async () => {
await sandbox.stop();
await fileState.stop();
await fs.rm(tmpDir, { recursive: true, force: true });
},
};
}
@@ -0,0 +1,86 @@
/**
* Real-error-path tests for the SHELL action (#12273): drive the action against
* a genuinely missing binary and a genuinely failing command through the *real*
* host shell (no capability router, no mocked failure) and assert the failure
* surfaces as `success: false` + `result.error` — the shape the planner loop
* shows the model. Guards the fallback-slop invariant that a shell failure is
* never repackaged as success-shaped output.
*/
import type { IAgentRuntime, Memory, UUID } from "@elizaos/core";
import { describe, expect, it, vi } from "vitest";
import { SandboxService, SessionCwdService } from "../services/index.js";
import { SANDBOX_SERVICE, SESSION_CWD_SERVICE } from "../types.js";
import { shellAction } from "./bash.js";
// The assertions pin bash exit-code framing (127 = command not found), so the
// suite targets POSIX hosts; the Windows path routes to powershell separately.
const describeIfPosix = process.platform === "win32" ? describe.skip : describe;
async function makeHostRuntime(): Promise<IAgentRuntime> {
const services = new Map<string, unknown>();
const runtime = {
agentId: "11111111-1111-1111-1111-111111111111" as UUID,
getSetting: vi.fn(() => undefined),
getService: vi.fn(<T>(type: string) => services.get(type) as T | null),
reportError: vi.fn(),
} as unknown as IAgentRuntime;
services.set(SANDBOX_SERVICE, await SandboxService.start(runtime));
services.set(SESSION_CWD_SERVICE, await SessionCwdService.start(runtime));
return runtime;
}
function makeMessage(text = ""): Memory {
return {
id: "33333333-3333-3333-3333-333333333333" as UUID,
entityId: "44444444-4444-4444-4444-444444444444" as UUID,
roomId: "11111111-aaaa-bbbb-cccc-222222222222" as UUID,
agentId: "11111111-1111-1111-1111-111111111111" as UUID,
content: { text },
createdAt: Date.now(),
} as Memory;
}
describeIfPosix("SHELL action real error paths", () => {
it("surfaces a missing binary as success:false + result.error (exit 127)", async () => {
const runtime = await makeHostRuntime();
const result = await shellAction.handler?.(
runtime,
makeMessage(),
undefined,
{
command: "definitely-not-a-real-binary-xyzzy-12345 --version",
},
);
// The failure reaches the caller unmasked — not a fabricated success.
expect(result.success).toBe(false);
expect(result.error).toBeInstanceOf(Error);
expect(String(result.error?.message)).toContain(
"command exited with code 127",
);
const data = result.data as Record<string, unknown> | undefined;
expect(data?.exit_code).toBe(127);
// The real shell diagnostic (not a canned default) is carried through.
expect(String(data?.output ?? "")).toMatch(/not found|No such file/i);
});
it("surfaces a failing command (unreadable path) as success:false + result.error", async () => {
const runtime = await makeHostRuntime();
const result = await shellAction.handler?.(
runtime,
makeMessage(),
undefined,
{
command: "cat /no/such/path/eliza-12273-does-not-exist",
},
);
expect(result.success).toBe(false);
expect(result.error).toBeInstanceOf(Error);
const data = result.data as Record<string, unknown> | undefined;
expect(data?.exit_code).not.toBe(0);
expect(String(result.error?.message)).toContain("command exited with code");
});
});
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,212 @@
/** Tests for the FILE `edit` handler over the real filesystem, including the read-before-write staleness guard. */
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
CAPABILITY_ROUTER_SERVICE_TYPE,
type IAgentRuntime,
} from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setupEnv, type TestEnv } from "./_test-helpers.js";
import { editFileHandler } from "./edit.js";
describe("EDIT", () => {
let env: TestEnv;
beforeEach(async () => {
env = await setupEnv("edit-test");
});
afterEach(async () => {
await env.cleanup();
});
async function seedFile(name: string, content: string): Promise<string> {
const file = path.join(env.tmpDir, name);
await fs.writeFile(file, content, "utf8");
await env.fileState.recordRead("test-room", file);
return file;
}
it("replaces a unique substring and reports the line number", async () => {
const file = await seedFile("a.txt", "line one\nfoo bar\nline three");
const result = await editFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: file,
old_string: "foo bar",
new_string: "BAZ",
},
});
expect(result.success).toBe(true);
const onDisk = await fs.readFile(file, "utf8");
expect(onDisk).toBe("line one\nBAZ\nline three");
const data = result.data as Record<string, unknown> | undefined;
expect(data?.replacements).toBe(1);
expect(data?.firstLine).toBe(2);
expect(data?.addedLines).toBe(1);
expect(data?.removedLines).toBe(1);
// The edit confirmation is user-facing so it survives a post-tool evaluator
// failure via the deterministic relay.
expect(result.userFacingText).toBe(result.text);
expect(result.userFacingText).toContain("Replaced 1 occurrence in ");
});
it("keeps edit plugin-owned until fs.patch parity exists", async () => {
const file = await seedFile("routed.txt", "alpha\nbeta\ngamma");
const guardedRuntime = {
...env.runtime,
getService: <T>(serviceType: string): T | null => {
if (serviceType === CAPABILITY_ROUTER_SERVICE_TYPE) {
throw new Error("edit must not use the capability router yet");
}
return env.runtime.getService<T>(serviceType);
},
} as IAgentRuntime;
const result = await editFileHandler(
guardedRuntime,
env.message,
undefined,
{
parameters: {
file_path: file,
old_string: "beta",
new_string: "BETA",
},
},
);
expect(result.success).toBe(true);
expect(await fs.readFile(file, "utf8")).toBe("alpha\nBETA\ngamma");
});
it("fails on no_match when old_string isn't in the file", async () => {
const file = await seedFile("b.txt", "the quick brown fox");
const result = await editFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: file,
old_string: "zebra",
new_string: "lion",
},
});
expect(result.success).toBe(false);
expect(result.text).toContain("no_match");
});
it("rejects ambiguous matches when replace_all is false", async () => {
const file = await seedFile("c.txt", "alpha alpha alpha");
const result = await editFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: file,
old_string: "alpha",
new_string: "beta",
},
});
expect(result.success).toBe(false);
expect(result.text).toContain("ambiguous");
expect(result.text).toContain("3 matches");
});
it("replaces every occurrence with replace_all=true", async () => {
const file = await seedFile("d.txt", "x x x");
const result = await editFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: file,
old_string: "x",
new_string: "Y",
replace_all: true,
},
});
expect(result.success).toBe(true);
const onDisk = await fs.readFile(file, "utf8");
expect(onDisk).toBe("Y Y Y");
const data = result.data as Record<string, unknown> | undefined;
expect(data?.replacements).toBe(3);
});
it("rejects identical old_string and new_string", async () => {
const file = await seedFile("e.txt", "same content");
const result = await editFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: file,
old_string: "same",
new_string: "same",
},
});
expect(result.success).toBe(false);
expect(result.text).toContain("invalid_param");
expect(result.text).toContain("identical");
});
it("refuses edits that introduce a detected secret", async () => {
const file = await seedFile("f.txt", "API_KEY = REPLACE_ME");
const result = await editFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: file,
old_string: "REPLACE_ME",
new_string: "AKIAABCDEFGHIJKLMNOP",
},
});
expect(result.success).toBe(false);
expect(result.text).toContain("invalid_param");
expect(result.text).toContain("aws_access_key");
});
it("requires a prior READ (must_read_first)", async () => {
const file = path.join(env.tmpDir, "no-read.txt");
await fs.writeFile(file, "content here", "utf8");
const result = await editFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: file,
old_string: "content",
new_string: "stuff",
},
});
expect(result.success).toBe(false);
expect(result.text).toContain("not read in this session");
});
it("fails on stale_read when the file was modified externally", async () => {
const file = await seedFile("g.txt", "first");
await new Promise((r) => setTimeout(r, 20));
await fs.writeFile(file, "external edit", "utf8");
const result = await editFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: file,
old_string: "external",
new_string: "internal",
},
});
expect(result.success).toBe(false);
expect(result.text).toContain("stale_read");
});
it("rejects paths under the blocklist", async () => {
const file = path.join(env.blockedPath, "x.txt");
await fs.writeFile(file, "hello");
const result = await editFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: file,
old_string: "hello",
new_string: "world",
},
});
expect(result.success).toBe(false);
expect(result.text).toContain("path_blocked");
});
});
@@ -0,0 +1,195 @@
/**
* FILE `edit` handler: applies a find/replace edit to an existing file after
* validating the path and confirming (via FileStateService) it was not modified
* externally since the last read. Flags secrets in the new content before writing.
*/
import * as fs from "node:fs/promises";
import {
type ActionResult,
logger as coreLogger,
type HandlerCallback,
type IAgentRuntime,
type Memory,
type State,
} from "@elizaos/core";
import {
failureToActionResult,
readBoolParam,
readStringParam,
userFacingSuccessResult,
} from "../lib/format.js";
import { detectSecrets } from "../lib/secrets.js";
import type { FileStateService } from "../services/file-state-service.js";
import type { SandboxService } from "../services/sandbox-service.js";
import {
CODING_TOOLS_LOG_PREFIX,
FILE_STATE_SERVICE,
SANDBOX_SERVICE,
} from "../types.js";
function countOccurrences(haystack: string, needle: string): number {
if (needle.length === 0) return 0;
let count = 0;
let idx = 0;
while (idx < haystack.length) {
const foundAt = haystack.indexOf(needle, idx);
if (foundAt === -1) break;
count += 1;
idx = foundAt + needle.length;
}
return count;
}
function lineNumberOf(haystack: string, byteIndex: number): number {
if (byteIndex <= 0) return 1;
let line = 1;
for (let i = 0; i < byteIndex && i < haystack.length; i += 1) {
if (haystack.charCodeAt(i) === 10) line += 1;
}
return line;
}
function lineSpan(text: string): number {
return text.length === 0 ? 0 : text.split("\n").length;
}
export async function editFileHandler(
runtime: IAgentRuntime,
message: Memory,
_state: State | undefined,
options: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> {
const conversationId =
message.roomId !== undefined && message.roomId !== null
? String(message.roomId)
: undefined;
if (!conversationId) {
return failureToActionResult({
reason: "missing_param",
message: "no roomId",
});
}
const filePath = readStringParam(options, "file_path");
const oldStr = readStringParam(options, "old_string");
const newStr = readStringParam(options, "new_string");
const replaceAll = readBoolParam(options, "replace_all") ?? false;
if (!filePath || oldStr === undefined || newStr === undefined) {
return failureToActionResult({
reason: "missing_param",
message: "file_path, old_string, and new_string are required",
});
}
if (oldStr === newStr) {
return failureToActionResult({
reason: "invalid_param",
message: "old_string and new_string are identical; nothing to do",
});
}
const sandbox = runtime.getService(SANDBOX_SERVICE) as InstanceType<
typeof SandboxService
> | null;
const fileState = runtime.getService(FILE_STATE_SERVICE) as InstanceType<
typeof FileStateService
> | null;
if (!sandbox || !fileState) {
return failureToActionResult({
reason: "internal",
message: "coding-tools services unavailable",
});
}
const validated = await sandbox.validatePath(conversationId, filePath);
if (validated.ok === false) {
const reason =
validated.reason === "blocked" ? "path_blocked" : "invalid_param";
return failureToActionResult({ reason, message: validated.message });
}
const resolved = validated.resolved;
const gate = await fileState.assertWritable(conversationId, resolved);
if (gate.ok === false) {
const reason =
gate.reason === "stale_read" ? "stale_read" : "invalid_param";
return failureToActionResult({ reason, message: gate.message });
}
let original: string;
try {
original = await fs.readFile(resolved, "utf8");
} catch (err) {
// error-policy:J1 action boundary; a read failure becomes a success:false
// ActionResult carrying the real message, surfaced to the model.
const msg = err instanceof Error ? err.message : String(err);
return failureToActionResult({
reason: "io_error",
message: `read failed: ${msg}`,
});
}
const occurrences = countOccurrences(original, oldStr);
if (occurrences === 0) {
return failureToActionResult({
reason: "no_match",
message: `old_string not found in ${resolved}`,
});
}
if (!replaceAll && occurrences > 1) {
return failureToActionResult({
reason: "invalid_param",
message: `ambiguous: ${occurrences} matches; pass replace_all=true or extend old_string`,
});
}
const firstIndex = original.indexOf(oldStr);
const firstLine = lineNumberOf(original, firstIndex);
const updated = replaceAll
? original.split(oldStr).join(newStr)
: `${original.slice(0, firstIndex)}${newStr}${original.slice(firstIndex + oldStr.length)}`;
const replacements = replaceAll ? occurrences : 1;
const addedLines = lineSpan(newStr) * replacements;
const removedLines = lineSpan(oldStr) * replacements;
const secrets = detectSecrets(newStr);
if (secrets.length > 0) {
const names = secrets.map((s) => s.name).join(", ");
return failureToActionResult({
reason: "invalid_param",
message: `refusing to introduce content matching secret patterns: ${names}`,
});
}
try {
await fs.writeFile(resolved, updated, "utf8");
} catch (err) {
// error-policy:J1 action boundary; a write failure becomes a success:false
// ActionResult carrying the real message, surfaced to the model.
const msg = err instanceof Error ? err.message : String(err);
return failureToActionResult({
reason: "io_error",
message: `write failed: ${msg}`,
});
}
await fileState.recordWrite(conversationId, resolved);
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} EDIT ${resolved} replacements=${replacements} firstLine=${firstLine}`,
);
const text = `Replaced ${replacements} occurrence${replacements === 1 ? "" : "s"} in ${resolved} (first at line ${firstLine})`;
if (callback) await callback({ text, source: "coding-tools" });
return userFacingSuccessResult(text, {
path: resolved,
replacements,
firstLine,
addedLines,
removedLines,
});
}
@@ -0,0 +1,323 @@
/** Tests for the WORKTREE `enter` handler against a real git worktree in a temp repo. */
import { execFileSync } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
CAPABILITY_ROUTER_SERVICE_TYPE,
type ElizaCapabilityRouter,
type GitCommandRunParams,
type GitCommandRunResult,
type IAgentRuntime,
type Memory,
type State,
UnavailableCapabilityRouter,
} from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SandboxService } from "../services/sandbox-service.js";
import { SessionCwdService } from "../services/session-cwd-service.js";
import { SANDBOX_SERVICE, SESSION_CWD_SERVICE } from "../types.js";
import { enterWorktreeHandler } from "./enter-worktree.js";
interface TestEnv {
repoDir: string;
cleanupDirs: string[];
sandbox: SandboxService;
session: SessionCwdService;
runtime: IAgentRuntime;
conversationId: string;
}
async function initRepo(repoDir: string): Promise<void> {
await fs.mkdir(repoDir, { recursive: true });
const env = {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@t",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@t",
};
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, env });
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], {
cwd: repoDir,
env,
});
}
async function setupRepo(): Promise<TestEnv> {
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "ewt-repo-"));
await initRepo(repoDir);
const conversationId = "conv-enter-test";
const runtime = {
getSetting: (key: string) => {
if (key === "CODING_TOOLS_WORKSPACE_ROOTS") return repoDir;
return undefined;
},
} as IAgentRuntime;
const sandbox = await SandboxService.start(runtime);
const session = await SessionCwdService.start(runtime);
session.setCwd(conversationId, repoDir);
const services: Record<string, unknown> = {
[SANDBOX_SERVICE]: sandbox,
[SESSION_CWD_SERVICE]: session,
};
(runtime as { getService: (k: string) => unknown }).getService = (
key: string,
) => services[key] ?? null;
return {
repoDir,
cleanupDirs: [repoDir],
sandbox,
session,
runtime,
conversationId,
};
}
async function cleanupEnv(env: TestEnv | undefined): Promise<void> {
if (!env) return;
for (const dir of env.cleanupDirs) {
await fs.rm(dir, { recursive: true, force: true });
}
await env.sandbox.stop();
await env.session.stop();
}
function makeMessage(conversationId: string): Memory {
return { roomId: conversationId } as Memory;
}
function setCapabilityRouter(
env: TestEnv,
router: ElizaCapabilityRouter,
): void {
const getService = env.runtime.getService.bind(env.runtime);
(env.runtime as { getService: (k: string) => unknown }).getService = (
key: string,
) => (key === CAPABILITY_ROUTER_SERVICE_TYPE ? router : getService(key));
}
function makeGitRouter(
commandRun: (params: GitCommandRunParams) => Promise<GitCommandRunResult>,
): ElizaCapabilityRouter {
return {
environment: "desktop",
availability: async () => ({
environment: "desktop",
available: true,
capabilities: {
fs: false,
pty: false,
git: true,
model: false,
plugin: false,
},
}),
fs: {
list: async () => {
throw new Error("fs unavailable");
},
readText: async () => {
throw new Error("fs unavailable");
},
writeText: async () => {
throw new Error("fs unavailable");
},
},
pty: {
runCommand: async () => {
throw new Error("pty unavailable");
},
},
git: {
status: async () => {
throw new Error("git status unavailable");
},
diff: async () => {
throw new Error("git diff unavailable");
},
commandRun,
},
model: {
status: async () => {
throw new Error("model unavailable");
},
},
plugin: new UnavailableCapabilityRouter("desktop").plugin,
};
}
function gitCommandResult(params: GitCommandRunParams): GitCommandRunResult {
return {
operation: {
id: "git-op-routed",
name: "git.command.run",
cwd: params.root,
command: params.args,
status: "completed",
stdout: "",
stderr: "",
exitCode: 0,
signal: null,
startedAt: "2026-05-17T00:00:00.000Z",
completedAt: "2026-05-17T00:00:00.001Z",
},
};
}
const state: State | undefined = undefined;
describe("ENTER_WORKTREE", () => {
let env: TestEnv = undefined as TestEnv;
beforeEach(async () => {
env = await setupRepo();
});
afterEach(async () => {
await cleanupEnv(env);
});
it("creates a worktree, sets session cwd to it, and adds it as a sandbox root", async () => {
const result = await enterWorktreeHandler(
env.runtime,
makeMessage(env.conversationId),
state,
{ parameters: {} },
);
if (!result.success) throw new Error(result.text);
const data = result.data as Record<string, unknown> | undefined;
const worktreePath = data?.worktreePath as string | undefined;
expect(typeof worktreePath).toBe("string");
expect(worktreePath).toBeDefined();
if (!worktreePath) throw new Error("missing worktreePath");
env.cleanupDirs.push(worktreePath);
const stat = await fs.stat(worktreePath);
expect(stat.isDirectory()).toBe(true);
expect(env.session.getCwd(env.conversationId)).toBe(
path.resolve(worktreePath),
);
expect(typeof data?.branch).toBe("string");
expect(result.text).toContain("Entered worktree");
});
it("routes git worktree add through the capability router when available", async () => {
const calls: GitCommandRunParams[] = [];
setCapabilityRouter(
env,
makeGitRouter(async (params) => {
calls.push(params);
return gitCommandResult(params);
}),
);
const worktreePath = path.join(
await fs.realpath(env.repoDir),
"routed-worktree",
);
const result = await enterWorktreeHandler(
env.runtime,
makeMessage(env.conversationId),
state,
{
parameters: {
name: "routed-feature",
path: worktreePath,
base: "main",
},
},
);
if (!result.success) throw new Error(result.text);
expect(calls).toEqual([
{
root: env.repoDir,
args: ["worktree", "add", "-b", "routed-feature", worktreePath, "main"],
},
]);
expect(env.session.getCwd(env.conversationId)).toBe(
path.resolve(worktreePath),
);
});
it("uses the provided name when supplied", async () => {
const result = await enterWorktreeHandler(
env.runtime,
makeMessage(env.conversationId),
state,
{ parameters: { name: "feature-x" } },
);
expect(result.success).toBe(true);
const data = result.data as Record<string, unknown> | undefined;
expect(data?.branch).toBe("feature-x");
const worktreePath = data?.worktreePath as string;
env.cleanupDirs.push(worktreePath);
const branchOutput = execFileSync("git", ["branch", "--show-current"], {
cwd: worktreePath,
encoding: "utf8",
});
expect(branchOutput.trim()).toBe("feature-x");
});
it("fails with io_error when run from a non-git directory", async () => {
const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "ewt-nongit-"));
env.cleanupDirs.push(nonGitDir);
const conversationId = "conv-nongit";
const runtime = {
getSetting: (key: string) => {
if (key === "CODING_TOOLS_WORKSPACE_ROOTS") return nonGitDir;
return undefined;
},
} as IAgentRuntime;
const sandbox = await SandboxService.start(runtime);
const session = await SessionCwdService.start(runtime);
session.setCwd(conversationId, nonGitDir);
const services: Record<string, unknown> = {
[SANDBOX_SERVICE]: sandbox,
[SESSION_CWD_SERVICE]: session,
};
(runtime as { getService: (k: string) => unknown }).getService = (
key: string,
) => services[key] ?? null;
const result = await enterWorktreeHandler(
runtime,
makeMessage(conversationId),
state,
{
parameters: {},
},
);
expect(result.success).toBe(false);
expect(result.text).toContain("io_error");
await sandbox.stop();
await session.stop();
});
it("fails with missing_param when message has no roomId", async () => {
const result = await enterWorktreeHandler(
env.runtime,
{} as Memory,
state,
{ parameters: {} },
);
expect(result.success).toBe(false);
expect(result.text).toContain("missing_param");
});
});
@@ -0,0 +1,178 @@
/**
* WORKTREE `enter` handler: creates/attaches a git worktree, registers its root
* with SandboxService, and pushes it onto the SessionCwdService stack so
* subsequent glob/grep/ls/shell operations run inside it.
*/
import * as crypto from "node:crypto";
import * as os from "node:os";
import * as path from "node:path";
import {
type ActionResult,
logger as coreLogger,
type HandlerCallback,
type IAgentRuntime,
type Memory,
type State,
} from "@elizaos/core";
import {
failureToActionResult,
readStringParam,
successActionResult,
} from "../lib/format.js";
import { runGitCommand } from "../lib/run-git-command.js";
import type { SandboxService } from "../services/sandbox-service.js";
import type { SessionCwdService } from "../services/session-cwd-service.js";
import {
CODING_TOOLS_LOG_PREFIX,
SANDBOX_SERVICE,
SESSION_CWD_SERVICE,
} from "../types.js";
function generateWorktreeName(): string {
return `auto-${crypto.randomBytes(4).toString("hex")}`;
}
function generateWorktreePath(name: string): string {
return path.join(
os.tmpdir(),
`eliza-worktree-${name}-${crypto.randomBytes(3).toString("hex")}`,
);
}
function conversationIdFromMessage(message: Memory): string | undefined {
return message.roomId !== undefined && message.roomId !== null
? String(message.roomId)
: undefined;
}
function codingServices(runtime: IAgentRuntime): {
sandbox: InstanceType<typeof SandboxService>;
session: InstanceType<typeof SessionCwdService>;
} | null {
const sandbox = runtime.getService(SANDBOX_SERVICE) as InstanceType<
typeof SandboxService
> | null;
const session = runtime.getService(SESSION_CWD_SERVICE) as InstanceType<
typeof SessionCwdService
> | null;
return sandbox && session ? { sandbox, session } : null;
}
async function resolveWorktreePath(params: {
sandbox: InstanceType<typeof SandboxService>;
conversationId: string;
explicitPath: string | undefined;
name: string;
}): Promise<
| { ok: true; worktreePath: string }
| { ok: false; reason: "path_blocked" | "invalid_param"; message: string }
> {
if (!params.explicitPath) {
return {
ok: true,
worktreePath: path.resolve(generateWorktreePath(params.name)),
};
}
const validation = await params.sandbox.validatePath(
params.conversationId,
params.explicitPath,
);
if (validation.ok) return { ok: true, worktreePath: validation.resolved };
return {
ok: false,
reason: validation.reason === "blocked" ? "path_blocked" : "invalid_param",
message: validation.message,
};
}
function gitErrorMessage(err: unknown): string {
const stderr =
err && typeof err === "object" && "stderr" in err
? String((err as { stderr: unknown }).stderr ?? "")
: "";
const message = err instanceof Error ? err.message : String(err);
return stderr.trim() || message;
}
export async function enterWorktreeHandler(
runtime: IAgentRuntime,
message: Memory,
_state: State | undefined,
options: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> {
const conversationId = conversationIdFromMessage(message);
if (!conversationId) {
return failureToActionResult({
reason: "missing_param",
message: "no roomId",
});
}
const services = codingServices(runtime);
if (!services) {
return failureToActionResult({
reason: "internal",
message: "coding-tools services unavailable",
});
}
const name = readStringParam(options, "name") ?? generateWorktreeName();
const explicitPath = readStringParam(options, "path");
const base = readStringParam(options, "base") ?? "HEAD";
const resolved = await resolveWorktreePath({
sandbox: services.sandbox,
conversationId,
explicitPath,
name,
});
if (!resolved.ok) {
return failureToActionResult({
reason: resolved.reason,
message: resolved.message,
});
}
const worktreePath = resolved.worktreePath;
const cwd = services.session.getCwd(conversationId);
try {
const timeoutMs = 30_000;
await runGitCommand(runtime, {
cwd,
args: ["worktree", "add", "-b", name, worktreePath, base],
timeoutMs,
});
} catch (err) {
// error-policy:J1 action boundary; a failed `git worktree add` becomes a
// success:false ActionResult carrying the git error, surfaced to the model.
return failureToActionResult({
reason: "io_error",
message: `git worktree add failed: ${gitErrorMessage(err)}`,
});
}
services.sandbox.addRoot(conversationId, worktreePath);
services.session.pushWorktree(conversationId, worktreePath);
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} WORKTREE action=enter branch=${name} path=${worktreePath} base=${base}`,
);
const maxActionResultBytes = 2000;
const text =
`Entered worktree ${worktreePath} on branch ${name} (from ${base})`.slice(
0,
maxActionResultBytes,
);
if (callback) await callback({ text, source: "coding-tools" });
return successActionResult(text, {
worktreePath,
branch: name,
message: text,
});
}
@@ -0,0 +1,315 @@
/** Tests for the WORKTREE `exit` handler and the SessionCwdService stack pop. */
import { execFileSync } from "node:child_process";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
CAPABILITY_ROUTER_SERVICE_TYPE,
type ElizaCapabilityRouter,
type GitCommandRunParams,
type GitCommandRunResult,
type IAgentRuntime,
type Memory,
type State,
UnavailableCapabilityRouter,
} from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SandboxService } from "../services/sandbox-service.js";
import { SessionCwdService } from "../services/session-cwd-service.js";
import { SANDBOX_SERVICE, SESSION_CWD_SERVICE } from "../types.js";
import { enterWorktreeHandler } from "./enter-worktree.js";
import { exitWorktreeHandler } from "./exit-worktree.js";
interface TestEnv {
repoDir: string;
cleanupDirs: string[];
sandbox: SandboxService;
session: SessionCwdService;
runtime: IAgentRuntime;
conversationId: string;
}
async function initRepo(repoDir: string): Promise<void> {
await fs.mkdir(repoDir, { recursive: true });
const env = {
...process.env,
GIT_AUTHOR_NAME: "t",
GIT_AUTHOR_EMAIL: "t@t",
GIT_COMMITTER_NAME: "t",
GIT_COMMITTER_EMAIL: "t@t",
};
execFileSync("git", ["init", "-b", "main"], { cwd: repoDir, env });
execFileSync("git", ["commit", "--allow-empty", "-m", "init"], {
cwd: repoDir,
env,
});
}
async function setupRepo(): Promise<TestEnv> {
const repoDir = await fs.mkdtemp(path.join(os.tmpdir(), "xwt-repo-"));
await initRepo(repoDir);
const conversationId = "conv-exit-test";
const runtime = {
getSetting: (key: string) => {
if (key === "CODING_TOOLS_WORKSPACE_ROOTS") return repoDir;
return undefined;
},
} as IAgentRuntime;
const sandbox = await SandboxService.start(runtime);
const session = await SessionCwdService.start(runtime);
session.setCwd(conversationId, repoDir);
const services: Record<string, unknown> = {
[SANDBOX_SERVICE]: sandbox,
[SESSION_CWD_SERVICE]: session,
};
(runtime as { getService: (k: string) => unknown }).getService = (
key: string,
) => services[key] ?? null;
return {
repoDir,
cleanupDirs: [repoDir],
sandbox,
session,
runtime,
conversationId,
};
}
async function cleanupEnv(env: TestEnv | undefined): Promise<void> {
if (!env) return;
for (const dir of env.cleanupDirs) {
await fs.rm(dir, { recursive: true, force: true });
}
await env.sandbox.stop();
await env.session.stop();
}
function makeMessage(conversationId: string): Memory {
return { roomId: conversationId } as Memory;
}
function setCapabilityRouter(
env: TestEnv,
router: ElizaCapabilityRouter,
): void {
const getService = env.runtime.getService.bind(env.runtime);
(env.runtime as { getService: (k: string) => unknown }).getService = (
key: string,
) => (key === CAPABILITY_ROUTER_SERVICE_TYPE ? router : getService(key));
}
function makeGitRouter(
commandRun: (params: GitCommandRunParams) => Promise<GitCommandRunResult>,
): ElizaCapabilityRouter {
return {
environment: "desktop",
availability: async () => ({
environment: "desktop",
available: true,
capabilities: {
fs: false,
pty: false,
git: true,
model: false,
plugin: false,
},
}),
fs: {
list: async () => {
throw new Error("fs unavailable");
},
readText: async () => {
throw new Error("fs unavailable");
},
writeText: async () => {
throw new Error("fs unavailable");
},
},
pty: {
runCommand: async () => {
throw new Error("pty unavailable");
},
},
git: {
status: async () => {
throw new Error("git status unavailable");
},
diff: async () => {
throw new Error("git diff unavailable");
},
commandRun,
},
model: {
status: async () => {
throw new Error("model unavailable");
},
},
plugin: new UnavailableCapabilityRouter("desktop").plugin,
};
}
function gitCommandResult(params: GitCommandRunParams): GitCommandRunResult {
return {
operation: {
id: "git-op-routed",
name: "git.command.run",
cwd: params.root,
command: params.args,
status: "completed",
stdout: "",
stderr: "",
exitCode: 0,
signal: null,
startedAt: "2026-05-17T00:00:00.000Z",
completedAt: "2026-05-17T00:00:00.001Z",
},
};
}
async function enter(env: TestEnv): Promise<string> {
const result = await enterWorktreeHandler(
env.runtime,
makeMessage(env.conversationId),
undefined,
{ parameters: {} },
);
if (!result.success) throw new Error(`enter failed: ${result.text}`);
const data = result.data as Record<string, unknown> | undefined;
const worktreePath = data?.worktreePath as string;
env.cleanupDirs.push(worktreePath);
return worktreePath;
}
const state: State | undefined = undefined;
describe("EXIT_WORKTREE", () => {
let env: TestEnv = undefined as TestEnv;
beforeEach(async () => {
env = await setupRepo();
});
afterEach(async () => {
await cleanupEnv(env);
});
it("pops the most recent worktree, restores cwd, and removes the sandbox root", async () => {
const worktreePath = await enter(env);
expect(env.session.getCwd(env.conversationId)).toBe(
path.resolve(worktreePath),
);
const result = await exitWorktreeHandler(
env.runtime,
makeMessage(env.conversationId),
state,
{ parameters: {} },
);
expect(result.success).toBe(true);
const data = result.data as Record<string, unknown> | undefined;
expect(path.resolve(String(data?.exited))).toBe(path.resolve(worktreePath));
expect(path.resolve(String(data?.restoredTo))).toBe(
path.resolve(env.repoDir),
);
expect(data?.cleaned).toBe(false);
expect(env.session.getCwd(env.conversationId)).toBe(
path.resolve(env.repoDir),
);
const stillThere = await fs
.stat(worktreePath)
.then(() => true)
.catch(() => false);
expect(stillThere).toBe(true);
});
it("fails with invalid_param when no worktree is on the stack", async () => {
const result = await exitWorktreeHandler(
env.runtime,
makeMessage(env.conversationId),
state,
{ parameters: {} },
);
expect(result.success).toBe(false);
expect(result.text).toContain("invalid_param");
expect(result.text).toContain("no worktree to exit");
});
it("with cleanup=true removes the worktree directory", async () => {
const worktreePath = await enter(env);
const result = await exitWorktreeHandler(
env.runtime,
makeMessage(env.conversationId),
state,
{ parameters: { cleanup: true } },
);
expect(result.success).toBe(true);
const data = result.data as Record<string, unknown> | undefined;
expect(data?.cleaned).toBe(true);
const stillThere = await fs
.stat(worktreePath)
.then(() => true)
.catch(() => false);
expect(stillThere).toBe(false);
const list = execFileSync("git", ["worktree", "list", "--porcelain"], {
cwd: env.repoDir,
encoding: "utf8",
});
expect(list).not.toContain(worktreePath);
});
it("routes git worktree remove through the capability router when available", async () => {
const calls: GitCommandRunParams[] = [];
setCapabilityRouter(
env,
makeGitRouter(async (params) => {
calls.push(params);
return gitCommandResult(params);
}),
);
const worktreePath = path.join(env.repoDir, "routed-worktree");
env.session.pushWorktree(env.conversationId, worktreePath);
env.sandbox.addRoot(env.conversationId, worktreePath);
const result = await exitWorktreeHandler(
env.runtime,
makeMessage(env.conversationId),
state,
{ parameters: { cleanup: true } },
);
expect(result.success).toBe(true);
expect(calls).toEqual([
{
root: env.repoDir,
args: ["worktree", "remove", "--force", worktreePath],
},
]);
const data = result.data as Record<string, unknown> | undefined;
expect(data?.cleaned).toBe(true);
expect(path.resolve(String(data?.restoredTo))).toBe(
path.resolve(env.repoDir),
);
});
it("fails with missing_param when message has no roomId", async () => {
const result = await exitWorktreeHandler(env.runtime, {} as Memory, state, {
parameters: {},
});
expect(result.success).toBe(false);
expect(result.text).toContain("missing_param");
});
});
@@ -0,0 +1,122 @@
/**
* WORKTREE `exit` handler: pops the current worktree root off the SessionCwdService
* stack, returning subsequent operations to the previous working directory.
*/
import {
type ActionResult,
logger as coreLogger,
type HandlerCallback,
type IAgentRuntime,
type Memory,
type State,
} from "@elizaos/core";
import {
failureToActionResult,
readBoolParam,
successActionResult,
} from "../lib/format.js";
import { runGitCommand } from "../lib/run-git-command.js";
import type { SandboxService } from "../services/sandbox-service.js";
import type { SessionCwdService } from "../services/session-cwd-service.js";
import {
CODING_TOOLS_LOG_PREFIX,
SANDBOX_SERVICE,
SESSION_CWD_SERVICE,
} from "../types.js";
export async function exitWorktreeHandler(
runtime: IAgentRuntime,
message: Memory,
_state: State | undefined,
options: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> {
const conversationId =
message.roomId !== undefined && message.roomId !== null
? String(message.roomId)
: undefined;
if (!conversationId) {
return failureToActionResult({
reason: "missing_param",
message: "no roomId",
});
}
const sandbox = runtime.getService(SANDBOX_SERVICE) as InstanceType<
typeof SandboxService
> | null;
const session = runtime.getService(SESSION_CWD_SERVICE) as InstanceType<
typeof SessionCwdService
> | null;
if (!sandbox || !session) {
return failureToActionResult({
reason: "internal",
message: "coding-tools services unavailable",
});
}
const cleanup = readBoolParam(options, "cleanup") ?? false;
const popped = session.popWorktree(conversationId);
if (!popped) {
return failureToActionResult({
reason: "invalid_param",
message: "no worktree to exit",
});
}
sandbox.removeRoot(conversationId, popped.entered);
let cleaned = false;
if (cleanup) {
try {
const timeoutMs = 30_000;
await runGitCommand(runtime, {
cwd: popped.previousCwd,
args: ["worktree", "remove", "--force", popped.entered],
timeoutMs,
});
cleaned = true;
} catch (err) {
// error-policy:J1 action boundary; a failed `git worktree remove` becomes
// a success:false ActionResult carrying the stderr/message for the model.
const stderr =
err && typeof err === "object" && "stderr" in err
? String((err as { stderr: unknown }).stderr ?? "")
: "";
const msg = err instanceof Error ? err.message : String(err);
return failureToActionResult(
{
reason: "io_error",
message: stderr
? `git worktree remove failed: ${stderr.trim()}`
: `git worktree remove failed: ${msg}`,
},
{
exited: popped.entered,
restoredTo: popped.previousCwd,
cleaned: false,
},
);
}
}
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} WORKTREE action=exit from ${popped.entered} -> ${popped.previousCwd} cleaned=${cleaned}`,
);
const maxActionResultBytes = 2000;
const text = (
cleaned
? `Exited and removed worktree ${popped.entered}; cwd -> ${popped.previousCwd}`
: `Exited worktree ${popped.entered}; cwd -> ${popped.previousCwd}`
).slice(0, maxActionResultBytes);
if (callback) await callback({ text, source: "coding-tools" });
return successActionResult(text, {
exited: popped.entered,
restoredTo: popped.previousCwd,
cleaned,
});
}
@@ -0,0 +1,144 @@
/** Tests for the FILE umbrella action's operation dispatch, including the `device_filesystem` bridge path via a fake bridge service. */
import type { IAgentRuntime, Memory, Service } from "@elizaos/core";
import { describe, expect, it } from "vitest";
import { fileAction } from "./file.js";
interface FakeDeviceBridge extends Service {
read(path: string, encoding?: "utf8" | "base64"): Promise<string>;
write(
path: string,
content: string,
encoding?: "utf8" | "base64",
): Promise<void>;
list(path: string): Promise<{ name: string; type: "file" | "directory" }[]>;
}
function buildRuntime(bridge: FakeDeviceBridge): IAgentRuntime {
return {
getService: (serviceType: string) =>
serviceType === "device_filesystem" ? bridge : null,
getSetting: () => undefined,
} as IAgentRuntime;
}
describe("FILE target=device", () => {
const message = { roomId: "test-room" } as Memory;
it("routes reads through the device filesystem bridge", async () => {
const bridge = {
read: async (path: string, encoding?: "utf8" | "base64") =>
`${path}:${encoding}`,
write: async () => {},
list: async () => [],
} as FakeDeviceBridge;
const result = await fileAction.handler(
buildRuntime(bridge),
message,
undefined,
{ parameters: { action: "read", target: "device", path: "notes.txt" } },
);
expect(result.success).toBe(true);
expect(result.text).toContain("Read");
expect(result.data).toMatchObject({
action: "FILE",
target: "device",
operation: "read",
path: "notes.txt",
encoding: "utf8",
content: "notes.txt:utf8",
});
});
it("routes writes through the device filesystem bridge", async () => {
const writes: unknown[] = [];
const bridge = {
read: async () => "",
write: async (
path: string,
content: string,
encoding?: "utf8" | "base64",
) => {
writes.push({ path, content, encoding });
},
list: async () => [],
} as FakeDeviceBridge;
const result = await fileAction.handler(
buildRuntime(bridge),
message,
undefined,
{
parameters: {
action: "write",
scope: "device",
file_path: "docs/out.txt",
content: "hello",
encoding: "utf8",
},
},
);
expect(result.success).toBe(true);
expect(writes).toEqual([
{ path: "docs/out.txt", content: "hello", encoding: "utf8" },
]);
expect(result.data).toMatchObject({
action: "FILE",
target: "device",
operation: "write",
path: "docs/out.txt",
bytes: 5,
});
});
it("routes directory listing through FILE action=ls target=device", async () => {
const bridge = {
read: async () => "",
write: async () => {},
list: async (path: string) => [
{ name: `${path || "root"}.txt`, type: "file" as const },
{ name: "docs", type: "directory" as const },
],
} as FakeDeviceBridge;
const result = await fileAction.handler(
buildRuntime(bridge),
message,
undefined,
{ parameters: { action: "ls", target: "device", path: "" } },
);
expect(result.success).toBe(true);
expect(result.text).toContain("docs/");
expect(result.text).toContain("root.txt");
expect(result.data).toMatchObject({
action: "FILE",
target: "device",
operation: "ls",
path: "",
});
});
it("rejects unsupported device operations", async () => {
const bridge = {
read: async () => "",
write: async () => {},
list: async () => [],
} as FakeDeviceBridge;
const result = await fileAction.handler(
buildRuntime(bridge),
message,
undefined,
{ parameters: { action: "grep", target: "device", pattern: "x" } },
);
expect(result.success).toBe(false);
expect(result.text).toContain(
"target=device supports action=read/write/ls",
);
});
});
@@ -0,0 +1,481 @@
/**
* FILE umbrella action: a single agent-facing tool that dispatches to the
* read/write/edit/grep/glob/ls handlers by operation name. Reads and writes route
* through the local filesystem, or through a `device_filesystem` bridge service
* when `target=device` (mobile). Gated to coding contexts with ADMIN role.
*/
import type {
Action,
ActionResult,
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import {
failureToActionResult,
readStringParam,
successActionResult,
userFacingSuccessResult,
} from "../lib/format.js";
import { CODING_TOOLS_CONTEXTS } from "../types.js";
import { editFileHandler } from "./edit.js";
import { globHandler } from "./glob.js";
import { grepHandler } from "./grep.js";
import { lsHandler } from "./ls.js";
import { readFileHandler } from "./read.js";
import { summarizeFileOperation } from "./summaries.js";
import { writeFileHandler } from "./write.js";
const FILE_OPERATIONS = [
"read",
"write",
"edit",
"grep",
"glob",
"ls",
] as const;
type FileOperation = (typeof FILE_OPERATIONS)[number];
type FileTarget = "workspace" | "device";
const DEVICE_FILESYSTEM_SERVICE_TYPE = "device_filesystem";
const DEVICE_TARGET_VALUES = new Set([
"device",
"device_filesystem",
"device-filesystem",
"mobile",
"phone",
"local_device",
"local-device",
]);
type FileEncoding = "utf8" | "base64";
interface DeviceDirectoryEntry {
name: string;
type: "file" | "directory";
}
interface DeviceFilesystemBridgeLike {
read(path: string, encoding?: FileEncoding): Promise<string>;
write(path: string, content: string, encoding?: FileEncoding): Promise<void>;
list(path: string): Promise<DeviceDirectoryEntry[]>;
}
type FileHandler = (
runtime: IAgentRuntime,
message: Memory,
state: State | undefined,
options: HandlerOptions | undefined,
callback: HandlerCallback | undefined,
) => Promise<ActionResult>;
const FILE_ACTIONS: Record<FileOperation, FileHandler> = {
read: readFileHandler,
write: writeFileHandler,
edit: editFileHandler,
grep: grepHandler,
glob: globHandler,
ls: lsHandler,
};
const FILE_OPERATION_ALIASES: Record<string, FileOperation> = {
cat: "read",
open: "read",
search: "grep",
rg: "grep",
find: "glob",
list: "ls",
dir: "ls",
};
function readFileTarget(options: unknown): FileTarget {
for (const key of ["target", "scope", "source"]) {
const raw = readStringParam(options, key);
if (!raw) continue;
const normalized = raw.trim().toLowerCase();
if (DEVICE_TARGET_VALUES.has(normalized)) return "device";
}
return "workspace";
}
function readFileRouting(
options: unknown,
): { operation: FileOperation; target: FileTarget } | undefined {
const explicitTarget = readFileTarget(options);
const raw = readStringParam(options, "action");
if (!raw) return undefined;
const normalized = raw.trim().toLowerCase().replace(/-/g, "_");
if ((FILE_OPERATIONS as readonly string[]).includes(normalized)) {
return {
operation: normalized as FileOperation,
target: explicitTarget,
};
}
const alias = FILE_OPERATION_ALIASES[normalized];
if (alias) return { operation: alias, target: explicitTarget };
return undefined;
}
function getDeviceFilesystemBridge(
runtime: IAgentRuntime,
): DeviceFilesystemBridgeLike | null {
const service = runtime.getService(DEVICE_FILESYSTEM_SERVICE_TYPE) as unknown;
if (service && typeof service === "object") {
const candidate = service as Partial<DeviceFilesystemBridgeLike>;
if (
typeof candidate.read === "function" &&
typeof candidate.write === "function" &&
typeof candidate.list === "function"
) {
return candidate as DeviceFilesystemBridgeLike;
}
}
return null;
}
function readDevicePath(
options: unknown,
operation: FileOperation,
): string | undefined {
const path =
readStringParam(options, "path") ?? readStringParam(options, "file_path");
if (path !== undefined) return path;
return operation === "ls" ? "" : undefined;
}
function readDeviceEncoding(options: unknown): FileEncoding | undefined {
const encoding = readStringParam(options, "encoding");
if (encoding === undefined) return "utf8";
if (encoding === "utf8" || encoding === "base64") return encoding;
return undefined;
}
function renderDeviceEntries(
path: string,
entries: DeviceDirectoryEntry[],
): string {
if (entries.length === 0) {
return `(${path || "."}: empty)`;
}
const lines = entries
.slice()
.sort((a, b) => a.name.localeCompare(b.name))
.map((entry) =>
entry.type === "directory" ? `${entry.name}/` : entry.name,
);
return `${path || "."}:\n${lines.join("\n")}`;
}
async function deviceFileHandler(
operation: FileOperation,
runtime: IAgentRuntime,
options: unknown,
callback: HandlerCallback | undefined,
): Promise<ActionResult> {
if (operation !== "read" && operation !== "write" && operation !== "ls") {
return failureToActionResult({
reason: "invalid_param",
message: "FILE target=device supports action=read/write/ls",
});
}
const bridge = getDeviceFilesystemBridge(runtime);
if (!bridge) {
return failureToActionResult({
reason: "internal",
message: "device filesystem bridge service unavailable",
});
}
const path = readDevicePath(options, operation);
if (path === undefined || (operation !== "ls" && path.length === 0)) {
return failureToActionResult({
reason: "missing_param",
message: operation === "write" ? "path is required" : "path is required",
});
}
const encoding = readDeviceEncoding(options);
if (!encoding) {
return failureToActionResult({
reason: "invalid_param",
message: "encoding must be utf8 or base64",
});
}
if (operation === "read") {
const content = await bridge.read(path, encoding);
const bytes = Buffer.byteLength(content, encoding);
const text = `Read ${bytes} byte${bytes === 1 ? "" : "s"} from ${path}`;
if (callback) await callback({ text, source: "coding-tools" });
return successActionResult(text, {
action: "FILE",
target: "device",
operation,
path,
encoding,
bytes,
content,
});
}
if (operation === "write") {
const content = readStringParam(options, "content");
if (content === undefined) {
return failureToActionResult({
reason: "missing_param",
message: "content is required",
});
}
await bridge.write(path, content, encoding);
const bytes = Buffer.byteLength(content, encoding);
const text = `Wrote ${bytes} byte${bytes === 1 ? "" : "s"} to ${path}`;
if (callback) await callback({ text, source: "coding-tools" });
return userFacingSuccessResult(text, {
action: "FILE",
target: "device",
operation,
path,
encoding,
bytes,
});
}
const entries = await bridge.list(path);
const text = renderDeviceEntries(path, entries);
if (callback) await callback({ text, source: "coding-tools" });
return successActionResult(text, {
action: "FILE",
target: "device",
operation,
path,
entries,
});
}
export const fileAction: Action = {
name: "FILE",
contexts: [...CODING_TOOLS_CONTEXTS],
contextGate: { anyOf: [...CODING_TOOLS_CONTEXTS] },
roleGate: { minRole: "ADMIN" },
similes: ["FILE_OPERATION", "FILE_IO"],
description:
"Read, write, edit, grep, glob, or list files. Workspace paths are absolute unless the operation defaults to session cwd; target=device uses the device bridge.",
descriptionCompressed:
"File operations umbrella: action=read/write/edit/grep/glob/ls, optional target=device.",
parameters: [
{
name: "action",
description: "File operation to run.",
required: true,
schema: { type: "string", enum: [...FILE_OPERATIONS] },
},
{
name: "target",
description:
"target=device uses device-relative paths; omit for workspace.",
required: false,
schema: { type: "string", enum: ["workspace", "device"] },
},
{
name: "file_path",
description: "Absolute path for read/write/edit operations.",
required: false,
schema: { type: "string" },
},
{
name: "path",
description:
"Path for grep/glob/ls; defaults to session cwd when supported.",
required: false,
schema: { type: "string" },
},
{
name: "content",
description: "Full file contents for action=write.",
required: false,
schema: { type: "string" },
},
{
name: "old_string",
description: "Exact substring to replace for action=edit.",
required: false,
schema: { type: "string" },
},
{
name: "new_string",
description: "Replacement substring for action=edit.",
required: false,
schema: { type: "string" },
},
{
name: "replace_all",
description: "For action=edit: replace all matches, not exactly one.",
required: false,
schema: { type: "boolean" },
},
{
name: "pattern",
description: "Regex for action=grep or glob pattern for action=glob.",
required: false,
schema: { type: "string" },
},
{
name: "glob",
description: "Optional ripgrep glob filter for action=grep.",
required: false,
schema: { type: "string" },
},
{
name: "type",
description: "Optional ripgrep file type for action=grep.",
required: false,
schema: { type: "string" },
},
{
name: "output_mode",
description: "For action=grep: content, files_with_matches, or count.",
required: false,
schema: {
type: "string",
enum: ["content", "files_with_matches", "count"],
},
},
{
name: "-A",
description: "For action=grep content mode, lines after each match.",
required: false,
schema: { type: "number" },
},
{
name: "-B",
description: "For action=grep content mode, lines before each match.",
required: false,
schema: { type: "number" },
},
{
name: "-C",
description: "For action=grep content mode, lines around each match.",
required: false,
schema: { type: "number" },
},
{
name: "case_insensitive",
description: "For action=grep, match case-insensitively.",
required: false,
schema: { type: "boolean" },
},
{
name: "multiline",
description: "For action=grep, enable multiline regex matching.",
required: false,
schema: { type: "boolean" },
},
{
name: "head_limit",
description: "For action=grep, truncate output to the first N lines.",
required: false,
schema: { type: "number" },
},
{
name: "show_line_numbers",
description: "For action=grep: include 1-based line numbers.",
required: false,
schema: { type: "boolean" },
},
{
name: "offset",
description: "For action=read, zero-based line offset.",
required: false,
schema: { type: "number" },
},
{
name: "limit",
description: "For action=read, max number of lines to return.",
required: false,
schema: { type: "number" },
},
{
name: "ignore",
description: "For action=ls, glob patterns to exclude.",
required: false,
schema: { type: "array", items: { type: "string" } },
},
{
name: "encoding",
description:
"For target=device read/write: utf8 or base64. Default utf8.",
required: false,
schema: { type: "string", enum: ["utf8", "base64"] },
},
],
validate: async () => true,
summarize: (result, params) =>
result?.success === true ? summarizeFileOperation(params) : undefined,
handler: async (
runtime: IAgentRuntime,
message: Memory,
state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const routing = readFileRouting(options);
if (!routing) {
return failureToActionResult({
reason: "missing_param",
message: "FILE requires action=read/write/edit/grep/glob/ls",
});
}
const { operation, target } = routing;
if (target === "device") {
return deviceFileHandler(operation, runtime, options, callback);
}
const handler = FILE_ACTIONS[operation];
const result = await handler(
runtime,
message,
state,
options as HandlerOptions | undefined,
callback,
);
return result;
},
examples: [
[
{
name: "{{name1}}",
content: { text: "Read /tmp/app.ts.", source: "chat" },
},
{
name: "{{agentName}}",
content: {
text: "Read /tmp/app.ts.",
actions: ["FILE"],
thought:
"Reading a file maps to FILE with action=read and file_path.",
},
},
],
[
{
name: "{{name1}}",
content: {
text: "Find every TypeScript file under the repo.",
source: "chat",
},
},
{
name: "{{agentName}}",
content: {
text: "Found matching files.",
actions: ["FILE"],
thought:
"File discovery maps to FILE with action=glob, pattern, and path.",
},
},
],
],
};
@@ -0,0 +1,146 @@
/** Tests for the FILE `glob` handler over a real temp directory tree. */
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
CAPABILITY_ROUTER_SERVICE_TYPE,
type IAgentRuntime,
type Memory,
type State,
} from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SandboxService } from "../services/sandbox-service.js";
import { SessionCwdService } from "../services/session-cwd-service.js";
import { SANDBOX_SERVICE, SESSION_CWD_SERVICE } from "../types.js";
import { globHandler } from "./glob.js";
let tmpRoot: string;
let blockedPath: string;
interface RuntimeBundle {
runtime: IAgentRuntime;
message: Memory;
}
async function buildRuntime(): Promise<RuntimeBundle> {
const settings: Record<string, unknown> = {
CODING_TOOLS_BLOCKED_PATHS: blockedPath,
};
const runtimeSeed = {
getSetting: (key: string) => settings[key],
getService: <T>(_type: string): T | null => null,
} as IAgentRuntime;
const sandbox = await SandboxService.start(runtimeSeed);
const session = await SessionCwdService.start(runtimeSeed);
session.setCwd("test-room", tmpRoot);
const runtime = {
getSetting: (key: string) => settings[key],
getService: <T>(serviceType: string): T | null => {
if (serviceType === SANDBOX_SERVICE) return sandbox as T;
if (serviceType === SESSION_CWD_SERVICE) return session as T;
return null;
},
} as IAgentRuntime;
const message = { roomId: "test-room" } as Memory;
return { runtime, message };
}
beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "ct-glob-"));
blockedPath = path.join(tmpRoot, "_blocked");
await fs.mkdir(blockedPath, { recursive: true });
const fooDir = path.join(tmpRoot, "foo");
const subDir = path.join(fooDir, "sub");
await fs.mkdir(subDir, { recursive: true });
await fs.writeFile(path.join(fooDir, "a.ts"), "export const A = 1;\n");
await fs.writeFile(path.join(fooDir, "b.ts"), "export const B = 2;\n");
await fs.writeFile(path.join(subDir, "c.ts"), "export const C = 3;\n");
await fs.writeFile(path.join(fooDir, "notes.md"), "# notes\n");
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
const state: State | undefined = undefined;
describe("GLOB", () => {
it("matches **/*.ts and returns expected count", async () => {
const { runtime, message } = await buildRuntime();
const result = await globHandler(runtime, message, state, {
parameters: { pattern: "**/*.ts" },
});
expect(result.success).toBe(true);
const data = result.data as Record<string, unknown> | undefined;
const files = data?.files as string[] | undefined;
expect(Array.isArray(files)).toBe(true);
expect(files?.length).toBe(3);
const sortedNames = [...(files ?? [])].sort();
expect(sortedNames.some((p) => p.endsWith("a.ts"))).toBe(true);
expect(sortedNames.some((p) => p.endsWith("b.ts"))).toBe(true);
expect(sortedNames.some((p) => p.endsWith("c.ts"))).toBe(true);
expect(data?.truncated).toBe(false);
expect(result.text).toMatch(/3 files \(truncated=false\)/);
});
it("keeps glob plugin-owned until fs.glob parity exists", async () => {
const { runtime, message } = await buildRuntime();
const guardedRuntime = {
...runtime,
getService: <T>(serviceType: string): T | null => {
if (serviceType === CAPABILITY_ROUTER_SERVICE_TYPE) {
throw new Error("glob must not use the capability router yet");
}
return runtime.getService<T>(serviceType);
},
} as IAgentRuntime;
const result = await globHandler(guardedRuntime, message, state, {
parameters: { pattern: "**/*.ts" },
});
expect(result.success).toBe(true);
expect(result.text).toMatch(/3 files \(truncated=false\)/);
});
it("rejects a relative path", async () => {
const { runtime, message } = await buildRuntime();
const result = await globHandler(runtime, message, state, {
parameters: { pattern: "**/*.ts", path: "./foo" },
});
expect(result.success).toBe(false);
expect(result.text).toContain("invalid_param");
});
it("rejects a path under the blocklist", async () => {
const { runtime, message } = await buildRuntime();
const result = await globHandler(runtime, message, state, {
parameters: { pattern: "**/*", path: blockedPath },
});
expect(result.success).toBe(false);
expect(result.text).toContain("path_blocked");
});
it("fails when roomId is missing", async () => {
const { runtime } = await buildRuntime();
const result = await globHandler(runtime, {} as Memory, state, {
parameters: { pattern: "**/*.ts" },
});
expect(result.success).toBe(false);
expect(result.text).toContain("missing_param");
});
it("fails when pattern is missing", async () => {
const { runtime, message } = await buildRuntime();
const result = await globHandler(runtime, message, state, {
parameters: {},
});
expect(result.success).toBe(false);
expect(result.text).toContain("missing_param");
});
});
@@ -0,0 +1,253 @@
/**
* FILE `glob` handler: expands a glob pattern to matching file paths, rooted at an
* explicit path or the conversation's SessionCwdService cwd, with SandboxService
* validation on the search root.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
type ActionResult,
logger as coreLogger,
type HandlerCallback,
type IAgentRuntime,
type Memory,
type State,
} from "@elizaos/core";
import {
failureToActionResult,
readStringParam,
successActionResult,
} from "../lib/format.js";
import type { SandboxService } from "../services/sandbox-service.js";
import type { SessionCwdService } from "../services/session-cwd-service.js";
import {
CODING_TOOLS_LOG_PREFIX,
SANDBOX_SERVICE,
SESSION_CWD_SERVICE,
} from "../types.js";
const RESULT_LIMIT = 100;
const EXCLUDED_DIR_NAMES = new Set([
".git",
"node_modules",
"dist",
".turbo",
".cache",
]);
interface NodeFsGlobModule {
glob: (
pattern: string | string[],
options?: { cwd?: string; exclude?: (path: string) => boolean },
) => AsyncIterable<string>;
}
function getNodeFsGlob(): NodeFsGlobModule["glob"] | undefined {
const candidate = (fs as Partial<NodeFsGlobModule>).glob;
return typeof candidate === "function" ? candidate : undefined;
}
function globToRegExp(pattern: string): RegExp {
let regex = "";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === "*") {
if (pattern[i + 1] === "*") {
const after = pattern[i + 2];
if (after === "/") {
regex += "(?:.*/)?";
i += 3;
} else {
regex += ".*";
i += 2;
}
} else {
regex += "[^/]*";
i += 1;
}
} else if (ch === "?") {
regex += "[^/]";
i += 1;
} else if (ch === ".") {
regex += "\\.";
i += 1;
} else if ("+^$()|[]{}\\".includes(ch ?? "")) {
regex += `\\${ch}`;
i += 1;
} else {
regex += ch;
i += 1;
}
}
return new RegExp(`^${regex}$`);
}
async function walkFallback(root: string, pattern: string): Promise<string[]> {
const matcher = globToRegExp(pattern);
const results: string[] = [];
async function walk(dir: string): Promise<void> {
let names: string[];
try {
names = await fs.readdir(dir);
} catch {
// error-policy:J6 best-effort walk; a directory that became unreadable
// (permissions, race) is skipped so the remaining tree is still globbed.
return;
}
for (const name of names) {
if (EXCLUDED_DIR_NAMES.has(name)) continue;
const abs = path.join(dir, name);
try {
const st = await fs.lstat(abs);
if (st.isDirectory()) {
await walk(abs);
} else if (st.isFile()) {
const rel = path.relative(root, abs).split(path.sep).join("/");
if (matcher.test(rel)) {
results.push(abs);
}
}
} catch {
// error-policy:J6 best-effort per-entry lstat; an entry that vanished
// or is unreadable is skipped rather than aborting the whole walk.
}
}
}
await walk(root);
return results;
}
async function nodeGlob(
glob: NodeFsGlobModule["glob"],
root: string,
pattern: string,
): Promise<string[]> {
const out: string[] = [];
const iter = glob(pattern, {
cwd: root,
exclude: (p: string) => {
const segments = p.split(/[\\/]/);
return segments.some((seg) => EXCLUDED_DIR_NAMES.has(seg));
},
});
for await (const entry of iter) {
out.push(path.isAbsolute(entry) ? entry : path.join(root, entry));
}
return out;
}
export async function globHandler(
runtime: IAgentRuntime,
message: Memory,
_state: State | undefined,
options: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> {
const conversationId =
message.roomId !== undefined && message.roomId !== null
? String(message.roomId)
: undefined;
if (!conversationId) {
return failureToActionResult({
reason: "missing_param",
message: "no roomId",
});
}
const pattern = readStringParam(options, "pattern");
if (!pattern || pattern.length === 0) {
return failureToActionResult({
reason: "missing_param",
message: "pattern is required",
});
}
const sandbox = runtime.getService(SANDBOX_SERVICE) as InstanceType<
typeof SandboxService
> | null;
const session = runtime.getService(SESSION_CWD_SERVICE) as InstanceType<
typeof SessionCwdService
> | null;
if (!sandbox || !session) {
return failureToActionResult({
reason: "internal",
message: "coding-tools services unavailable",
});
}
const requestedPath = readStringParam(options, "path");
const targetPath =
requestedPath ?? (await session.getExistingCwd(conversationId)).cwd;
const validation = await sandbox.validatePath(conversationId, targetPath);
if (validation.ok === false) {
const reason =
validation.reason === "blocked" ? "path_blocked" : "invalid_param";
return failureToActionResult({ reason, message: validation.message });
}
const root = validation.resolved;
let candidates: string[];
const builtinGlob = getNodeFsGlob();
if (builtinGlob) {
try {
candidates = await nodeGlob(builtinGlob, root, pattern);
} catch (err) {
// error-policy:J4 designed degrade; the native `node:fs.glob` and the
// manual walker compute the same match set, so a native-glob failure
// falls back to the walker with a warning rather than failing the action.
const msg = err instanceof Error ? err.message : String(err);
coreLogger.warn(
`${CODING_TOOLS_LOG_PREFIX} GLOB node:fs.glob failed (${msg}); falling back to walker`,
);
candidates = await walkFallback(root, pattern);
}
} else {
candidates = await walkFallback(root, pattern);
}
const stats = await Promise.all(
candidates.map(async (filePath) => {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) return undefined;
return { filePath, mtimeMs: stat.mtimeMs };
} catch {
// error-policy:J6 best-effort mtime enrichment; a candidate that
// vanished between glob and stat drops out of the sorted result.
return undefined;
}
}),
);
const filtered = stats.filter(
(entry): entry is { filePath: string; mtimeMs: number } =>
entry !== undefined,
);
filtered.sort((a, b) => b.mtimeMs - a.mtimeMs);
const truncated = filtered.length > RESULT_LIMIT;
const limited = filtered
.slice(0, RESULT_LIMIT)
.map((entry) => entry.filePath);
const header = `${limited.length} files (truncated=${truncated})`;
const text =
limited.length === 0 ? header : `${header}\n${limited.join("\n")}`;
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} GLOB pattern=${JSON.stringify(pattern)} root=${root} found=${limited.length} truncated=${truncated}`,
);
if (callback) await callback({ text });
return successActionResult(text, {
files: limited,
truncated,
});
}
@@ -0,0 +1,241 @@
/** Tests for the FILE `grep` handler driving RipgrepService over a real temp workspace. */
import { existsSync } from "node:fs";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
CAPABILITY_ROUTER_SERVICE_TYPE,
type IAgentRuntime,
type Memory,
type State,
} from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { RipgrepService } from "../services/ripgrep-service.js";
import { SandboxService } from "../services/sandbox-service.js";
import { SessionCwdService } from "../services/session-cwd-service.js";
import {
RIPGREP_SERVICE,
SANDBOX_SERVICE,
SESSION_CWD_SERVICE,
} from "../types.js";
import { grepHandler } from "./grep.js";
function locateSystemRg(): string | undefined {
const candidates = [
"/opt/homebrew/bin/rg",
"/usr/local/bin/rg",
"/usr/bin/rg",
];
for (const c of candidates) {
if (existsSync(c)) return c;
}
return undefined;
}
let tmpRoot: string;
let blockedPath: string;
interface RuntimeBundle {
runtime: IAgentRuntime;
message: Memory;
}
async function buildRuntime(
rootOverride?: string,
settings: Record<string, unknown> = {},
): Promise<RuntimeBundle | null> {
const root = rootOverride ?? tmpRoot;
const mergedSettings = {
CODING_TOOLS_BLOCKED_PATHS: blockedPath,
...settings,
};
const runtimeSeed = {
getSetting: (key: string) => mergedSettings[key],
getService: <T>(_type: string): T | null => null,
} as IAgentRuntime;
const sandbox = await SandboxService.start(runtimeSeed);
const session = await SessionCwdService.start(runtimeSeed);
const rg = await RipgrepService.start(runtimeSeed);
// The bundled @vscode/ripgrep binary may be absent in dev installs; fall back
// to a system rg if so. If neither works, skip the test.
const initialBinary = rg.binary();
if (!existsSync(initialBinary)) {
const sysRg = locateSystemRg();
if (!sysRg) {
console.warn(
`no usable ripgrep found (tried ${initialBinary} and system paths); skipping`,
);
return null;
}
(rg as { rgPath: string }).rgPath = sysRg;
}
session.setCwd("test-room", root);
const runtime = {
getSetting: (key: string) => mergedSettings[key],
getService: <T>(serviceType: string): T | null => {
if (serviceType === SANDBOX_SERVICE) return sandbox as T;
if (serviceType === SESSION_CWD_SERVICE) return session as T;
if (serviceType === RIPGREP_SERVICE) return rg as T;
return null;
},
} as IAgentRuntime;
const message = { roomId: "test-room" } as Memory;
return { runtime, message };
}
beforeEach(async () => {
tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), "ct-grep-"));
blockedPath = path.join(tmpRoot, "_blocked");
await fs.mkdir(blockedPath, { recursive: true });
const fooDir = path.join(tmpRoot, "foo");
const subDir = path.join(fooDir, "sub");
await fs.mkdir(subDir, { recursive: true });
await fs.writeFile(path.join(fooDir, "a.ts"), "export const NEEDLE = 1;\n");
await fs.writeFile(path.join(fooDir, "b.ts"), "// nothing matches here\n");
await fs.writeFile(
path.join(subDir, "c.ts"),
"function needle() { return 'NEEDLE'; }\n",
);
await fs.writeFile(
path.join(fooDir, "notes.md"),
"Some markdown about NEEDLE.\n",
);
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
const state: State | undefined = undefined;
describe("GREP", () => {
it("returns matching files for a known token (default mode)", async () => {
const bundle = await buildRuntime();
if (!bundle) {
console.warn("no ripgrep available, skipping");
return;
}
const { runtime, message } = bundle;
const result = await grepHandler(runtime, message, state, {
parameters: { pattern: "NEEDLE" },
});
expect(result.success).toBe(true);
const data = result.data as Record<string, unknown> | undefined;
expect(data?.mode).toBe("files_with_matches");
expect(typeof data?.matches_count).toBe("number");
expect((data?.matches_count as number) >= 2).toBe(true);
expect(result.text).toContain("a.ts");
expect(result.text).toContain("notes.md");
});
it("keeps search plugin-owned until fs.search parity exists", async () => {
const bundle = await buildRuntime();
if (!bundle) {
console.warn("no ripgrep available, skipping");
return;
}
const { runtime, message } = bundle;
const guardedRuntime = {
...runtime,
getService: <T>(serviceType: string): T | null => {
if (serviceType === CAPABILITY_ROUTER_SERVICE_TYPE) {
throw new Error("grep must not use the capability router yet");
}
return runtime.getService<T>(serviceType);
},
} as IAgentRuntime;
const result = await grepHandler(guardedRuntime, message, state, {
parameters: { pattern: "NEEDLE" },
});
expect(result.success).toBe(true);
expect(result.text).toContain("a.ts");
});
it("matches case-insensitively when case_insensitive is true", async () => {
const bundle = await buildRuntime();
if (!bundle) {
console.warn("no ripgrep available, skipping");
return;
}
const { runtime, message } = bundle;
const sensitive = await grepHandler(runtime, message, state, {
parameters: { pattern: "needle", output_mode: "files_with_matches" },
});
expect(sensitive.success).toBe(true);
const sensitiveCount = (
sensitive.data as Record<string, unknown> | undefined
)?.matches_count as number;
const insensitive = await grepHandler(runtime, message, state, {
parameters: {
pattern: "needle",
output_mode: "files_with_matches",
case_insensitive: true,
},
});
expect(insensitive.success).toBe(true);
const insensitiveCount = (
insensitive.data as Record<string, unknown> | undefined
)?.matches_count as number;
expect(insensitiveCount).toBeGreaterThan(sensitiveCount);
});
it("rejects a path under the blocklist", async () => {
const bundle = await buildRuntime();
if (!bundle) {
console.warn("no ripgrep available, skipping");
return;
}
const { runtime, message } = bundle;
const result = await grepHandler(runtime, message, state, {
parameters: { pattern: "NEEDLE", path: blockedPath },
});
expect(result.success).toBe(false);
expect(result.text).toContain("path_blocked");
});
it("returns 'no matches' for an unmatched pattern", async () => {
const bundle = await buildRuntime();
if (!bundle) {
console.warn("no ripgrep available, skipping");
return;
}
const { runtime, message } = bundle;
const result = await grepHandler(runtime, message, state, {
parameters: { pattern: "ZZZ_DEFINITELY_NO_MATCH_ZZZ" },
});
expect(result.success).toBe(true);
expect(result.text).toContain("no matches");
expect(
(result.data as Record<string, unknown> | undefined)?.matches_count,
).toBe(0);
});
it("fails when roomId is missing", async () => {
const bundle = await buildRuntime();
if (!bundle) {
console.warn("no ripgrep available, skipping");
return;
}
const { runtime } = bundle;
const result = await grepHandler(runtime, {} as Memory, state, {
parameters: { pattern: "NEEDLE" },
});
expect(result.success).toBe(false);
expect(result.text).toContain("missing_param");
});
});
@@ -0,0 +1,202 @@
/**
* FILE `grep` handler: content search over the workspace via RipgrepService,
* rooted at an explicit path or the conversation's SessionCwdService cwd. Output is
* capped by `head_limit` (default `CODING_TOOLS_GREP_HEAD_LIMIT`).
*/
import {
type ActionResult,
logger as coreLogger,
type HandlerCallback,
type IAgentRuntime,
type Memory,
type State,
} from "@elizaos/core";
import {
failureToActionResult,
readBoolParam,
readNumberParam,
readPositiveIntSetting,
readStringParam,
successActionResult,
} from "../lib/format.js";
import type {
RipgrepMode,
RipgrepOptions,
RipgrepService,
} from "../services/ripgrep-service.js";
import type { SandboxService } from "../services/sandbox-service.js";
import type { SessionCwdService } from "../services/session-cwd-service.js";
import {
CODING_TOOLS_LOG_PREFIX,
RIPGREP_SERVICE,
SANDBOX_SERVICE,
SESSION_CWD_SERVICE,
} from "../types.js";
const DEFAULT_HEAD_LIMIT = 250;
function isValidMode(value: string | undefined): value is RipgrepMode {
return (
value === "content" || value === "files_with_matches" || value === "count"
);
}
export async function grepHandler(
runtime: IAgentRuntime,
message: Memory,
_state: State | undefined,
options: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> {
const conversationId =
message.roomId !== undefined && message.roomId !== null
? String(message.roomId)
: undefined;
if (!conversationId) {
return failureToActionResult({
reason: "missing_param",
message: "no roomId",
});
}
const pattern = readStringParam(options, "pattern");
if (!pattern || pattern.length === 0) {
return failureToActionResult({
reason: "missing_param",
message: "pattern is required",
});
}
const sandbox = runtime.getService(SANDBOX_SERVICE) as InstanceType<
typeof SandboxService
> | null;
const session = runtime.getService(SESSION_CWD_SERVICE) as InstanceType<
typeof SessionCwdService
> | null;
const rg = runtime.getService(RIPGREP_SERVICE) as InstanceType<
typeof RipgrepService
> | null;
if (!sandbox || !session || !rg) {
return failureToActionResult({
reason: "internal",
message: "coding-tools services unavailable",
});
}
try {
const requestedPath = readStringParam(options, "path");
const targetPath =
requestedPath ?? (await session.getExistingCwd(conversationId)).cwd;
const validation = await sandbox.validatePath(conversationId, targetPath);
if (validation.ok === false) {
const reason =
validation.reason === "blocked" ? "path_blocked" : "invalid_param";
return failureToActionResult({ reason, message: validation.message });
}
const resolved = validation.resolved;
const requestedMode = readStringParam(options, "output_mode");
const mode: RipgrepMode = isValidMode(requestedMode)
? requestedMode
: "files_with_matches";
const showLineNumbersParam = readBoolParam(options, "show_line_numbers");
const showLineNumbers = showLineNumbersParam ?? mode === "content";
const rgOptions: RipgrepOptions = {
pattern,
path: resolved,
showLineNumbers,
};
const glob = readStringParam(options, "glob");
if (glob !== undefined) rgOptions.glob = glob;
const type = readStringParam(options, "type");
if (type !== undefined) rgOptions.type = type;
const contextBefore = readNumberParam(options, "-B");
if (contextBefore !== undefined)
rgOptions.contextBefore = Math.max(0, Math.floor(contextBefore));
const contextAfter = readNumberParam(options, "-A");
if (contextAfter !== undefined)
rgOptions.contextAfter = Math.max(0, Math.floor(contextAfter));
const contextAround = readNumberParam(options, "-C");
if (contextAround !== undefined)
rgOptions.contextAround = Math.max(0, Math.floor(contextAround));
if (readBoolParam(options, "case_insensitive") === true)
rgOptions.caseInsensitive = true;
if (readBoolParam(options, "multiline") === true)
rgOptions.multiline = true;
const result = await rg.search(rgOptions, mode);
if (
result.exitCode === 1 &&
(mode === "content" || mode === "files_with_matches")
) {
const text = "no matches";
if (callback) await callback({ text, source: "coding-tools" });
return successActionResult(text, {
matches_count: 0,
mode,
truncated: false,
});
}
if (result.exitCode !== 0) {
return failureToActionResult({
reason: "command_failed",
message: `ripgrep exited ${result.exitCode}: ${result.output.slice(0, 500)}`,
});
}
const headLimitRequested = readNumberParam(options, "head_limit");
const headLimitDefault = readPositiveIntSetting(
runtime,
"CODING_TOOLS_GREP_HEAD_LIMIT",
DEFAULT_HEAD_LIMIT,
);
const headLimit =
headLimitRequested === undefined
? headLimitDefault
: Math.max(0, Math.floor(headLimitRequested));
const rawLines =
result.output.length === 0
? []
: result.output.replace(/\n$/, "").split("\n");
let outputLines = rawLines;
let headTruncated = false;
if (headLimit > 0 && rawLines.length > headLimit) {
outputLines = rawLines.slice(0, headLimit);
headTruncated = true;
}
const truncated = headTruncated || result.truncated;
const text =
outputLines.length === 0 ? "no matches" : outputLines.join("\n");
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} GREP pattern=${JSON.stringify(pattern)} mode=${mode} matches=${outputLines.length} truncated=${truncated}`,
);
if (callback) await callback({ text, source: "coding-tools" });
return successActionResult(text, {
matches_count: outputLines.length,
mode,
truncated,
});
} catch (error) {
// error-policy:J1 action boundary; any grep failure becomes a success:false
// ActionResult carrying the real message, surfaced to the model.
const messageText = error instanceof Error ? error.message : String(error);
return failureToActionResult({
reason: "internal",
message: `grep failed: ${messageText.slice(0, 500)}`,
});
}
}
@@ -0,0 +1,12 @@
/** Re-exports the FILE/SHELL/WORKTREE actions and their per-operation handlers. */
export { shellAction } from "./bash.js";
export { editFileHandler } from "./edit.js";
export { enterWorktreeHandler } from "./enter-worktree.js";
export { exitWorktreeHandler } from "./exit-worktree.js";
export { fileAction } from "./file.js";
export { globHandler } from "./glob.js";
export { grepHandler } from "./grep.js";
export { lsHandler } from "./ls.js";
export { readFileHandler } from "./read.js";
export { worktreeAction } from "./worktree.js";
export { writeFileHandler } from "./write.js";
@@ -0,0 +1,266 @@
/** Tests for the FILE `ls` handler over the real filesystem. */
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import {
CAPABILITY_ROUTER_SERVICE_TYPE,
CapabilityError,
type ElizaCapabilityRouter,
type FileListParams,
type IAgentRuntime,
type Memory,
type State,
UnavailableCapabilityRouter,
} from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SandboxService } from "../services/sandbox-service.js";
import { SessionCwdService } from "../services/session-cwd-service.js";
import { SANDBOX_SERVICE, SESSION_CWD_SERVICE } from "../types.js";
import { lsHandler } from "./ls.js";
let tmpRoot: string;
let blockedPath: string;
interface RuntimeBundle {
runtime: IAgentRuntime;
message: Memory;
}
function unavailableCapability(
capability: "fs" | "pty" | "git" | "model",
method: string,
): never {
throw new CapabilityError({
code: "CAPABILITY_UNAVAILABLE",
message: `${capability} unavailable`,
capability,
method,
});
}
function makeListRouter(
list: ElizaCapabilityRouter["fs"]["list"],
): ElizaCapabilityRouter {
const unavailable = new UnavailableCapabilityRouter("desktop");
return {
environment: "desktop",
availability: async () => ({
environment: "desktop",
available: true,
capabilities: {
fs: true,
pty: false,
git: false,
model: false,
},
}),
fs: {
list,
readText: async () => unavailableCapability("fs", "fs.readText"),
writeText: async () => unavailableCapability("fs", "fs.writeText"),
},
pty: {
runCommand: async () => unavailableCapability("pty", "pty.command.run"),
},
git: {
status: async () => unavailableCapability("git", "git.status"),
diff: async () => unavailableCapability("git", "git.diff"),
commandRun: async () => unavailableCapability("git", "git.command.run"),
},
model: {
status: async () => unavailableCapability("model", "model.status"),
},
plugin: unavailable.plugin,
};
}
async function buildRuntime(
capabilityRouter?: ElizaCapabilityRouter,
): Promise<RuntimeBundle> {
const settings: Record<string, unknown> = {
CODING_TOOLS_BLOCKED_PATHS: blockedPath,
};
const runtimeSeed = {
getSetting: (key: string) => settings[key],
getService: <T>(): T | null => null,
} as IAgentRuntime;
const sandbox = await SandboxService.start(runtimeSeed);
const session = await SessionCwdService.start(runtimeSeed);
session.setCwd("test-room", tmpRoot);
const runtime = {
getSetting: (key: string) => settings[key],
getService: <T>(serviceType: string): T | null => {
if (serviceType === CAPABILITY_ROUTER_SERVICE_TYPE && capabilityRouter) {
return capabilityRouter as T;
}
if (serviceType === SANDBOX_SERVICE) return sandbox as T;
if (serviceType === SESSION_CWD_SERVICE) return session as T;
return null;
},
} as IAgentRuntime;
const message = { roomId: "test-room" } as Memory;
return { runtime, message };
}
beforeEach(async () => {
tmpRoot = await fs.realpath(
await fs.mkdtemp(path.join(os.tmpdir(), "ct-ls-")),
);
blockedPath = path.join(tmpRoot, "_blocked");
await fs.mkdir(blockedPath, { recursive: true });
const fooDir = path.join(tmpRoot, "foo");
const barDir = path.join(tmpRoot, "bar");
await fs.mkdir(fooDir, { recursive: true });
await fs.mkdir(barDir, { recursive: true });
await fs.writeFile(path.join(tmpRoot, "alpha.ts"), "alpha\n");
await fs.writeFile(path.join(tmpRoot, "beta.md"), "beta\n");
await fs.writeFile(path.join(tmpRoot, "skip.log"), "noise\n");
});
afterEach(async () => {
await fs.rm(tmpRoot, { recursive: true, force: true });
});
const state: State | undefined = undefined;
describe("LS", () => {
it("lists fixture entries with directories first then files (sorted)", async () => {
const { runtime, message } = await buildRuntime();
const result = await lsHandler(runtime, message, state, {
parameters: {},
});
expect(result.success).toBe(true);
const data = result.data as Record<string, unknown> | undefined;
const entries = data?.entries as
| { name: string; type: string }[]
| undefined;
expect(Array.isArray(entries)).toBe(true);
expect(entries?.length).toBe(6);
const types = entries?.map((e) => e.type) ?? [];
const firstFileIndex = types.indexOf("file");
const lastDirIndex = types.lastIndexOf("dir");
expect(lastDirIndex).toBeLessThan(firstFileIndex);
const dirNames = (entries ?? [])
.filter((e) => e.type === "dir")
.map((e) => e.name);
expect(dirNames).toEqual(["_blocked", "bar", "foo"]);
const fileNames = (entries ?? [])
.filter((e) => e.type !== "dir")
.map((e) => e.name);
expect(fileNames).toEqual(["alpha.ts", "beta.md", "skip.log"]);
expect(result.text).toContain("Directory:");
expect(result.text).toContain("bar/");
expect(result.text).toContain("foo/");
expect(result.text).toContain("alpha.ts");
});
it("prefers capability router for directory listings when available", async () => {
const calls: FileListParams[] = [];
const router = makeListRouter(async (params) => {
calls.push(params);
return {
root: { id: "workspace", path: tmpRoot },
path: params.path ?? tmpRoot,
entries: [
{
path: path.join(tmpRoot, "foo"),
name: "foo",
kind: "directory",
size: 96,
},
{
path: path.join(tmpRoot, "routed.ts"),
name: "routed.ts",
kind: "file",
size: 12,
isText: true,
},
],
truncated: false,
totalAfterIgnore: 2,
};
});
const { runtime, message } = await buildRuntime(router);
const result = await lsHandler(runtime, message, state, {
parameters: { ignore: ["*.log"] },
});
expect(result.success).toBe(true);
expect(calls).toEqual([
{
path: tmpRoot,
limit: 1000,
includeHidden: true,
ignore: ["*.log"],
},
]);
const data = result.data as Record<string, unknown> | undefined;
const entries = data?.entries as
| { name: string; type: string }[]
| undefined;
expect(entries).toEqual([
{ name: "foo", type: "dir" },
{ name: "routed.ts", type: "file", size: 12 },
]);
expect(result.text).toContain("foo/");
expect(result.text).toContain("routed.ts");
expect(result.text).not.toContain("beta.md");
});
it("respects the ignore glob list", async () => {
const { runtime, message } = await buildRuntime();
const result = await lsHandler(runtime, message, state, {
parameters: { ignore: ["*.log"] },
});
expect(result.success).toBe(true);
const data = result.data as Record<string, unknown> | undefined;
const entries = data?.entries as { name: string }[] | undefined;
const names = entries?.map((e) => e.name) ?? [];
expect(names).not.toContain("skip.log");
expect(names).toContain("alpha.ts");
expect(names).toContain("beta.md");
});
it("rejects a path under the blocklist", async () => {
const { runtime, message } = await buildRuntime();
const result = await lsHandler(runtime, message, state, {
parameters: { path: blockedPath },
});
expect(result.success).toBe(false);
expect(result.text).toContain("path_blocked");
});
it("fails when roomId is missing", async () => {
const { runtime } = await buildRuntime();
const result = await lsHandler(runtime, {} as Memory, state, {
parameters: {},
});
expect(result.success).toBe(false);
expect(result.text).toContain("missing_param");
});
it("includes file size for files in the entries data", async () => {
const { runtime, message } = await buildRuntime();
const result = await lsHandler(runtime, message, state, {
parameters: {},
});
expect(result.success).toBe(true);
const data = result.data as Record<string, unknown> | undefined;
const entries = data?.entries as
| { name: string; type: string; size?: number }[]
| undefined;
const alpha = entries?.find((e) => e.name === "alpha.ts");
expect(alpha?.type).toBe("file");
expect(typeof alpha?.size).toBe("number");
});
});
@@ -0,0 +1,298 @@
/**
* FILE `ls` handler: lists a directory's entries after SandboxService validation,
* rooted at an explicit path or the conversation's SessionCwdService cwd. Supports
* the `device_filesystem` bridge for device targets.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
type ActionResult,
CapabilityError,
logger as coreLogger,
type FileListResult,
getCapabilityRouter,
type HandlerCallback,
type IAgentRuntime,
type Memory,
type State,
} from "@elizaos/core";
import {
failureToActionResult,
readArrayParam,
readStringParam,
successActionResult,
} from "../lib/format.js";
import type { SandboxService } from "../services/sandbox-service.js";
import type { SessionCwdService } from "../services/session-cwd-service.js";
import {
CODING_TOOLS_LOG_PREFIX,
SANDBOX_SERVICE,
SESSION_CWD_SERVICE,
} from "../types.js";
const ENTRY_LIMIT = 1000;
type EntryType = "file" | "dir" | "symlink";
interface LsEntry {
name: string;
type: EntryType;
size?: number;
}
function sortEntries(entries: LsEntry[]): LsEntry[] {
const dirEntries = entries
.filter((e) => e.type === "dir")
.sort((a, b) => a.name.localeCompare(b.name));
const fileEntries = entries
.filter((e) => e.type !== "dir")
.sort((a, b) => a.name.localeCompare(b.name));
return [...dirEntries, ...fileEntries];
}
function formatListText(params: {
dir: string;
entries: LsEntry[];
truncated: boolean;
totalAfterIgnore: number;
}): string {
const lines = [
`Directory: ${params.dir}`,
...params.entries.map((e) => (e.type === "dir" ? `${e.name}/` : e.name)),
];
if (params.truncated) {
lines.push(
`…[truncated, listed ${ENTRY_LIMIT} of ${params.totalAfterIgnore} entries]`,
);
}
return lines.join("\n");
}
function toLsEntry(entry: FileListResult["entries"][number]): LsEntry {
const type: EntryType =
entry.kind === "directory"
? "dir"
: entry.kind === "symlink"
? "symlink"
: "file";
if (type === "file") {
return { name: entry.name, type, size: entry.size };
}
return { name: entry.name, type };
}
async function listWithCapabilityRouter(params: {
runtime: IAgentRuntime;
dir: string;
ignore: string[];
}): Promise<
| { ok: true; payload: FileListResult }
| { ok: false; reason: "unavailable" | "failed"; message: string }
> {
const router = getCapabilityRouter(params.runtime);
if (!router) return { ok: false, reason: "unavailable", message: "" };
try {
const result = await router.fs.list({
path: params.dir,
limit: ENTRY_LIMIT,
includeHidden: true,
ignore: params.ignore,
});
return { ok: true, payload: result };
} catch (error) {
// error-policy:J1 capability-router boundary; the routed listing is
// translated into a typed failure DTO — CAPABILITY_UNAVAILABLE degrades to
// "unavailable", any other error to "failed" — never a fabricated payload.
if (
error instanceof CapabilityError &&
error.code === "CAPABILITY_UNAVAILABLE"
) {
return { ok: false, reason: "unavailable", message: error.message };
}
const message = error instanceof Error ? error.message : String(error);
return { ok: false, reason: "failed", message };
}
}
function globToRegExp(pattern: string): RegExp {
let regex = "";
let i = 0;
while (i < pattern.length) {
const ch = pattern[i];
if (ch === "*") {
if (pattern[i + 1] === "*") {
const after = pattern[i + 2];
if (after === "/") {
regex += "(?:.*/)?";
i += 3;
} else {
regex += ".*";
i += 2;
}
} else {
regex += "[^/]*";
i += 1;
}
} else if (ch === "?") {
regex += "[^/]";
i += 1;
} else if (ch === ".") {
regex += "\\.";
i += 1;
} else if ("+^$()|[]{}\\".includes(ch ?? "")) {
regex += `\\${ch}`;
i += 1;
} else {
regex += ch;
i += 1;
}
}
return new RegExp(`^${regex}$`);
}
export async function lsHandler(
runtime: IAgentRuntime,
message: Memory,
_state: State | undefined,
options: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> {
const conversationId =
message.roomId !== undefined && message.roomId !== null
? String(message.roomId)
: undefined;
if (!conversationId) {
return failureToActionResult({
reason: "missing_param",
message: "no roomId",
});
}
const sandbox = runtime.getService(SANDBOX_SERVICE) as InstanceType<
typeof SandboxService
> | null;
const session = runtime.getService(SESSION_CWD_SERVICE) as InstanceType<
typeof SessionCwdService
> | null;
if (!sandbox || !session) {
return failureToActionResult({
reason: "internal",
message: "coding-tools services unavailable",
});
}
const requestedPath = readStringParam(options, "path");
const targetPath =
requestedPath ?? (await session.getExistingCwd(conversationId)).cwd;
const validation = await sandbox.validatePath(conversationId, targetPath);
if (validation.ok === false) {
const reason =
validation.reason === "blocked" ? "path_blocked" : "invalid_param";
return failureToActionResult({ reason, message: validation.message });
}
const dir = validation.resolved;
const ignore = (readArrayParam(options, "ignore") ?? []).filter(
(entry): entry is string => typeof entry === "string" && entry.length > 0,
);
const routed = await listWithCapabilityRouter({ runtime, dir, ignore });
if (routed.ok) {
const sorted = sortEntries(routed.payload.entries.map(toLsEntry));
const text = formatListText({
dir: routed.payload.path,
entries: sorted,
truncated: routed.payload.truncated,
totalAfterIgnore: routed.payload.totalAfterIgnore,
});
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} LS dir=${routed.payload.path} count=${sorted.length} truncated=${routed.payload.truncated}`,
);
if (callback) await callback({ text, source: "coding-tools" });
return successActionResult(text, {
entries: sorted,
truncated: routed.payload.truncated,
});
}
if (routed.reason === "failed") {
return failureToActionResult({
reason: "io_error",
message: `fs.list failed: ${routed.message}`,
});
}
const ignoreMatchers: RegExp[] = ignore
.filter(
(entry): entry is string => typeof entry === "string" && entry.length > 0,
)
.map((entry) => globToRegExp(entry));
let names: string[];
try {
names = await fs.readdir(dir);
} catch (err) {
// error-policy:J1 action boundary; a readdir failure becomes a success:false
// ActionResult carrying the real message, surfaced to the model.
const msg = err instanceof Error ? err.message : String(err);
return failureToActionResult({
reason: "io_error",
message: `readdir failed: ${msg}`,
});
}
const filteredNames = names.filter(
(name) => !ignoreMatchers.some((re) => re.test(name)),
);
const totalAfterIgnore = filteredNames.length;
const truncated = totalAfterIgnore > ENTRY_LIMIT;
const limited = filteredNames.slice(0, ENTRY_LIMIT);
const enriched: LsEntry[] = [];
for (const name of limited) {
const joined = path.join(dir, name);
let type: EntryType = "file";
let size: number | undefined;
try {
const st = await fs.lstat(joined);
if (st.isDirectory()) {
type = "dir";
} else if (st.isSymbolicLink()) {
type = "symlink";
} else if (st.isFile()) {
type = "file";
size = st.size;
}
} catch {
// error-policy:J6 best-effort per-entry enrichment; a name that vanished
// between readdir and lstat (transient race) must not abort the whole
// listing. The entry name is still truthful; only its type/size default.
}
enriched.push(size === undefined ? { name, type } : { name, type, size });
}
const sorted = sortEntries(enriched);
const text = formatListText({
dir,
entries: sorted,
truncated,
totalAfterIgnore,
});
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} LS dir=${dir} count=${sorted.length} truncated=${truncated}`,
);
if (callback) await callback({ text, source: "coding-tools" });
return successActionResult(text, {
entries: sorted,
truncated,
});
}
@@ -0,0 +1,267 @@
/** Tests for the FILE `read` handler: line/size caps and read recording, over the real filesystem. */
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
CAPABILITY_ROUTER_SERVICE_TYPE,
CapabilityError,
type ElizaCapabilityRouter,
type IAgentRuntime,
UnavailableCapabilityRouter,
} from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setupEnv, type TestEnv } from "./_test-helpers.js";
import { readFileHandler } from "./read.js";
describe("READ", () => {
let env: TestEnv;
beforeEach(async () => {
env = await setupEnv("read-test");
});
afterEach(async () => {
await env.cleanup();
});
it("reads a small file and returns numbered lines", async () => {
const file = path.join(env.tmpDir, "hello.txt");
await fs.writeFile(file, "line one\nline two\nline three", "utf8");
const result = await readFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: file },
});
expect(result.success).toBe(true);
expect(result.text).toContain(file);
expect(result.text).toContain("\tline one");
expect(result.text).toContain("\tline two");
expect(result.text).toContain("\tline three");
const data = result.data as Record<string, unknown> | undefined;
expect(data?.totalLines).toBe(3);
expect(data?.lines).toBe(3);
});
it("right-pads line numbers to 6 chars and uses tab separator", async () => {
const file = path.join(env.tmpDir, "lines.txt");
await fs.writeFile(file, "alpha\nbeta", "utf8");
const result = await readFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: file },
});
expect(result.success).toBe(true);
expect(result.text).toContain(" 1\talpha");
expect(result.text).toContain(" 2\tbeta");
});
it("respects offset and limit and marks truncated", async () => {
const file = path.join(env.tmpDir, "long.txt");
const lines = Array.from({ length: 50 }, (_, i) => `line ${i + 1}`);
await fs.writeFile(file, lines.join("\n"), "utf8");
const result = await readFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: file, offset: 10, limit: 5 },
});
expect(result.success).toBe(true);
expect(result.text).toContain("\tline 11");
expect(result.text).toContain("\tline 15");
expect(result.text).not.toContain("\tline 10");
expect(result.text).not.toContain("\tline 16");
const data = result.data as Record<string, unknown> | undefined;
expect(data?.truncated).toBe(true);
});
it("records the read in FileStateService", async () => {
const file = path.join(env.tmpDir, "track.txt");
await fs.writeFile(file, "hello", "utf8");
const result = await readFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: file },
});
expect(result.success).toBe(true);
const data = result.data as Record<string, unknown> | undefined;
const resolved = String(data?.path);
const meta = env.fileState.get("test-room", resolved);
expect(meta).toBeDefined();
expect(meta?.path).toBe(resolved);
});
it("prefers capability router for file content when available", async () => {
const file = path.join(env.tmpDir, "routed.txt");
await fs.writeFile(file, "local file content", "utf8");
const calls: string[] = [];
const router: ElizaCapabilityRouter = {
environment: "desktop",
availability: async () => ({
environment: "desktop",
available: true,
capabilities: {
fs: true,
pty: false,
git: false,
model: false,
plugin: false,
},
}),
fs: {
list: async () => {
throw new CapabilityError({
code: "CAPABILITY_UNAVAILABLE",
message: "fs unavailable",
capability: "fs",
method: "fs.list",
});
},
readText: async (params) => {
calls.push(params.path);
return {
path: params.path,
text: "routed line one\nrouted line two",
size: 29,
truncated: false,
};
},
writeText: async () => {
throw new CapabilityError({
code: "CAPABILITY_UNAVAILABLE",
message: "fs unavailable",
capability: "fs",
method: "fs.writeText",
});
},
},
pty: {
runCommand: async () => {
throw new CapabilityError({
code: "CAPABILITY_UNAVAILABLE",
message: "terminal unavailable",
capability: "pty",
method: "pty.command.run",
});
},
},
git: {
status: async () => {
throw new CapabilityError({
code: "CAPABILITY_UNAVAILABLE",
message: "git unavailable",
capability: "git",
method: "git.status",
});
},
diff: async () => {
throw new CapabilityError({
code: "CAPABILITY_UNAVAILABLE",
message: "git unavailable",
capability: "git",
method: "git.diff",
});
},
commandRun: async () => {
throw new CapabilityError({
code: "CAPABILITY_UNAVAILABLE",
message: "git unavailable",
capability: "git",
method: "git.command.run",
});
},
},
model: {
status: async () => {
throw new CapabilityError({
code: "CAPABILITY_UNAVAILABLE",
message: "model unavailable",
capability: "model",
method: "model.status",
});
},
},
plugin: new UnavailableCapabilityRouter("desktop").plugin,
};
const runtime = {
...env.runtime,
getService: <T>(serviceType: string): T | null =>
serviceType === CAPABILITY_ROUTER_SERVICE_TYPE
? (router as T)
: env.runtime.getService<T>(serviceType),
} as IAgentRuntime;
const result = await readFileHandler(runtime, env.message, undefined, {
parameters: { file_path: file },
});
expect(result.success).toBe(true);
expect(result.text).toContain("routed line one");
expect(result.text).not.toContain("local file content");
expect(calls).toEqual([file]);
const meta = env.fileState.get("test-room", file);
expect(meta).toBeDefined();
});
it("rejects relative paths", async () => {
const result = await readFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: "relative/path.txt" },
});
expect(result.success).toBe(false);
expect(result.text).toContain("invalid_param");
});
it("rejects paths under the blocklist", async () => {
const file = path.join(env.blockedPath, "secret.txt");
await fs.writeFile(file, "data");
const result = await readFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: file },
});
expect(result.success).toBe(false);
expect(result.text).toContain("path_blocked");
});
it("rejects files larger than CODING_TOOLS_MAX_FILE_SIZE_BYTES", async () => {
const env2 = await setupEnv("read-big", {
extraSettings: { CODING_TOOLS_MAX_FILE_SIZE_BYTES: 32 },
});
try {
const file = path.join(env2.tmpDir, "big.txt");
await fs.writeFile(file, "x".repeat(64), "utf8");
const result = await readFileHandler(
env2.runtime,
env2.message,
undefined,
{
parameters: { file_path: file },
},
);
expect(result.success).toBe(false);
expect(result.text).toContain("io_error");
expect(result.text).toContain("offset/limit");
} finally {
await env2.cleanup();
}
});
it("rejects binary files containing NUL bytes", async () => {
const file = path.join(env.tmpDir, "binary.bin");
await fs.writeFile(file, Buffer.from([0x68, 0x69, 0x00, 0x21]));
const result = await readFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: file },
});
expect(result.success).toBe(false);
expect(result.text).toContain("binary file");
});
it("fails when roomId is missing", async () => {
const result = await readFileHandler(
env.runtime,
{} as typeof env.message,
undefined,
{ parameters: { file_path: path.join(env.tmpDir, "any.txt") } },
);
expect(result.success).toBe(false);
expect(result.text).toContain("roomId");
});
});
@@ -0,0 +1,273 @@
/**
* FILE `read` handler: returns file contents (line-numbered, size- and
* line-capped) after validating the path through SandboxService, and records the
* read with FileStateService so a later write/edit can detect external
* modification. Supports the `device_filesystem` bridge when reading device files.
*/
import * as fs from "node:fs/promises";
import {
type ActionResult,
CapabilityError,
logger as coreLogger,
getCapabilityRouter,
type HandlerCallback,
type IAgentRuntime,
type Memory,
type State,
} from "@elizaos/core";
import {
failureToActionResult,
readNumberParam,
readPositiveIntSetting,
readStringParam,
successActionResult,
} from "../lib/format.js";
import type { FileStateService } from "../services/file-state-service.js";
import type { SandboxService } from "../services/sandbox-service.js";
import {
CODING_TOOLS_LOG_PREFIX,
FILE_STATE_SERVICE,
SANDBOX_SERVICE,
} from "../types.js";
type ReadTextPayload = {
text: string;
size: number;
};
function formatLine(lineNumber: number, content: string): string {
return `${String(lineNumber).padStart(6, " ")}\t${content}`;
}
async function finalizeReadResult(params: {
runtime: IAgentRuntime;
callback?: HandlerCallback;
conversationId: string;
fileState: InstanceType<typeof FileStateService>;
resolved: string;
text: string;
options: unknown;
}): Promise<ActionResult> {
const lines = params.text.split("\n");
const totalLines = lines.length;
const offset = Math.max(
0,
Math.floor(readNumberParam(params.options, "offset") ?? 0),
);
const requestedLimit = readNumberParam(params.options, "limit");
const defaultLimit = readPositiveIntSetting(
params.runtime,
"CODING_TOOLS_MAX_READ_LINES",
2000,
);
const limit = Math.max(1, Math.floor(requestedLimit ?? defaultLimit));
const endExclusive = Math.min(totalLines, offset + limit);
const slice = lines.slice(offset, endExclusive);
const truncated = endExclusive < totalLines || offset > 0;
const formatted = [
params.resolved,
...slice.map((content, idx) => formatLine(offset + idx + 1, content)),
].join("\n");
await params.fileState.recordRead(params.conversationId, params.resolved);
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} READ ${params.resolved} offset=${offset} returned=${slice.length}/${totalLines}`,
);
if (params.callback) {
await params.callback({ text: formatted, source: "coding-tools" });
}
return successActionResult(formatted, {
path: params.resolved,
lines: slice.length,
totalLines,
offset,
truncated,
});
}
async function readWithCapabilityRouter(params: {
runtime: IAgentRuntime;
resolved: string;
maxBytes: number;
}): Promise<
| { ok: true; payload: ReadTextPayload }
| { ok: false; reason: "unavailable" | "failed"; message: string }
> {
const router = getCapabilityRouter(params.runtime);
if (!router) return { ok: false, reason: "unavailable", message: "" };
try {
const result = await router.fs.readText({
path: params.resolved,
maxBytes: params.maxBytes,
});
if (result.size > params.maxBytes || result.truncated) {
return {
ok: false,
reason: "failed",
message: `file size ${result.size} exceeds ${params.maxBytes}; use offset/limit to read in chunks`,
};
}
return {
ok: true,
payload: {
text: result.text,
size: result.size,
},
};
} catch (error) {
// error-policy:J1 capability-router boundary; the routed read is translated
// into a typed failure DTO — CAPABILITY_UNAVAILABLE degrades to
// "unavailable", any other error to "failed" — never a fabricated payload.
if (
error instanceof CapabilityError &&
error.code === "CAPABILITY_UNAVAILABLE"
) {
return { ok: false, reason: "unavailable", message: error.message };
}
const message = error instanceof Error ? error.message : String(error);
return { ok: false, reason: "failed", message };
}
}
export async function readFileHandler(
runtime: IAgentRuntime,
message: Memory,
_state: State | undefined,
options: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> {
const conversationId =
message.roomId !== undefined && message.roomId !== null
? String(message.roomId)
: undefined;
if (!conversationId) {
return failureToActionResult({
reason: "missing_param",
message: "no roomId",
});
}
const filePath = readStringParam(options, "file_path");
if (!filePath) {
return failureToActionResult({
reason: "missing_param",
message: "file_path is required",
});
}
const sandbox = runtime.getService(SANDBOX_SERVICE) as InstanceType<
typeof SandboxService
> | null;
const fileState = runtime.getService(FILE_STATE_SERVICE) as InstanceType<
typeof FileStateService
> | null;
if (!sandbox || !fileState) {
return failureToActionResult({
reason: "internal",
message: "coding-tools services unavailable",
});
}
const validated = await sandbox.validatePath(conversationId, filePath);
if (validated.ok === false) {
const reason =
validated.reason === "blocked" ? "path_blocked" : "invalid_param";
return failureToActionResult({ reason, message: validated.message });
}
const resolved = validated.resolved;
const maxBytes = readPositiveIntSetting(
runtime,
"CODING_TOOLS_MAX_FILE_SIZE_BYTES",
262_144,
);
const routed = await readWithCapabilityRouter({
runtime,
resolved,
maxBytes,
});
if (routed.ok) {
return finalizeReadResult({
runtime,
callback,
conversationId,
fileState,
resolved,
text: routed.payload.text,
options,
});
}
if (routed.reason === "failed") {
return failureToActionResult({
reason: "io_error",
message: routed.message,
});
}
let stat: Awaited<ReturnType<typeof fs.stat>>;
try {
stat = await fs.stat(resolved);
} catch (err) {
// error-policy:J1 action boundary; a stat failure becomes a success:false
// ActionResult carrying the real message, surfaced to the model.
const msg = err instanceof Error ? err.message : String(err);
return failureToActionResult({
reason: "io_error",
message: `stat failed: ${msg}`,
});
}
if (!stat.isFile()) {
return failureToActionResult({
reason: "invalid_param",
message: `path is not a regular file: ${resolved}`,
});
}
if (stat.size > maxBytes) {
return failureToActionResult({
reason: "io_error",
message: `file size ${stat.size} exceeds ${maxBytes}; use offset/limit to read in chunks`,
});
}
let buffer: Buffer;
try {
buffer = await fs.readFile(resolved);
} catch (err) {
// error-policy:J1 action boundary; a read failure becomes a success:false
// ActionResult carrying the real message, surfaced to the model.
const msg = err instanceof Error ? err.message : String(err);
return failureToActionResult({
reason: "io_error",
message: `read failed: ${msg}`,
});
}
if (buffer.includes(0)) {
return failureToActionResult({
reason: "io_error",
message: `binary file detected at ${resolved}; use SHELL+xxd or similar to inspect`,
});
}
const text = buffer.toString("utf8");
return finalizeReadResult({
runtime,
callback,
conversationId,
fileState,
resolved,
text,
options,
});
}
@@ -0,0 +1,42 @@
/** Unit tests for the operation-summary string helpers. */
import { describe, expect, it } from "vitest";
import {
compactSummaryText,
summarizeFileOperation,
summarizeShellCommand,
} from "./summaries.js";
describe("coding tool planner summaries", () => {
it("summarizes write and edit file operations", () => {
expect(
summarizeFileOperation({
action: "write",
file_path: "/workspace/src/app.ts",
}),
).toBe("wrote app.ts");
expect(
summarizeFileOperation({
action: "edit",
path: "/workspace/src/app.ts",
}),
).toBe("edited app.ts");
expect(
summarizeFileOperation({
action: "read",
file_path: "/workspace/src/app.ts",
}),
).toBeUndefined();
});
it("summarizes shell commands with bounded text", () => {
expect(summarizeShellCommand({ command: "bun test" })).toBe(
"ran `bun test`",
);
expect(
compactSummaryText(
"bun run test --filter very-long-package-name -- --reporter verbose",
20,
),
).toBe("bun run test --filt…");
});
});
@@ -0,0 +1,40 @@
/**
* Text helpers that turn a completed FILE or SHELL operation into a short
* human-readable summary line for action results. Pure string formatting, shared
* by the file and bash actions.
*/
export function basename(path: string): string {
return path.split("/").pop() || path;
}
export function compactSummaryText(text: string, maxLength: number): string {
const normalized = text.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) return normalized;
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trimEnd()}`;
}
export function summarizeFileOperation(
params: Record<string, unknown>,
): string | undefined {
const action = String(params.action ?? "").toLowerCase();
const rawPath = params.file_path ?? params.path;
const path = typeof rawPath === "string" ? basename(rawPath) : undefined;
if (!path) return undefined;
if (action === "write" || action === "create") {
return `wrote ${path}`;
}
if (action === "edit") {
return `edited ${path}`;
}
return undefined;
}
export function summarizeShellCommand(
params: Record<string, unknown>,
): string | undefined {
const command = params.command;
if (typeof command !== "string" || command.trim().length === 0) {
return undefined;
}
return `ran \`${compactSummaryText(command, 60)}\``;
}
@@ -0,0 +1,151 @@
/**
* WORKTREE umbrella action: dispatches enter/exit git-worktree operations to their
* handlers. Entering registers the new root in SandboxService and pushes it onto
* the SessionCwdService stack; exiting pops it. Gated to coding contexts with
* ADMIN role.
*/
import type {
Action,
ActionResult,
HandlerCallback,
HandlerOptions,
IAgentRuntime,
Memory,
State,
} from "@elizaos/core";
import { failureToActionResult, readStringParam } from "../lib/format.js";
import { CODING_TOOLS_CONTEXTS } from "../types.js";
import { enterWorktreeHandler } from "./enter-worktree.js";
import { exitWorktreeHandler } from "./exit-worktree.js";
const WORKTREE_OPERATIONS = ["enter", "exit"] as const;
type WorktreeOperation = (typeof WORKTREE_OPERATIONS)[number];
type WorktreeHandler = (
runtime: IAgentRuntime,
message: Memory,
state: State | undefined,
options: HandlerOptions | undefined,
callback: HandlerCallback | undefined,
) => Promise<ActionResult>;
const WORKTREE_ACTIONS: Record<WorktreeOperation, WorktreeHandler> = {
enter: enterWorktreeHandler,
exit: exitWorktreeHandler,
};
const WORKTREE_OPERATION_ALIASES: Record<string, WorktreeOperation> = {
add: "enter",
open: "enter",
create: "enter",
leave: "exit",
pop: "exit",
remove: "exit",
};
function readWorktreeOperation(
options: unknown,
): WorktreeOperation | undefined {
const raw = readStringParam(options, "action");
if (!raw) return undefined;
const normalized = raw.trim().toLowerCase().replace(/-/g, "_");
if ((WORKTREE_OPERATIONS as readonly string[]).includes(normalized)) {
return normalized as WorktreeOperation;
}
const alias = WORKTREE_OPERATION_ALIASES[normalized];
if (alias) return alias;
return undefined;
}
export const worktreeAction: Action = {
name: "WORKTREE",
contexts: [...CODING_TOOLS_CONTEXTS],
contextGate: { anyOf: [...CODING_TOOLS_CONTEXTS] },
roleGate: { minRole: "ADMIN" },
similes: ["GIT_WORKTREE"],
description:
"Manage the session git worktree stack: enter creates/switches, exit leaves and can remove it.",
descriptionCompressed: "Git worktree umbrella: action=enter/exit.",
parameters: [
{
name: "action",
description: "Worktree operation to run.",
required: true,
schema: { type: "string", enum: [...WORKTREE_OPERATIONS] },
},
{
name: "name",
description:
"For action=enter: worktree branch/dir name. Default auto-*.",
required: false,
schema: { type: "string" },
},
{
name: "path",
description:
"For action=enter: absolute worktree dir within sandbox roots.",
required: false,
schema: { type: "string" },
},
{
name: "base",
description: "For action=enter, optional base ref. Defaults to HEAD.",
required: false,
schema: { type: "string" },
},
{
name: "cleanup",
description: "For action=exit: remove popped worktree dir.",
required: false,
schema: { type: "boolean" },
},
],
validate: async () => true,
summarize: (result) =>
result?.success === true ? "managed a git worktree" : undefined,
handler: async (
runtime: IAgentRuntime,
message: Memory,
state?: State,
options?: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> => {
const operation = readWorktreeOperation(options);
if (!operation) {
return failureToActionResult({
reason: "missing_param",
message: "WORKTREE requires action=enter/exit",
});
}
const handler = WORKTREE_ACTIONS[operation];
const result = await handler(
runtime,
message,
state,
options as HandlerOptions | undefined,
callback,
);
return result;
},
examples: [
[
{
name: "{{name1}}",
content: {
text: "Enter a worktree for feature/login.",
source: "chat",
},
},
{
name: "{{agentName}}",
content: {
text: "Entered a new worktree.",
actions: ["WORKTREE"],
thought:
"Creating a git worktree maps to WORKTREE with action=enter.",
},
},
],
],
};
@@ -0,0 +1,225 @@
/** Tests for the FILE `write` handler over the real filesystem, including the writability guard and secret detection. */
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
CAPABILITY_ROUTER_SERVICE_TYPE,
CapabilityError,
type ElizaCapabilityRouter,
type FileWriteTextParams,
type IAgentRuntime,
UnavailableCapabilityRouter,
} from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setupEnv, type TestEnv } from "./_test-helpers.js";
import { writeFileHandler } from "./write.js";
function unavailableCapability(
capability: "fs" | "pty" | "git" | "model",
method: string,
): never {
throw new CapabilityError({
code: "CAPABILITY_UNAVAILABLE",
message: `${capability} unavailable`,
capability,
method,
});
}
function makeWriteRouter(
writeText: ElizaCapabilityRouter["fs"]["writeText"],
): ElizaCapabilityRouter {
return {
environment: "desktop",
availability: async () => ({
environment: "desktop",
available: true,
capabilities: {
fs: true,
pty: false,
git: false,
model: false,
plugin: false,
},
}),
fs: {
list: async () => unavailableCapability("fs", "fs.list"),
readText: async () => unavailableCapability("fs", "fs.readText"),
writeText,
},
pty: {
runCommand: async () => unavailableCapability("pty", "pty.command.run"),
},
git: {
status: async () => unavailableCapability("git", "git.status"),
diff: async () => unavailableCapability("git", "git.diff"),
commandRun: async () => unavailableCapability("git", "git.command.run"),
},
model: {
status: async () => unavailableCapability("model", "model.status"),
},
plugin: new UnavailableCapabilityRouter("desktop").plugin,
};
}
function runtimeWithRouter(
runtime: IAgentRuntime,
router: ElizaCapabilityRouter,
): IAgentRuntime {
return {
...runtime,
getService: <T>(serviceType: string): T | null =>
serviceType === CAPABILITY_ROUTER_SERVICE_TYPE
? (router as T)
: runtime.getService<T>(serviceType),
} as IAgentRuntime;
}
describe("WRITE", () => {
let env: TestEnv;
beforeEach(async () => {
env = await setupEnv("write-test");
});
afterEach(async () => {
await env.cleanup();
});
it("creates a new file and its parent directory", async () => {
const file = path.join(env.tmpDir, "nested", "deeper", "out.txt");
const result = await writeFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: file, content: "hello world" },
});
expect(result.success).toBe(true);
const onDisk = await fs.readFile(file, "utf8");
expect(onDisk).toBe("hello world");
const data = result.data as Record<string, unknown> | undefined;
expect(data?.bytes).toBe(11);
// The write confirmation is user-facing so the planner-loop relays it
// verbatim when the post-tool evaluator model call fails (no regression on
// the elizaOS Cloud 400 incident).
expect(result.userFacingText).toBe(result.text);
expect(result.userFacingText).toContain("Wrote 11 bytes to ");
});
it("prefers capability router for file writes when available", async () => {
const file = path.join(env.tmpDir, "nested", "routed.txt");
const calls: FileWriteTextParams[] = [];
const router = makeWriteRouter(async (params) => {
calls.push(params);
await fs.mkdir(path.dirname(params.path), { recursive: true });
await fs.writeFile(params.path, params.text, "utf8");
return {
path: params.path,
bytesWritten: Buffer.byteLength(params.text, "utf8"),
};
});
const result = await writeFileHandler(
runtimeWithRouter(env.runtime, router),
env.message,
undefined,
{ parameters: { file_path: file, content: "routed write" } },
);
expect(result.success).toBe(true);
expect(await fs.readFile(file, "utf8")).toBe("routed write");
expect(calls).toEqual([
{
path: file,
text: "routed write",
createDirectories: true,
overwrite: true,
},
]);
const meta = env.fileState.get("test-room", file);
expect(meta).toBeDefined();
});
it("rejects writes to existing files that were not READ first (must_read_first)", async () => {
const file = path.join(env.tmpDir, "preexisting.txt");
await fs.writeFile(file, "original", "utf8");
const result = await writeFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: file, content: "overwrite" },
});
expect(result.success).toBe(false);
expect(result.text).toContain("not read in this session");
const onDisk = await fs.readFile(file, "utf8");
expect(onDisk).toBe("original");
});
it("allows overwriting after a previous recordRead with matching mtime", async () => {
const file = path.join(env.tmpDir, "tracked.txt");
await fs.writeFile(file, "original", "utf8");
await env.fileState.recordRead("test-room", file);
const result = await writeFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: file, content: "fresh" },
});
expect(result.success).toBe(true);
const onDisk = await fs.readFile(file, "utf8");
expect(onDisk).toBe("fresh");
});
it("rejects stale reads when the file was modified externally", async () => {
const file = path.join(env.tmpDir, "stale.txt");
await fs.writeFile(file, "original", "utf8");
await env.fileState.recordRead("test-room", file);
// bump mtime
await new Promise((r) => setTimeout(r, 20));
await fs.writeFile(file, "external edit", "utf8");
const result = await writeFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: file, content: "agent overwrite" },
});
expect(result.success).toBe(false);
expect(result.text).toContain("stale_read");
});
it("refuses to write content containing detected secret patterns", async () => {
const file = path.join(env.tmpDir, "secret.txt");
const result = await writeFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: file,
content: "AKIAABCDEFGHIJKLMNOP",
},
});
expect(result.success).toBe(false);
expect(result.text).toContain("invalid_param");
expect(result.text).toContain("aws_access_key");
await expect(fs.access(file)).rejects.toBeDefined();
});
it("rejects relative paths", async () => {
const result = await writeFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: "rel/path.txt", content: "x" },
});
expect(result.success).toBe(false);
expect(result.text).toContain("invalid_param");
});
it("rejects paths under the blocklist", async () => {
const result = await writeFileHandler(env.runtime, env.message, undefined, {
parameters: {
file_path: path.join(env.blockedPath, "x.txt"),
content: "x",
},
});
expect(result.success).toBe(false);
expect(result.text).toContain("path_blocked");
});
it("fails when content param is missing", async () => {
const result = await writeFileHandler(env.runtime, env.message, undefined, {
parameters: { file_path: path.join(env.tmpDir, "x.txt") },
});
expect(result.success).toBe(false);
expect(result.text).toContain("missing_param");
});
});
@@ -0,0 +1,187 @@
/**
* FILE `write` handler: writes full file contents after a SandboxService path
* check and a FileStateService writability check (rejects if the file changed
* since the last read). Flags secrets in the payload via lib/secrets before
* writing. Supports the `device_filesystem` bridge for device targets.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
type ActionResult,
CapabilityError,
logger as coreLogger,
getCapabilityRouter,
type HandlerCallback,
type IAgentRuntime,
type Memory,
type State,
} from "@elizaos/core";
import {
failureToActionResult,
readStringParam,
userFacingSuccessResult,
} from "../lib/format.js";
import { detectSecrets } from "../lib/secrets.js";
import type { FileStateService } from "../services/file-state-service.js";
import type { SandboxService } from "../services/sandbox-service.js";
import {
CODING_TOOLS_LOG_PREFIX,
FILE_STATE_SERVICE,
SANDBOX_SERVICE,
} from "../types.js";
async function writeWithCapabilityRouter(params: {
runtime: IAgentRuntime;
resolved: string;
content: string;
}): Promise<
| { ok: true; bytesWritten: number }
| { ok: false; reason: "unavailable" | "failed"; message: string }
> {
const router = getCapabilityRouter(params.runtime);
if (!router) return { ok: false, reason: "unavailable", message: "" };
try {
const result = await router.fs.writeText({
path: params.resolved,
text: params.content,
createDirectories: true,
overwrite: true,
});
return { ok: true, bytesWritten: result.bytesWritten };
} catch (error) {
// error-policy:J1 capability-router boundary; the routed write is translated
// into a typed failure DTO — CAPABILITY_UNAVAILABLE degrades to
// "unavailable", any other error to "failed" — never a fabricated success.
if (
error instanceof CapabilityError &&
error.code === "CAPABILITY_UNAVAILABLE"
) {
return { ok: false, reason: "unavailable", message: error.message };
}
const message = error instanceof Error ? error.message : String(error);
return { ok: false, reason: "failed", message };
}
}
export async function writeFileHandler(
runtime: IAgentRuntime,
message: Memory,
_state: State | undefined,
options: unknown,
callback?: HandlerCallback,
): Promise<ActionResult> {
const conversationId =
message.roomId !== undefined && message.roomId !== null
? String(message.roomId)
: undefined;
if (!conversationId) {
return failureToActionResult({
reason: "missing_param",
message: "no roomId",
});
}
const filePath = readStringParam(options, "file_path");
const content = readStringParam(options, "content");
if (!filePath) {
return failureToActionResult({
reason: "missing_param",
message: "file_path is required",
});
}
if (content === undefined) {
return failureToActionResult({
reason: "missing_param",
message: "content is required",
});
}
const sandbox = runtime.getService(SANDBOX_SERVICE) as InstanceType<
typeof SandboxService
> | null;
const fileState = runtime.getService(FILE_STATE_SERVICE) as InstanceType<
typeof FileStateService
> | null;
if (!sandbox || !fileState) {
return failureToActionResult({
reason: "internal",
message: "coding-tools services unavailable",
});
}
const validated = await sandbox.validatePath(conversationId, filePath);
if (validated.ok === false) {
const reason =
validated.reason === "blocked" ? "path_blocked" : "invalid_param";
return failureToActionResult({ reason, message: validated.message });
}
const resolved = validated.resolved;
const gate = await fileState.assertWritable(conversationId, resolved);
if (gate.ok === false) {
const reason =
gate.reason === "stale_read" ? "stale_read" : "invalid_param";
return failureToActionResult({ reason, message: gate.message });
}
const secrets = detectSecrets(content);
if (secrets.length > 0) {
const names = secrets.map((s) => s.name).join(", ");
return failureToActionResult({
reason: "invalid_param",
message: `refusing to write content containing detected secret patterns: ${names}`,
});
}
const routed = await writeWithCapabilityRouter({
runtime,
resolved,
content,
});
if (routed.ok === false && routed.reason === "failed") {
return failureToActionResult({
reason: "io_error",
message: `write failed: ${routed.message}`,
});
}
if (routed.ok === false) {
try {
await fs.mkdir(path.dirname(resolved), { recursive: true });
await fs.writeFile(resolved, content, "utf8");
} catch (err) {
// error-policy:J1 action boundary; the direct-write failure becomes a
// success:false ActionResult carrying the real message for the model.
const msg = err instanceof Error ? err.message : String(err);
return failureToActionResult({
reason: "io_error",
message: `write failed: ${msg}`,
});
}
}
await fileState.recordWrite(conversationId, resolved);
const bytes =
routed.ok === true
? routed.bytesWritten
: Buffer.byteLength(content, "utf8");
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} WRITE ${resolved} bytes=${bytes}`,
);
const maxActionResultBytes = 2000;
const text =
`Wrote ${bytes} byte${bytes === 1 ? "" : "s"} to ${resolved}`.slice(
0,
maxActionResultBytes,
);
if (callback) await callback({ text, source: "coding-tools" });
return userFacingSuccessResult(text, {
path: resolved,
bytes,
});
}
+99
View File
@@ -0,0 +1,99 @@
/**
* Plugin entry for @elizaos/plugin-coding-tools: assembles the FILE, SHELL, and
* WORKTREE actions, the AVAILABLE_CODING_TOOLS provider, and the sandbox /
* file-state / session-cwd / ripgrep services into the `codingToolsPlugin`
* object, and declares the auto-enable predicate that turns the plugin on only
* when a coding feature flag is set and the environment supports a terminal.
* `terminalSupportedByEnv` mirrors the gating in auto-enable.ts (disabled on the
* store build variant and iOS; Android only in local-yolo mode). Also re-exports
* the services and types for external consumers.
*/
import type { Plugin } from "@elizaos/core";
import { fileAction, shellAction, worktreeAction } from "./actions/index.js";
import { availableToolsProvider } from "./providers/available-tools.js";
import {
FileStateService,
RipgrepService,
SandboxService,
SessionCwdService,
} from "./services/index.js";
function terminalSupportedByEnv(
env: Record<string, string | undefined>,
): boolean {
const variant = (env.ELIZA_BUILD_VARIANT ?? "").trim().toLowerCase();
if (variant === "store") return false;
const platform = env.ELIZA_PLATFORM?.trim().toLowerCase();
const mobile =
platform === "android" ||
platform === "ios" ||
Boolean(env.ANDROID_ROOT || env.ANDROID_DATA);
if (!mobile) return true;
const mode = (
env.ELIZA_RUNTIME_MODE ??
env.RUNTIME_MODE ??
env.LOCAL_RUNTIME_MODE ??
""
)
.trim()
.toLowerCase();
return platform === "android" && mode === "local-yolo";
}
export const codingToolsPlugin: Plugin = {
name: "coding-tools",
description:
"Native coding tools: FILE read/write/edit/grep/glob/ls, SHELL commands/history, WORKTREE enter/exit. Absolute workspace paths unless an operation defaults to session cwd; private/system paths are blocked.",
services: [
FileStateService,
SandboxService,
SessionCwdService,
RipgrepService,
],
providers: [availableToolsProvider],
actions: [fileAction, shellAction, worktreeAction],
async dispose(runtime) {
await runtime
.getService<SandboxService>(SandboxService.serviceType)
?.stop();
await runtime
.getService<FileStateService>(FileStateService.serviceType)
?.stop();
await runtime
.getService<SessionCwdService>(SessionCwdService.serviceType)
?.stop();
await runtime
.getService<RipgrepService>(RipgrepService.serviceType)
?.stop();
},
// Self-declared auto-enable: activate when features.codingTools is enabled,
// or via the legacy "coding-agent" feature key kept as an alias.
autoEnable: {
shouldEnable: (env, config) => {
const features = config.features as Record<string, unknown> | undefined;
const isFeatureEnabled = (f: unknown) =>
f === true ||
(typeof f === "object" &&
f !== null &&
(f as { enabled?: unknown }).enabled !== false);
return (
(isFeatureEnabled(features?.codingTools) ||
isFeatureEnabled(features?.["coding-agent"])) &&
terminalSupportedByEnv(env as Record<string, string | undefined>)
);
},
},
};
export default codingToolsPlugin;
export { availableToolsProvider } from "./providers/available-tools.js";
export * from "./services/coding-agent-context.js";
export {
CodingTaskExecutor,
FileStateService,
RipgrepService,
SandboxService,
SessionCwdService,
} from "./services/index.js";
export * from "./types.js";
@@ -0,0 +1,122 @@
/** Unit tests for the action-result and parameter-reader helpers. */
import type { IAgentRuntime } from "@elizaos/core";
import { describe, expect, it } from "vitest";
import { FAILURE_TEXT_PREFIX, type ToolFailure } from "../types.js";
import {
failureToActionResult,
readArrayParam,
readBoolParam,
readNumberParam,
readParam,
readPositiveIntSetting,
readStringParam,
successActionResult,
truncate,
userFacingSuccessResult,
} from "./format.js";
/** Pure param-reading + ActionResult formatting helpers for the coding tools. */
describe("ActionResult builders", () => {
it("failureToActionResult carries prefix, reason, message, and an Error", () => {
const failure: ToolFailure = {
reason: "bad_input",
message: "nope",
} as ToolFailure;
const r = failureToActionResult(failure, { x: 1 });
expect(r.success).toBe(false);
expect(r.text).toBe(`${FAILURE_TEXT_PREFIX} bad_input: nope`);
expect(r.error).toBeInstanceOf(Error);
expect((r.error as Error).message).toBe(r.text);
expect(r.data).toEqual({ x: 1 });
});
it("successActionResult is success with optional data", () => {
expect(successActionResult("ok")).toMatchObject({
success: true,
text: "ok",
});
expect(successActionResult("ok", { a: 2 }).data).toEqual({ a: 2 });
expect(successActionResult("ok").data).toBeUndefined();
});
it("userFacingSuccessResult marks the text user-facing (relay opt-in)", () => {
const r = userFacingSuccessResult("Wrote 3 bytes to /tmp/x", { bytes: 3 });
expect(r.success).toBe(true);
expect(r.text).toBe("Wrote 3 bytes to /tmp/x");
expect(r.userFacingText).toBe("Wrote 3 bytes to /tmp/x");
// verifiedUserFacing stays unset so the evaluator's messageToUser still wins
// the happy path — only the failure relay reads userFacingText.
expect(r.verifiedUserFacing).toBeUndefined();
expect(r.data).toEqual({ bytes: 3 });
});
});
describe("readParam family", () => {
const opts = { parameters: { p: "fromParams" }, top: "fromTop" };
it("prefers parameters[name], then the top-level key", () => {
expect(readParam(opts, "p")).toBe("fromParams");
expect(readParam(opts, "top")).toBe("fromTop");
expect(readParam(opts, "missing")).toBeUndefined();
expect(readParam(null, "p")).toBeUndefined();
expect(readParam("str", "p")).toBeUndefined();
});
it("readStringParam returns only strings", () => {
expect(readStringParam({ parameters: { s: "hi" } }, "s")).toBe("hi");
expect(readStringParam({ parameters: { s: 5 } }, "s")).toBeUndefined();
});
it("readNumberParam coerces numeric strings", () => {
expect(readNumberParam({ n: 7 }, "n")).toBe(7);
expect(readNumberParam({ n: "7.5" }, "n")).toBe(7.5);
expect(readNumberParam({ n: "x" }, "n")).toBeUndefined();
expect(readNumberParam({ n: Number.NaN }, "n")).toBeUndefined();
});
it("readBoolParam accepts the documented truthy/falsy forms", () => {
for (const v of [true, "true", "1", 1]) {
expect(readBoolParam({ b: v }, "b")).toBe(true);
}
for (const v of [false, "false", "0", 0]) {
expect(readBoolParam({ b: v }, "b")).toBe(false);
}
expect(readBoolParam({ b: "maybe" }, "b")).toBeUndefined();
});
it("readArrayParam returns only arrays", () => {
expect(readArrayParam({ a: [1, 2] }, "a")).toEqual([1, 2]);
expect(readArrayParam({ a: "no" }, "a")).toBeUndefined();
});
});
describe("truncate", () => {
it("leaves short strings untouched", () => {
expect(truncate("hello", 10)).toEqual({ text: "hello", truncated: false });
});
it("truncates with a remaining-chars suffix", () => {
const r = truncate("abcdefghij", 4);
expect(r.truncated).toBe(true);
expect(r.text.startsWith("abcd")).toBe(true);
expect(r.text).toContain("6 more chars");
});
});
describe("readPositiveIntSetting", () => {
const rt = (value: unknown): IAgentRuntime =>
({ getSetting: () => value }) as unknown as IAgentRuntime;
it("reads positive numbers / numeric strings, flooring", () => {
expect(readPositiveIntSetting(rt(5), "k", 1)).toBe(5);
expect(readPositiveIntSetting(rt(5.9), "k", 1)).toBe(5);
expect(readPositiveIntSetting(rt("8"), "k", 1)).toBe(8);
});
it("falls back for missing / invalid / non-positive values", () => {
expect(readPositiveIntSetting(rt(undefined), "k", 3)).toBe(3);
expect(readPositiveIntSetting(rt(0), "k", 3)).toBe(3);
expect(readPositiveIntSetting(rt(-2), "k", 3)).toBe(3);
expect(readPositiveIntSetting(rt("nope"), "k", 3)).toBe(3);
});
});
@@ -0,0 +1,133 @@
/**
* Result and parameter helpers shared by the action handlers: the
* `failureToActionResult`/`successActionResult` builders that produce the
* `ActionResult` envelope, and the `readStringParam`/`readNumberParam` readers that
* coerce loosely-typed handler options into validated values. Keeps every action's
* success/failure shape identical.
*/
import type { ActionResult, IAgentRuntime } from "@elizaos/core";
import {
type ActionResultData,
FAILURE_TEXT_PREFIX,
type ToolFailure,
} from "../types.js";
export function failureToActionResult(
failure: ToolFailure,
data?: Record<string, unknown>,
): ActionResult {
const text = `${FAILURE_TEXT_PREFIX} ${failure.reason}: ${failure.message}`;
return {
success: false,
text,
error: new Error(text),
...(data ? { data: data as ActionResultData } : {}),
};
}
export function successActionResult(
text: string,
data?: Record<string, unknown>,
): ActionResult {
return {
success: true,
text,
...(data ? { data: data as ActionResultData } : {}),
};
}
/**
* A success result whose `text` is ALSO marked user-facing. Use only for
* mutation confirmations whose text is a clean, user-safe one-liner
* ("Wrote N bytes to <path>", "Replaced N occurrences in <path>") — never for
* log-shaped output (reads, grep, ls, shell). The planner-loop relays
* `userFacingText` verbatim when the post-tool evaluator model call fails, so
* the completed write is reported truthfully instead of as a generic failure;
* it never guesses the diagnostic `text` of a log-emitting tool into that
* channel. `verifiedUserFacing` is deliberately left unset so an explicit
* evaluator `messageToUser` still outranks this on the happy path.
*/
export function userFacingSuccessResult(
text: string,
data?: Record<string, unknown>,
): ActionResult {
return { ...successActionResult(text, data), userFacingText: text };
}
export function readParam<T = unknown>(
options: unknown,
name: string,
): T | undefined {
if (!options || typeof options !== "object") return undefined;
const opts = options as Record<string, unknown>;
const params = opts.parameters as Record<string, unknown> | undefined;
const value = (params?.[name] ?? opts[name]) as T | undefined;
return value;
}
export function readStringParam(
options: unknown,
name: string,
): string | undefined {
const v = readParam<unknown>(options, name);
return typeof v === "string" ? v : undefined;
}
export function readNumberParam(
options: unknown,
name: string,
): number | undefined {
const v = readParam<unknown>(options, name);
if (typeof v === "number" && Number.isFinite(v)) return v;
if (typeof v === "string") {
const parsed = Number(v);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
export function readBoolParam(
options: unknown,
name: string,
): boolean | undefined {
const v = readParam<unknown>(options, name);
if (typeof v === "boolean") return v;
if (v === "true" || v === "1" || v === 1) return true;
if (v === "false" || v === "0" || v === 0) return false;
return undefined;
}
export function readArrayParam(
options: unknown,
name: string,
): unknown[] | undefined {
const v = readParam<unknown>(options, name);
return Array.isArray(v) ? v : undefined;
}
export function truncate(
s: string,
max: number,
): { text: string; truncated: boolean } {
if (s.length <= max) return { text: s, truncated: false };
return {
text: `${s.slice(0, max)}\n…[truncated, ${s.length - max} more chars]`,
truncated: true,
};
}
/** Reads a numeric runtime setting; invalid or missing falls back to `fallback`. */
export function readPositiveIntSetting(
runtime: IAgentRuntime,
key: string,
fallback: number,
): number {
const v = runtime.getSetting(key);
if (typeof v === "number" && Number.isFinite(v) && v > 0)
return Math.floor(v);
if (typeof v === "string") {
const n = Number(v);
if (Number.isFinite(n) && n > 0) return Math.floor(n);
}
return fallback;
}
@@ -0,0 +1,82 @@
/** Unit tests for the path predicates and blocklist resolution. */
import { describe, expect, it } from "vitest";
import {
isAbsolutePath,
isBlockedPath,
isUncPath,
isWithin,
isWithinAnyRoot,
normalizeAbsolute,
relativeFromRoot,
} from "./path-utils.js";
/** Sandbox path validation — prevents traversal/escape, so the matching is pinned. */
describe("isAbsolutePath / isUncPath", () => {
it("accepts posix absolute, rejects relative / UNC / non-strings", () => {
expect(isAbsolutePath("/usr/local")).toBe(true);
expect(isAbsolutePath("relative/path")).toBe(false);
expect(isAbsolutePath("")).toBe(false);
expect(isAbsolutePath("\\\\server\\share")).toBe(false);
expect(isAbsolutePath("//server/share")).toBe(false);
expect(isAbsolutePath(undefined as unknown as string)).toBe(false);
});
it("flags UNC paths", () => {
expect(isUncPath("\\\\server\\share")).toBe(true);
expect(isUncPath("//server/share")).toBe(true);
expect(isUncPath("/server/share")).toBe(false);
});
});
describe("isBlockedPath", () => {
it("blocks special device files and /proc fd paths", () => {
expect(isBlockedPath("/dev/zero")).toBe(true);
expect(isBlockedPath("/dev/urandom")).toBe(true);
expect(isBlockedPath("/proc/123/fd/4")).toBe(true);
expect(isBlockedPath("/home/user/file.txt")).toBe(false);
expect(isBlockedPath("/proc/cpuinfo")).toBe(false);
});
});
describe("isWithin — traversal containment", () => {
it("treats equal paths and descendants as within", () => {
expect(isWithin("/a/b", "/a/b")).toBe(true);
expect(isWithin("/a/b/c/d", "/a/b")).toBe(true);
});
it("rejects siblings, ancestors, and ../ escapes", () => {
expect(isWithin("/a/x", "/a/b")).toBe(false);
expect(isWithin("/a", "/a/b")).toBe(false);
// A normalized traversal that lands outside the parent is rejected.
expect(isWithin("/a/b/../x", "/a/b")).toBe(false);
// ...and one that stays inside is accepted.
expect(isWithin("/a/b/sub/../c", "/a/b")).toBe(true);
});
});
describe("isWithinAnyRoot", () => {
it("is false with no roots, true when contained by one", async () => {
expect(await isWithinAnyRoot("/a/b/c", [])).toBe(false);
expect(await isWithinAnyRoot("/srv/app/x", ["/tmp", "/srv/app"])).toBe(
true,
);
expect(await isWithinAnyRoot("/etc/passwd", ["/srv/app"])).toBe(false);
});
});
describe("normalizeAbsolute / relativeFromRoot", () => {
it("normalizeAbsolute returns an absolute, collapsed path", () => {
// path.resolve is platform-specific (drive letter + backslashes on Windows),
// so normalize separators before asserting the collapsed POSIX-style tail.
const out = normalizeAbsolute("/a/b/../c").replace(/\\/g, "/");
expect(out).not.toContain("/../");
expect(out.endsWith("/a/c")).toBe(true);
});
it("relativeFromRoot returns the path relative to root, or '.'", () => {
// relativeFromRoot uses path.relative (backslashes on Windows) — normalize.
expect(relativeFromRoot("/a/b/c", "/a").replace(/\\/g, "/")).toBe("b/c");
expect(relativeFromRoot("/a", "/a")).toBe(".");
});
});
@@ -0,0 +1,75 @@
/**
* Path predicates and resolution used by the sandbox policy and file handlers:
* `isAbsolutePath`, `isWithin`, `resolveRealPath`, `isUncPath`, plus a blocklist of
* device and `/proc/<pid>/fd` pseudo-paths that must never be opened.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
const BLOCKED_PATHS = new Set([
"/dev/zero",
"/dev/random",
"/dev/urandom",
"/dev/stdin",
"/dev/stdout",
"/dev/stderr",
]);
const BLOCKED_PROC_FD = /^\/proc\/\d+\/fd\//;
export function isAbsolutePath(p: string): boolean {
if (!p || typeof p !== "string") return false;
if (p.startsWith("\\\\") || p.startsWith("//")) return false;
return path.isAbsolute(p);
}
export function isBlockedPath(p: string): boolean {
if (BLOCKED_PATHS.has(p)) return true;
if (BLOCKED_PROC_FD.test(p)) return true;
return false;
}
export function normalizeAbsolute(p: string): string {
return path.resolve(p);
}
export async function resolveRealPath(p: string): Promise<string> {
try {
return await fs.realpath(p);
} catch {
// error-policy:J3 realpath requires the path to exist on disk; for a
// not-yet-created target the lexical resolve is the designed fallback the
// sandbox root check relies on (symlinks simply resolve to themselves).
return path.resolve(p);
}
}
export function isWithin(child: string, parent: string): boolean {
const resolvedChild = path.resolve(child);
const resolvedParent = path.resolve(parent);
if (resolvedChild === resolvedParent) return true;
const rel = path.relative(resolvedParent, resolvedChild);
return rel.length > 0 && !rel.startsWith("..") && !path.isAbsolute(rel);
}
export async function isWithinAnyRoot(
p: string,
roots: string[],
): Promise<boolean> {
if (roots.length === 0) return false;
const real = await resolveRealPath(p);
for (const root of roots) {
const rootReal = await resolveRealPath(root);
if (isWithin(real, rootReal)) return true;
}
return false;
}
export function isUncPath(p: string): boolean {
return p.startsWith("\\\\") || p.startsWith("//");
}
export function relativeFromRoot(p: string, root: string): string {
const rel = path.relative(path.resolve(root), path.resolve(p));
return rel === "" ? "." : rel;
}
@@ -0,0 +1,104 @@
/**
* `runGitCommand` helper: runs a git subcommand with `execFile` (no shell) under a
* given cwd and timeout, brokered through the core capability router so git access
* can be gated or denied by the host. Used by the worktree actions.
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import {
CapabilityError,
type GitOperation,
getCapabilityRouter,
type IAgentRuntime,
} from "@elizaos/core";
const execFileAsync = promisify(execFile);
export type RunGitCommandOptions = {
cwd: string;
args: string[];
timeoutMs: number;
};
export type RunGitCommandResult = {
routed: boolean;
stdout: string;
stderr: string;
exitCode: number | null;
signal: string | null;
};
export class GitCommandExecutionError extends Error {
readonly stderr: string;
constructor(message: string, stderr = "") {
super(message);
this.name = "GitCommandExecutionError";
this.stderr = stderr;
}
}
export async function runGitCommand(
runtime: IAgentRuntime,
opts: RunGitCommandOptions,
): Promise<RunGitCommandResult> {
const router = getCapabilityRouter(runtime);
if (router) {
try {
const result = await router.git.commandRun({
root: opts.cwd,
args: opts.args,
});
return routedResult(result.operation);
} catch (error) {
// error-policy:J4 only the expected "no git capability" shape
// (CAPABILITY_UNAVAILABLE) degrades to the local-git fallback below; any
// other router error rethrows so a real git failure surfaces instead of
// being masked by the local path.
if (
error instanceof CapabilityError &&
error.code === "CAPABILITY_UNAVAILABLE"
) {
return runLocalGitCommand(opts);
}
throw error;
}
}
return runLocalGitCommand(opts);
}
async function runLocalGitCommand(
opts: RunGitCommandOptions,
): Promise<RunGitCommandResult> {
const result = await execFileAsync("git", opts.args, {
cwd: opts.cwd,
encoding: "utf8",
timeout: opts.timeoutMs,
});
return {
routed: false,
stdout: result.stdout,
stderr: result.stderr,
exitCode: 0,
signal: null,
};
}
function routedResult(operation: GitOperation): RunGitCommandResult {
const exitCode = operation.exitCode ?? null;
if (operation.status === "failed" || (exitCode !== null && exitCode !== 0)) {
throw new GitCommandExecutionError(
operation.error ?? `git exited with status ${exitCode}`,
operation.stderr,
);
}
return {
routed: true,
stdout: operation.stdout,
stderr: operation.stderr,
exitCode,
signal: operation.signal ?? null,
};
}
@@ -0,0 +1,178 @@
/** Tests for the `runShell` child-process wrapper, using the core capability router doubles. */
import {
CAPABILITY_ROUTER_SERVICE_TYPE,
type ElizaCapabilityRouter,
type IAgentRuntime,
UnavailableCapabilityRouter,
} from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runShell } from "./run-shell.js";
const ENV_KEYS = [
"ELIZA_PLATFORM",
"ELIZA_BUILD_VARIANT",
"ELIZA_RUNTIME_MODE",
"RUNTIME_MODE",
"LOCAL_RUNTIME_MODE",
] as const;
let savedEnv: Record<string, string | undefined>;
let savedPlatformDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
savedPlatformDescriptor = Object.getOwnPropertyDescriptor(
process,
"platform",
);
});
afterEach(() => {
for (const key of ENV_KEYS) {
const value = savedEnv[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
if (savedPlatformDescriptor) {
Object.defineProperty(process, "platform", savedPlatformDescriptor);
}
});
function runtimeWithRouter(router: ElizaCapabilityRouter): IAgentRuntime {
return {
getService: (serviceType: string) =>
serviceType === CAPABILITY_ROUTER_SERVICE_TYPE ? router : null,
} as IAgentRuntime;
}
function remoteRouter(): {
router: ElizaCapabilityRouter;
runCommand: ReturnType<typeof vi.fn>;
} {
const runCommand = vi.fn(async () => ({
output: "remote coded\n",
exitCode: 0,
timedOut: false,
}));
const router = {
environment: "server",
availability: async () => ({
environment: "server",
available: true,
capabilities: {
fs: true,
pty: true,
git: true,
model: false,
plugin: false,
},
}),
fs: {
list: vi.fn(),
readText: vi.fn(),
writeText: vi.fn(),
},
pty: { runCommand },
git: {
status: vi.fn(),
diff: vi.fn(),
commandRun: vi.fn(),
},
model: {
status: vi.fn(),
},
plugin: new UnavailableCapabilityRouter("server").plugin,
} satisfies ElizaCapabilityRouter;
return { router, runCommand };
}
describe("plugin-coding-tools runShell mobile routing", () => {
it("routes iOS coding commands through a Remote capability router", async () => {
process.env.ELIZA_PLATFORM = "ios";
process.env.ELIZA_BUILD_VARIANT = "store";
process.env.ELIZA_RUNTIME_MODE = "local-yolo";
const { router, runCommand } = remoteRouter();
const result = await runShell(runtimeWithRouter(router), {
command: "codex exec 'touch changed.txt'",
cwd: "/workspace",
timeoutMs: 10_000,
});
expect(result).toEqual({
exitCode: 0,
signal: null,
stdout: "remote coded\n",
stderr: "",
durationMs: expect.any(Number),
sandbox: "capability-router",
timedOut: false,
});
expect(runCommand).toHaveBeenCalledWith({
command: "codex exec 'touch changed.txt'",
cwd: "/workspace",
timeoutMs: 10_000,
});
});
it("rejects iOS coding commands when no Remote capability router is available", async () => {
process.env.ELIZA_PLATFORM = "ios";
process.env.ELIZA_RUNTIME_MODE = "local-yolo";
await expect(
runShell({ getService: () => null } as IAgentRuntime, {
command: "codex exec 'touch changed.txt'",
cwd: "/workspace",
timeoutMs: 10_000,
}),
).rejects.toThrow(
"Local coding tools are unavailable on iOS because the runtime does not expose shell, coding, or orchestrator subprocess capabilities.",
);
});
});
describe("plugin-coding-tools runShell local-safe sandbox routing", () => {
it("routes Windows local-safe commands through the runtime sandbox manager", async () => {
Object.defineProperty(process, "platform", {
value: "win32",
configurable: true,
});
process.env.ELIZA_RUNTIME_MODE = "local-safe";
const exec = vi.fn(async () => ({
exitCode: 0,
stdout: "sandboxed\n",
stderr: "",
durationMs: 7,
executedInSandbox: true,
}));
const runtime = {
getService: () => null,
getSandboxManager: () => ({
engine: { engineType: "docker" },
exec,
}),
} as unknown as IAgentRuntime;
const result = await runShell(runtime, {
command: "echo sandboxed",
cwd: process.cwd(),
timeoutMs: 10_000,
});
expect(exec).toHaveBeenCalledWith({
command: "echo sandboxed",
workdir: "/workspace",
timeoutMs: 10_000,
});
expect(result).toEqual({
exitCode: 0,
signal: null,
stdout: "sandboxed\n",
stderr: "",
durationMs: 7,
sandbox: "docker",
timedOut: false,
});
});
});
@@ -0,0 +1,389 @@
/**
* Plugin-local shell-execution chokepoint.
*
* Mirrors the contract of `runShell` in `@elizaos/agent` but is owned by this
* plugin so the plugin → agent dependency direction stays clean. Whoever holds
* an `IAgentRuntime` calls this from the SHELL action handler; the body
* dispatches against the runtime mode.
*/
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import * as importPath from "node:path";
import process from "node:process";
import {
CapabilityError,
getCapabilityRouter,
type IAgentRuntime,
} from "@elizaos/core";
import { resolveRuntimeExecutionMode } from "@elizaos/shared";
import {
detectTerminalSupport,
missingToolForCommand,
missingToolMessage,
resolveHostShell,
} from "./terminal-capabilities.js";
export type ShellSandboxBackend =
| "host"
| "capability-router"
| "docker"
| "apple-container"
| "wsl2"
| "appcontainer"
| "none";
export interface ShellResult {
exitCode: number;
stdout: string;
stderr: string;
durationMs: number;
sandbox: ShellSandboxBackend;
timedOut: boolean;
signal: NodeJS.Signals | null;
}
interface RuntimeSandboxManager {
exec: (options: {
command: string;
workdir?: string;
env?: Record<string, string>;
timeoutMs?: number;
stdin?: string;
}) => Promise<{
exitCode: number;
stdout: string;
stderr: string;
durationMs: number;
executedInSandbox: boolean;
}>;
}
function getRuntimeSandboxManager(
runtime: IAgentRuntime,
): RuntimeSandboxManager | null {
const candidate = (
runtime as {
getSandboxManager?: () => RuntimeSandboxManager | null;
}
).getSandboxManager?.();
return candidate ?? null;
}
function backendForManager(
manager: RuntimeSandboxManager,
): ShellSandboxBackend {
const internal = manager as RuntimeSandboxManager & {
engine?: { engineType?: string };
};
const engineType = internal.engine?.engineType;
if (engineType === "docker") return "docker";
if (engineType === "apple-container") return "apple-container";
return "none";
}
function toSandboxWorkdir(cwd: string): string | undefined {
const root = process.cwd();
const relative = importPath.relative(
importPath.resolve(root),
importPath.resolve(cwd),
);
if (relative === "") return "/workspace";
if (!relative.startsWith("..") && !importPath.isAbsolute(relative)) {
return `/workspace/${relative}`;
}
return undefined;
}
const STREAM_CAP_CHARS = 30_000;
function shellArgsForCommand(shell: {
command: string;
args: string[];
}): string[] {
const basename = importPath.basename(shell.command).toLowerCase();
if (basename === "bash") {
const commandFlagIndex = shell.args.lastIndexOf("-c");
const startupFlags = ["--noprofile", "--norc", "-o", "pipefail"];
if (commandFlagIndex >= 0) {
return [
...startupFlags,
...shell.args.slice(0, commandFlagIndex),
...shell.args.slice(commandFlagIndex),
];
}
return [...startupFlags, ...shell.args];
}
if (basename === "zsh") {
const commandFlagIndex = shell.args.lastIndexOf("-c");
const startupFlags = ["-f", "-o", "pipefail"];
if (commandFlagIndex >= 0) {
return [
...startupFlags,
...shell.args.slice(0, commandFlagIndex),
...shell.args.slice(commandFlagIndex),
];
}
return [...startupFlags, ...shell.args];
}
return shell.args;
}
function killHostProcess(
pid: number | undefined,
signal: NodeJS.Signals,
useProcessGroup: boolean,
proc: ReturnType<typeof spawn>,
): void {
try {
if (pid && useProcessGroup) {
process.kill(-pid, signal);
return;
}
proc.kill(signal);
} catch {
// error-policy:J6 best-effort teardown; the process may have exited between
// the timeout firing and kill delivery, so a failed signal is a no-op.
}
}
function runOnHost(opts: {
command: string;
cwd: string;
timeoutMs: number;
env: NodeJS.ProcessEnv;
}): Promise<ShellResult> {
return runOnHostWithShell(opts, resolveHostShell()).then(async (result) => {
const shell = resolveHostShell();
const basename = importPath.basename(shell.command).toLowerCase();
if (
basename === "zsh" &&
result.exitCode !== 0 &&
result.stdout.length === 0 &&
result.stderr.length === 0
) {
const bash = resolveExecutableForHost("bash", "/bin/bash");
if (bash && bash !== shell.command) {
return runOnHostWithShell(opts, {
command: bash,
args: ["-c"],
available: true,
source: "candidate",
});
}
}
return result;
});
}
function resolveExecutableForHost(
name: string,
fallback: string,
): string | undefined {
const pathEntries = (process.env.PATH ?? "")
.split(importPath.delimiter)
.filter(Boolean);
for (const entry of pathEntries) {
const candidate = importPath.join(entry, name);
if (existsSync(candidate)) return candidate;
}
if (existsSync(fallback)) return fallback;
return undefined;
}
function runOnHostWithShell(
opts: {
command: string;
cwd: string;
timeoutMs: number;
env: NodeJS.ProcessEnv;
},
shell: ReturnType<typeof resolveHostShell>,
): Promise<ShellResult> {
const start = Date.now();
return new Promise<ShellResult>((resolve) => {
if (!shell.available) {
resolve({
exitCode: -1,
signal: null,
stdout: "",
stderr: shell.warning ?? "No executable shell was detected.",
timedOut: false,
durationMs: Date.now() - start,
sandbox: "host",
});
return;
}
const useProcessGroup = process.platform !== "win32";
const proc = spawn(
shell.command,
[...shellArgsForCommand(shell), opts.command],
{
cwd: opts.cwd,
env: opts.env,
stdio: ["ignore", "pipe", "pipe"],
detached: useProcessGroup,
},
);
let stdout = "";
let stderr = "";
let timedOut = false;
proc.stdout.on("data", (chunk: Buffer) => {
if (stdout.length < STREAM_CAP_CHARS * 2) {
stdout += chunk.toString("utf8");
}
});
proc.stderr.on("data", (chunk: Buffer) => {
if (stderr.length < STREAM_CAP_CHARS * 2) {
stderr += chunk.toString("utf8");
}
});
const timer = setTimeout(() => {
timedOut = true;
killHostProcess(proc.pid, "SIGTERM", useProcessGroup, proc);
setTimeout(() => {
killHostProcess(proc.pid, "SIGKILL", useProcessGroup, proc);
}, 1500);
}, opts.timeoutMs);
if (typeof timer.unref === "function") timer.unref();
proc.on("close", (code, signal) => {
clearTimeout(timer);
resolve({
exitCode: code ?? -1,
signal,
stdout,
stderr,
timedOut,
durationMs: Date.now() - start,
sandbox: "host",
});
});
proc.on("error", (err) => {
clearTimeout(timer);
resolve({
exitCode: -1,
signal: null,
stdout,
stderr: stderr.length > 0 ? `${stderr}\n${err.message}` : err.message,
timedOut,
durationMs: Date.now() - start,
sandbox: "host",
});
});
});
}
async function runThroughCapabilityRouter(
runtime: IAgentRuntime,
opts: RunShellOptions,
): Promise<ShellResult | null> {
const router = getCapabilityRouter(runtime);
if (!router) return null;
const start = Date.now();
try {
const result = await router.pty.runCommand({
command: opts.command,
cwd: opts.cwd,
timeoutMs: opts.timeoutMs,
});
return {
exitCode: result.exitCode ?? -1,
signal: null,
stdout: result.output,
stderr: "",
durationMs: Date.now() - start,
timedOut: result.timedOut,
sandbox: "capability-router",
};
} catch (error) {
// error-policy:J4 only the expected "no PTY capability" shape
// (CAPABILITY_UNAVAILABLE) degrades to null below (advancing to the
// host-shell fallback); any other router error rethrows so a genuine
// execution failure reaches the SHELL action.
if (
error instanceof CapabilityError &&
error.code === "CAPABILITY_UNAVAILABLE"
) {
return null;
}
throw error;
}
}
export interface RunShellOptions {
command: string;
cwd: string;
timeoutMs: number;
}
/**
* Run a shell command, dispatching against the active runtime mode:
* - `cloud` → throws ("Local shell execution disabled in cloud mode.").
* - `local-safe` → SandboxManager.exec; refuses if the sandbox is unavailable
* or the cwd is outside the workspace.
* - `local-yolo` → /bin/bash -c host exec.
*/
export async function runShell(
runtime: IAgentRuntime,
opts: RunShellOptions,
): Promise<ShellResult> {
const mode = resolveRuntimeExecutionMode(runtime);
const routed = await runThroughCapabilityRouter(runtime, opts);
if (routed) return routed;
if (mode === "cloud") {
throw new Error("Local shell execution disabled in cloud mode.");
}
const support = detectTerminalSupport();
if (!support.supported) {
throw new Error(
support.message ?? "Local terminal execution is unavailable.",
);
}
const missingTool = missingToolForCommand(opts.command);
if (missingTool) {
throw new Error(missingToolMessage(missingTool));
}
if (mode === "local-safe") {
const manager = getRuntimeSandboxManager(runtime);
if (!manager) {
throw new Error(
"local-safe mode requires SandboxManager, but no sandbox manager is available for command execution.",
);
}
const sandboxWorkdir = toSandboxWorkdir(opts.cwd);
if (!sandboxWorkdir) {
throw new Error(
`local-safe mode can only execute inside the sandbox workspace; cwd is outside process workspace: ${opts.cwd}`,
);
}
const result = await manager.exec({
command: opts.command,
workdir: sandboxWorkdir,
timeoutMs: opts.timeoutMs,
});
return {
exitCode: result.exitCode,
signal: null,
stdout: result.stdout,
stderr: result.stderr,
durationMs: result.durationMs,
timedOut: false,
sandbox: backendForManager(manager),
};
}
return runOnHost({
command: opts.command,
cwd: opts.cwd,
timeoutMs: opts.timeoutMs,
env: process.env,
});
}
@@ -0,0 +1,49 @@
/** Unit tests for the secret-token detector used to gate WRITE/EDIT. */
import { describe, expect, it } from "vitest";
import { detectSecrets } from "./secrets.js";
/** detectSecrets gates WRITE/EDIT against leaking credentials — a security boundary. */
const SAMPLES: Array<{ name: string; content: string }> = [
{ name: "aws_access_key", content: "AKIAIOSFODNN7EXAMPLE" },
{ name: "github_token", content: `ghp_${"a".repeat(36)}` },
{ name: "github_oauth", content: `gho_${"b".repeat(36)}` },
{ name: "github_app", content: `ghs_${"c".repeat(36)}` },
{ name: "openai_key", content: `sk-${"d".repeat(24)}` },
{ name: "anthropic_key", content: `sk-ant-${"e".repeat(95)}` },
{ name: "google_api_key", content: `AIza${"f".repeat(35)}` },
{ name: "slack_token", content: "xoxb-1234567890-abcdef" },
{ name: "stripe_secret", content: `sk_live_${"g".repeat(28)}` },
{ name: "private_key_pem", content: "-----BEGIN RSA PRIVATE KEY-----" },
{
name: "jwt_like",
content: "eyJhbGciOiJxx.eyJzdWIiOiJyy.SflKxwRJSMzz",
},
];
describe("detectSecrets", () => {
for (const { name, content } of SAMPLES) {
it(`flags ${name}`, () => {
expect(detectSecrets(content).map((m) => m.name)).toContain(name);
});
}
it("returns no matches for benign content", () => {
expect(detectSecrets("const x = 1; // nothing secret here")).toEqual([]);
expect(detectSecrets("")).toEqual([]);
});
it("masks long secrets in the preview (first 6 … last 4)", () => {
const [match] = detectSecrets("AKIAIOSFODNN7EXAMPLE");
expect(match?.name).toBe("aws_access_key");
expect(match?.preview).toBe("AKIAIO…MPLE");
});
it("reports multiple distinct secrets in one blob", () => {
const names = detectSecrets(
`aws=AKIAIOSFODNN7EXAMPLE\ngh=ghp_${"a".repeat(36)}\n`,
).map((m) => m.name);
expect(names).toContain("aws_access_key");
expect(names).toContain("github_token");
});
});
@@ -0,0 +1,42 @@
// Conservative secret detection. Catches the obvious: AWS keys, GitHub tokens,
// generic high-entropy assignments to *_SECRET / *_TOKEN / *_KEY. Designed to
// gate WRITE/EDIT, not to be a full DLP solution.
const PATTERNS: Array<{ name: string; regex: RegExp }> = [
{ name: "aws_access_key", regex: /\bAKIA[0-9A-Z]{16}\b/ },
{ name: "github_token", regex: /\bghp_[A-Za-z0-9]{36}\b/ },
{ name: "github_oauth", regex: /\bgho_[A-Za-z0-9]{36}\b/ },
{ name: "github_app", regex: /\b(ghu|ghs)_[A-Za-z0-9]{36}\b/ },
{ name: "openai_key", regex: /\bsk-[A-Za-z0-9]{20,}\b/ },
{ name: "anthropic_key", regex: /\bsk-ant-[A-Za-z0-9_-]{90,}\b/ },
{ name: "google_api_key", regex: /\bAIza[0-9A-Za-z_-]{35}\b/ },
{ name: "slack_token", regex: /\bxox[abprs]-[A-Za-z0-9-]{10,}\b/ },
{ name: "stripe_secret", regex: /\bsk_live_[A-Za-z0-9]{24,}\b/ },
{
name: "private_key_pem",
regex: /-----BEGIN (?:RSA |EC |OPENSSH |DSA )?PRIVATE KEY-----/,
},
{
name: "jwt_like",
regex: /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/,
},
];
export interface SecretMatch {
name: string;
preview: string;
}
export function detectSecrets(content: string): SecretMatch[] {
const matches: SecretMatch[] = [];
for (const { name, regex } of PATTERNS) {
const m = regex.exec(content);
if (m) {
const found = m[0];
const preview =
found.length > 16 ? `${found.slice(0, 6)}${found.slice(-4)}` : found;
matches.push({ name, preview });
}
}
return matches;
}
@@ -0,0 +1,138 @@
/** Unit tests for shell resolution and coding-tool capability detection. */
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
detectTerminalSupport,
missingToolForCommand,
resolveExecutable,
resolveHostShell,
} from "./terminal-capabilities.js";
const ENV_KEYS = [
"ELIZA_PLATFORM",
"ELIZA_AOSP_BUILD",
"ELIZA_RUNTIME_MODE",
"RUNTIME_MODE",
"LOCAL_RUNTIME_MODE",
"ELIZA_BUILD_VARIANT",
"ELIZA_BUILD_VARIANT",
"CODING_TOOLS_SHELL",
"SHELL",
"PATH",
] as const;
let savedEnv: Record<string, string | undefined>;
let tempDir = "";
beforeEach(() => {
savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
tempDir = mkdtempSync(path.join(tmpdir(), "ct-cap-"));
});
afterEach(() => {
for (const key of ENV_KEYS) {
const value = savedEnv[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
rmSync(tempDir, { recursive: true, force: true });
});
function executable(name: string): string {
const file = path.join(tempDir, name);
writeFileSync(file, "#!/bin/sh\nexit 0\n");
chmodSync(file, 0o755);
return file;
}
describe("coding-tools terminal capability detection", () => {
it("uses CODING_TOOLS_SHELL for Android shell selection", () => {
const shell = executable("aosp-sh");
process.env.ELIZA_PLATFORM = "android";
process.env.CODING_TOOLS_SHELL = shell;
process.env.SHELL = "/definitely/missing";
process.env.PATH = tempDir;
const resolved = resolveHostShell();
expect(resolved.available).toBe(true);
expect(resolved.command).toBe(shell);
expect(resolved.source).toBe("env:CODING_TOOLS_SHELL");
});
it("detects Android PATH binaries without invoking which", () => {
const git = executable("git");
process.env.ELIZA_PLATFORM = "android";
process.env.PATH = tempDir;
expect(resolveExecutable("git")).toBe(git);
expect(missingToolForCommand("git status")).toBeUndefined();
expect(missingToolForCommand("codex exec test")).toBe("codex");
});
it("accepts direct Android local-yolo when a shell is executable", () => {
const shell = executable("sh");
process.env.ELIZA_PLATFORM = "android";
process.env.ELIZA_RUNTIME_MODE = "local-yolo";
process.env.CODING_TOOLS_SHELL = shell;
process.env.PATH = tempDir;
const support = detectTerminalSupport();
expect(support.supported).toBe(true);
});
it("rejects Play/store Android even when local-yolo has a staged shell", () => {
const shell = executable("sh");
process.env.ELIZA_BUILD_VARIANT = "store";
process.env.ELIZA_PLATFORM = "android";
process.env.ELIZA_RUNTIME_MODE = "local-yolo";
process.env.CODING_TOOLS_SHELL = shell;
process.env.PATH = tempDir;
const support = detectTerminalSupport();
expect(support.supported).toBe(false);
expect(support.reason).toBe("store_build");
});
it("rejects iOS terminal support", () => {
process.env.ELIZA_PLATFORM = "ios";
process.env.ELIZA_RUNTIME_MODE = "local-yolo";
const support = detectTerminalSupport();
expect(support.supported).toBe(false);
expect(support.reason).toBe("vanilla_mobile");
expect(support.message).toContain("iOS");
});
it("accepts branded AOSP local-yolo when a shell is executable", () => {
const shell = executable("sh");
process.env.ELIZA_PLATFORM = "android";
process.env.ELIZA_AOSP_BUILD = "1";
process.env.ELIZA_RUNTIME_MODE = "local-yolo";
process.env.CODING_TOOLS_SHELL = shell;
process.env.PATH = tempDir;
const support = detectTerminalSupport();
expect(support.supported).toBe(true);
});
it("rejects Android outside local-yolo mode", () => {
const shell = executable("sh");
process.env.ELIZA_PLATFORM = "android";
process.env.ELIZA_AOSP_BUILD = "1";
process.env.ELIZA_RUNTIME_MODE = "local-safe";
process.env.CODING_TOOLS_SHELL = shell;
process.env.PATH = tempDir;
const support = detectTerminalSupport();
expect(support.supported).toBe(false);
expect(support.reason).toBe("not_local_yolo");
});
});
@@ -0,0 +1,285 @@
/**
* Platform terminal capability detection: resolves the host shell and probes the
* PATH for the binaries the coding tools depend on (sh, git, rg, bun, codex,
* claude, opencode, …), and reports Android/AOSP runtime constraints. Consumed by
* the SHELL action and run-shell to decide what can execute and to produce
* missing-tool messages.
*/
import { accessSync, constants } from "node:fs";
import path from "node:path";
import { readAliasedEnv } from "@elizaos/shared";
export const CODING_TOOL_NAMES = [
"sh",
"git",
"rg",
"bun",
"acpx",
"codex",
"claude",
"opencode",
] as const;
export type CodingToolName = (typeof CODING_TOOL_NAMES)[number];
export interface CodingToolCapability {
name: CodingToolName;
path?: string;
available: boolean;
}
export interface ResolvedShell {
command: string;
args: string[];
available: boolean;
source: "env:CODING_TOOLS_SHELL" | "env:SHELL" | "candidate" | "fallback";
warning?: string;
}
export type TerminalUnsupportedReason =
| "store_build"
| "vanilla_mobile"
| "not_local_yolo"
| "missing_shell";
export interface TerminalSupport {
supported: boolean;
reason?: TerminalUnsupportedReason;
message?: string;
}
const ANDROID_PATH_ENTRIES = ["/system/bin", "/system/xbin", "/vendor/bin"];
export function isAndroidRuntime(): boolean {
return (
readAliasedEnv("ELIZA_PLATFORM")?.toLowerCase() === "android" ||
Boolean(process.env.ANDROID_ROOT || process.env.ANDROID_DATA)
);
}
function isIosRuntime(): boolean {
return readAliasedEnv("ELIZA_PLATFORM")?.toLowerCase() === "ios";
}
function isTruthyEnv(value: string | undefined): boolean {
if (!value) return false;
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
function isStoreBuild(): boolean {
const variant = process.env.ELIZA_BUILD_VARIANT ?? "";
return variant.trim().toLowerCase() === "store";
}
function runtimeMode(): string {
return (
process.env.ELIZA_RUNTIME_MODE ??
process.env.RUNTIME_MODE ??
process.env.LOCAL_RUNTIME_MODE ??
""
)
.trim()
.toLowerCase();
}
export function isAospTerminalRuntime(): boolean {
return isAndroidRuntime() && isTruthyEnv(process.env.ELIZA_AOSP_BUILD);
}
function pathEntries(): string[] {
const entries = (process.env.PATH ?? "")
.split(path.delimiter)
.map((entry) => entry.trim())
.filter(Boolean);
if (isAndroidRuntime()) {
for (const entry of ANDROID_PATH_ENTRIES) {
if (!entries.includes(entry)) entries.push(entry);
}
}
return entries;
}
function canExecute(filePath: string): boolean {
try {
accessSync(filePath, constants.X_OK);
return true;
} catch {
// error-policy:J3 existence/permission probe; an access failure means the
// path is absent or not executable — false is the expected-miss signal.
return false;
}
}
export function resolveExecutable(nameOrPath: string): string | undefined {
const trimmed = nameOrPath.trim();
if (!trimmed) return undefined;
if (trimmed.includes("/") || path.isAbsolute(trimmed)) {
return canExecute(trimmed) ? trimmed : undefined;
}
for (const entry of pathEntries()) {
const candidate = path.join(entry, trimmed);
if (canExecute(candidate)) return candidate;
}
return undefined;
}
function firstExecutable(candidates: readonly string[]): string | undefined {
for (const candidate of candidates) {
const resolved = resolveExecutable(candidate);
if (resolved) return resolved;
}
return undefined;
}
export function resolveHostShell(): ResolvedShell {
const explicitEntries = [
["CODING_TOOLS_SHELL", process.env.CODING_TOOLS_SHELL] as const,
["SHELL", process.env.SHELL] as const,
];
for (const [key, raw] of explicitEntries) {
const value = raw?.trim();
if (!value) continue;
const resolved = resolveExecutable(value);
if (resolved) {
return {
command: resolved,
args: ["-c"],
available: true,
source:
key === "CODING_TOOLS_SHELL" ? "env:CODING_TOOLS_SHELL" : "env:SHELL",
};
}
}
if (process.platform === "win32") {
return {
command: "powershell.exe",
args: ["-NoProfile", "-NonInteractive", "-Command"],
available: true,
source: "candidate",
};
}
const candidates = isAndroidRuntime()
? ["/system/bin/sh", "sh"]
: ["/bin/bash", "bash", "/bin/sh", "sh"];
const resolved = firstExecutable(candidates);
if (resolved) {
return {
command: resolved,
args: ["-c"],
available: true,
source: "candidate",
};
}
return {
command: isAndroidRuntime() ? "/system/bin/sh" : "sh",
args: ["-c"],
available: false,
source: "fallback",
warning: isAndroidRuntime()
? "No executable POSIX shell was detected. Android direct/AOSP local-yolo builds must expose /system/bin/sh or set CODING_TOOLS_SHELL to an executable shell."
: "No executable shell was detected. Set SHELL or CODING_TOOLS_SHELL to an executable shell.",
};
}
export function detectCodingToolCapabilities(): CodingToolCapability[] {
return CODING_TOOL_NAMES.map((name) => {
if (name === "sh") {
const shell = resolveHostShell();
return {
name,
path: shell.available ? shell.command : undefined,
available: shell.available,
};
}
const resolved = resolveExecutable(name);
return { name, path: resolved, available: Boolean(resolved) };
});
}
export function formatCodingToolCapabilities(
capabilities = detectCodingToolCapabilities(),
): string {
return capabilities
.map((capability) =>
capability.available
? `${capability.name}=ok(${capability.path})`
: `${capability.name}=missing`,
)
.join(" ");
}
export function missingToolMessage(tool: CodingToolName): string {
if (tool === "sh") {
return resolveHostShell().warning ?? "No executable shell was detected.";
}
const suffix = isAndroidRuntime()
? " On Android direct/AOSP builds, ensure the binary is staged into the agent image and PATH includes /system/bin or the tool's install directory."
: " Install it or add it to PATH.";
return `${tool} CLI is not available in PATH.${suffix}`;
}
export function missingToolForCommand(
command: string,
): CodingToolName | undefined {
const tokens = command.trim().split(/\s+/).filter(Boolean);
let index = 0;
while (tokens[index] && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[index])) {
index += 1;
}
const first = tokens[index]?.replace(/^["']|["']$/g, "");
if (!first) return undefined;
const name = path.basename(first) as CodingToolName;
if (
!(CODING_TOOL_NAMES as readonly string[]).includes(name) ||
name === "sh"
) {
return undefined;
}
return resolveExecutable(first) ? undefined : name;
}
export function detectTerminalSupport(): TerminalSupport {
if (isStoreBuild()) {
return {
supported: false,
reason: "store_build",
message:
"Local coding tools are unavailable in store builds because the OS sandbox blocks spawning local shells and developer CLIs.",
};
}
if (isIosRuntime()) {
return {
supported: false,
reason: "vanilla_mobile",
message:
"Local coding tools are unavailable on iOS because the runtime does not expose shell, coding, or orchestrator subprocess capabilities.",
};
}
if (isAndroidRuntime()) {
if (runtimeMode() !== "local-yolo") {
return {
supported: false,
reason: "not_local_yolo",
message:
"Android direct/AOSP coding tools require ELIZA_RUNTIME_MODE=local-yolo so commands run in the local agent environment.",
};
}
const shell = resolveHostShell();
if (!shell.available) {
return {
supported: false,
reason: "missing_shell",
message:
shell.warning ??
"Android direct/AOSP coding tools require an executable shell. Set CODING_TOOLS_SHELL or SHELL to a staged shell binary.",
};
}
}
return { supported: true };
}
@@ -0,0 +1,48 @@
/**
* AVAILABLE_CODING_TOOLS provider: injects the list of tool names the plugin
* exposes (FILE, SHELL, WORKTREE) into agent state at position -10 so the model
* knows which coding actions it can call.
*/
import type {
IAgentRuntime,
Memory,
Provider,
ProviderResult,
State,
} from "@elizaos/core";
import { CODING_TOOLS_CONTEXTS } from "../types.js";
const TOOL_NAMES = ["FILE", "SHELL", "WORKTREE"] as const;
/**
* Surface the coding-tools toolkit to the planner. Mirrors the
* `enabled_skills` provider pattern. Position -10 keeps it close to the front
* of the rendered state.
*/
export const availableToolsProvider: Provider = {
name: "AVAILABLE_CODING_TOOLS",
description: "Lists FILE, SHELL, and WORKTREE coding tools.",
position: -10,
contexts: [...CODING_TOOLS_CONTEXTS],
contextGate: { anyOf: [...CODING_TOOLS_CONTEXTS] },
cacheStable: true,
cacheScope: "agent",
get: async (
_runtime: IAgentRuntime,
_message: Memory,
_state?: State,
): Promise<ProviderResult> => {
const lines = [
"# Native coding tools",
"",
"FILE reads/writes/searches, SHELL runs commands, and WORKTREE manages git worktrees.",
"Use absolute workspace paths unless a tool says it defaults to session cwd. Configured private/system paths are blocked.",
"",
...TOOL_NAMES.map((n) => `- ${n}`),
];
return {
text: lines.join("\n"),
data: { codingTools: TOOL_NAMES.slice() },
};
},
};
@@ -0,0 +1,141 @@
/** Unit tests for the coding-agent-context Zod schemas. */
import { describe, expect, it } from "vitest";
import {
addIteration,
type CapturedError,
type CodingIteration,
createCodingAgentContext,
getUnresolvedErrors,
type HumanFeedback,
hasReachedMaxIterations,
injectFeedback,
isLastIterationClean,
shouldContinueLoop,
validateCodingAgentContext,
validateConnectorConfig,
} from "./coding-agent-context.js";
/**
* The coding-agent context drives the autonomous self-correction loop (#9146).
* The loop-termination decision (inactive / max iterations / clean / rejected)
* is the safety boundary that stops a runaway agent, so its branches are pinned.
*/
const err: CapturedError = { category: "compile", message: "boom" };
const iter = (errors: CapturedError[] = []): CodingIteration =>
({
index: 0,
startedAt: 1,
fileOperations: [],
commandResults: [],
errors,
feedback: [],
selfCorrected: false,
}) as CodingIteration;
const baseCtx = () =>
createCodingAgentContext({
sessionId: "s1",
taskDescription: "do the thing",
workingDirectory: "/work",
connectorType: "git-repo",
connectorBasePath: "/repo",
});
describe("createCodingAgentContext", () => {
it("applies sane defaults", () => {
const ctx = baseCtx();
expect(ctx).toMatchObject({
maxIterations: 10,
active: true,
interactionMode: "fully-automated",
iterations: [],
connector: { type: "git-repo", basePath: "/repo", available: true },
});
});
});
describe("iteration helpers", () => {
it("hasReachedMaxIterations compares count to the cap", () => {
const ctx = { ...baseCtx(), maxIterations: 1 };
expect(hasReachedMaxIterations(ctx)).toBe(false);
expect(hasReachedMaxIterations(addIteration(ctx, iter()))).toBe(true);
});
it("isLastIterationClean / getUnresolvedErrors reflect the last iteration", () => {
const ctx = baseCtx();
expect(isLastIterationClean(ctx)).toBe(true); // no iterations
expect(getUnresolvedErrors(ctx)).toEqual([]);
const dirty = addIteration(ctx, iter([err]));
expect(isLastIterationClean(dirty)).toBe(false);
expect(getUnresolvedErrors(dirty)).toEqual([err]);
const clean = addIteration(dirty, iter([]));
expect(isLastIterationClean(clean)).toBe(true);
});
it("addIteration / injectFeedback are immutable appends", () => {
const ctx = baseCtx();
const next = addIteration(ctx, iter([err]));
expect(ctx.iterations).toHaveLength(0); // original untouched
expect(next.iterations).toHaveLength(1);
const fb: HumanFeedback = {
id: "f1",
timestamp: 1,
text: "no",
type: "rejection",
};
const withFb = injectFeedback(next, fb);
expect(next.allFeedback).toHaveLength(0);
expect(withFb.allFeedback).toEqual([fb]);
});
});
describe("shouldContinueLoop", () => {
it("halts an inactive session", () => {
expect(shouldContinueLoop({ ...baseCtx(), active: false })).toMatchObject({
shouldContinue: false,
});
});
it("halts at the iteration cap", () => {
const ctx = addIteration({ ...baseCtx(), maxIterations: 1 }, iter([err]));
expect(shouldContinueLoop(ctx).reason).toMatch(/maximum iterations/);
});
it("halts when the last iteration is error-free", () => {
const ctx = addIteration(baseCtx(), iter([]));
expect(shouldContinueLoop(ctx).reason).toMatch(/without errors/);
});
it("halts when the user rejected the last iteration", () => {
const ctx = injectFeedback(addIteration(baseCtx(), iter([err])), {
id: "f1",
timestamp: 1,
text: "stop",
type: "rejection",
});
expect(shouldContinueLoop(ctx)).toMatchObject({ shouldContinue: false });
expect(shouldContinueLoop(ctx).reason).toMatch(/rejected/);
});
it("continues while there are errors to resolve", () => {
const ctx = addIteration(baseCtx(), iter([err]));
expect(shouldContinueLoop(ctx).shouldContinue).toBe(true);
});
});
describe("validators", () => {
it("accepts a well-formed context/connector and rejects malformed ones", () => {
expect(validateCodingAgentContext(baseCtx()).ok).toBe(true);
expect(validateCodingAgentContext({}).ok).toBe(false);
expect(
validateConnectorConfig({ type: "git-repo", basePath: "/x" }).ok,
).toBe(true);
expect(validateConnectorConfig({ type: "nope", basePath: "/x" }).ok).toBe(
false,
);
});
});
@@ -0,0 +1,355 @@
/**
* Coding agent context types and validation.
*
* Provides Zod schemas and TypeScript types for validating coding agent
* context objects used in the autonomous coding loop: code generation,
* execution, error capture, iterative self-correction, and human-in-the-loop
* feedback injection.
*
* @module services/coding-agent-context
*/
import { z } from "zod";
// ---------------------------------------------------------------------------
// Zod Schemas
// ---------------------------------------------------------------------------
/** Schema for a single file operation within a coding iteration. */
export const FileOperationSchema = z.object({
type: z.enum(["read", "write", "edit", "list", "search"]),
target: z.string().min(1, "File operation target must not be empty"),
/** Size in bytes for write/edit operations. */
size: z.number().int().nonnegative().optional(),
});
/** Schema for a shell command result captured during execution. */
export const CommandResultSchema = z.object({
command: z.string().min(1),
exitCode: z.number().int(),
stdout: z.string(),
stderr: z.string(),
/** Working directory where the command was executed. */
executedIn: z.string().min(1),
/** Duration in milliseconds. */
durationMs: z.number().nonnegative().optional(),
success: z.boolean(),
});
/** Schema for an error captured during code execution. */
export const CapturedErrorSchema = z.object({
/** Error category: compile, runtime, test, lint, or other. */
category: z.enum(["compile", "runtime", "test", "lint", "other"]),
/** Human-readable error message. */
message: z.string().min(1),
/** File path where the error occurred (if applicable). */
filePath: z.string().optional(),
/** Line number where the error occurred (if applicable). */
line: z.number().int().positive().optional(),
/** Raw error output from the tool/command. */
raw: z.string().optional(),
});
/** Schema for human feedback injected into the coding loop. */
export const HumanFeedbackSchema = z.object({
/** Unique feedback identifier. */
id: z.string().min(1),
/** Timestamp when feedback was received. */
timestamp: z.number().int().positive(),
/** The feedback text from the user. */
text: z.string().min(1),
/** The context/iteration the feedback applies to. */
iterationRef: z.number().int().nonnegative().optional(),
/** Feedback type: correction, guidance, approval, rejection. */
type: z.enum(["correction", "guidance", "approval", "rejection"]),
});
/** Schema for a single iteration of the coding agent loop. */
export const CodingIterationSchema = z.object({
/** Zero-based iteration index. */
index: z.number().int().nonnegative(),
/** Timestamp when this iteration started. */
startedAt: z.number().int().positive(),
/** Timestamp when this iteration completed. */
completedAt: z.number().int().positive().optional(),
/** Code generation output (the generated/modified code). */
generatedCode: z.string().optional(),
/** File operations performed during this iteration. */
fileOperations: z.array(FileOperationSchema).default([]),
/** Commands executed during this iteration. */
commandResults: z.array(CommandResultSchema).default([]),
/** Errors captured during this iteration. */
errors: z.array(CapturedErrorSchema).default([]),
/** Human feedback applied at this iteration. */
feedback: z.array(HumanFeedbackSchema).default([]),
/** Whether this iteration resolved all errors from the previous one. */
selfCorrected: z.boolean().default(false),
/** Summary of what changed in this iteration. */
summary: z.string().optional(),
});
/** Schema for the connector type used by the coding agent. */
export const ConnectorTypeSchema = z.enum([
"local-fs",
"git-repo",
"api",
"browser",
"sandbox",
]);
/** Schema for connector configuration. */
export const ConnectorConfigSchema = z.object({
type: ConnectorTypeSchema,
/** Base path / URL for the connector. */
basePath: z.string().min(1),
/** Whether the connector is currently available. */
available: z.boolean().default(true),
/** Connector-specific metadata. */
metadata: z.record(z.string(), z.string()).optional(),
});
/** Interaction mode for the coding session. */
export const InteractionModeSchema = z.enum([
"fully-automated",
"human-in-the-loop",
"manual-guidance",
]);
/** Schema for the full coding agent context. */
export const CodingAgentContextSchema = z.object({
/** Unique session identifier. */
sessionId: z.string().min(1),
/** Task description / goal for the coding session. */
taskDescription: z.string().min(1),
/** Working directory for file operations. */
workingDirectory: z.string().min(1),
/** Connector configuration for accessing code. */
connector: ConnectorConfigSchema,
/** Interaction mode for this session. */
interactionMode: InteractionModeSchema,
/** Maximum iterations before the loop stops. */
maxIterations: z.number().int().positive().default(10),
/** Whether the loop is currently active. */
active: z.boolean().default(true),
/** All iterations of the coding loop. */
iterations: z.array(CodingIterationSchema).default([]),
/** All human feedback collected during the session. */
allFeedback: z.array(HumanFeedbackSchema).default([]),
/** Timestamp when the session was created. */
createdAt: z.number().int().positive(),
/** Timestamp when the session was last updated. */
updatedAt: z.number().int().positive().optional(),
});
// ---------------------------------------------------------------------------
// TypeScript Types (inferred from schemas)
// ---------------------------------------------------------------------------
export type FileOperation = z.infer<typeof FileOperationSchema>;
export type CommandResult = z.infer<typeof CommandResultSchema>;
export type CapturedError = z.infer<typeof CapturedErrorSchema>;
export type HumanFeedback = z.infer<typeof HumanFeedbackSchema>;
export type CodingIteration = z.infer<typeof CodingIterationSchema>;
export type ConnectorType = z.infer<typeof ConnectorTypeSchema>;
export type ConnectorConfig = z.infer<typeof ConnectorConfigSchema>;
export type InteractionMode = z.infer<typeof InteractionModeSchema>;
export type CodingAgentContext = z.infer<typeof CodingAgentContextSchema>;
// ---------------------------------------------------------------------------
// Validation helpers
// ---------------------------------------------------------------------------
/** Result of a validation operation. */
export type ValidationResult<T> =
| { ok: true; data: T }
| { ok: false; errors: Array<{ path: string; message: string }> };
function formatValidationErrors(
issues: Array<{ path: Array<unknown>; message: string }>,
): Array<{ path: string; message: string }> {
return issues.map((issue) => ({
path: issue.path.join("."),
message: issue.message,
}));
}
/**
* Validate a coding agent context object.
* Returns a typed result with either the validated data or an array of errors.
*/
export function validateCodingAgentContext(
input: Record<string, unknown>,
): ValidationResult<CodingAgentContext> {
const result = CodingAgentContextSchema.safeParse(input);
if (result.success) {
return { ok: true, data: result.data };
}
return { ok: false, errors: formatValidationErrors(result.error.issues) };
}
/**
* Validate a single coding iteration.
*/
export function validateCodingIteration(
input: Record<string, unknown>,
): ValidationResult<CodingIteration> {
const result = CodingIterationSchema.safeParse(input);
if (result.success) {
return { ok: true, data: result.data };
}
return { ok: false, errors: formatValidationErrors(result.error.issues) };
}
/**
* Validate human feedback input.
*/
export function validateHumanFeedback(
input: Record<string, unknown>,
): ValidationResult<HumanFeedback> {
const result = HumanFeedbackSchema.safeParse(input);
if (result.success) {
return { ok: true, data: result.data };
}
return { ok: false, errors: formatValidationErrors(result.error.issues) };
}
/**
* Validate a connector configuration.
*/
export function validateConnectorConfig(
input: Record<string, unknown>,
): ValidationResult<ConnectorConfig> {
const result = ConnectorConfigSchema.safeParse(input);
if (result.success) {
return { ok: true, data: result.data };
}
return { ok: false, errors: formatValidationErrors(result.error.issues) };
}
// ---------------------------------------------------------------------------
// Context helpers
// ---------------------------------------------------------------------------
/**
* Create a new coding agent context with sensible defaults.
*/
export function createCodingAgentContext(params: {
sessionId: string;
taskDescription: string;
workingDirectory: string;
connectorType: ConnectorType;
connectorBasePath: string;
interactionMode?: InteractionMode;
maxIterations?: number;
}): CodingAgentContext {
const now = Date.now();
return {
sessionId: params.sessionId,
taskDescription: params.taskDescription,
workingDirectory: params.workingDirectory,
connector: {
type: params.connectorType,
basePath: params.connectorBasePath,
available: true,
},
interactionMode: params.interactionMode ?? "fully-automated",
maxIterations: params.maxIterations ?? 10,
active: true,
iterations: [],
allFeedback: [],
createdAt: now,
updatedAt: now,
};
}
/**
* Check if the coding agent context has reached its iteration limit.
*/
export function hasReachedMaxIterations(ctx: CodingAgentContext): boolean {
return ctx.iterations.length >= ctx.maxIterations;
}
/**
* Check if the latest iteration resolved all errors.
*/
export function isLastIterationClean(ctx: CodingAgentContext): boolean {
if (ctx.iterations.length === 0) return true;
const last = ctx.iterations[ctx.iterations.length - 1];
if (!last) return true;
return last.errors.length === 0;
}
/**
* Get all unresolved errors from the latest iteration.
*/
export function getUnresolvedErrors(ctx: CodingAgentContext): CapturedError[] {
if (ctx.iterations.length === 0) return [];
const last = ctx.iterations[ctx.iterations.length - 1];
if (!last) return [];
return last.errors;
}
/**
* Add an iteration to the context and update the timestamp.
*/
export function addIteration(
ctx: CodingAgentContext,
iteration: CodingIteration,
): CodingAgentContext {
return {
...ctx,
iterations: [...ctx.iterations, iteration],
updatedAt: Date.now(),
};
}
/**
* Inject human feedback into the context.
*/
export function injectFeedback(
ctx: CodingAgentContext,
feedback: HumanFeedback,
): CodingAgentContext {
return {
...ctx,
allFeedback: [...ctx.allFeedback, feedback],
updatedAt: Date.now(),
};
}
/**
* Determine if the coding loop should continue based on context state.
*/
export function shouldContinueLoop(ctx: CodingAgentContext): {
shouldContinue: boolean;
reason: string;
} {
if (!ctx.active) {
return { shouldContinue: false, reason: "Session is no longer active" };
}
if (hasReachedMaxIterations(ctx)) {
return {
shouldContinue: false,
reason: `Reached maximum iterations (${ctx.maxIterations})`,
};
}
if (isLastIterationClean(ctx) && ctx.iterations.length > 0) {
return {
shouldContinue: false,
reason: "Last iteration completed without errors",
};
}
// Check for human rejection feedback that should halt the loop
const lastFeedback = ctx.allFeedback[ctx.allFeedback.length - 1];
if (lastFeedback?.type === "rejection") {
return {
shouldContinue: false,
reason: "User rejected the last iteration",
};
}
return { shouldContinue: true, reason: "Errors to resolve or work to do" };
}
@@ -0,0 +1,255 @@
/**
* `CodingTaskExecutor`: maps a coding-related task spec into the agent action that
* fulfils it, resolving the delegation action name via the core registry. Consumed
* by orchestration plugins that hand coding work to a coding-capable agent.
*/
import crypto from "node:crypto";
import type { Content, IAgentRuntime, Memory, UUID } from "@elizaos/core";
import { findCodingDelegationActionName, ModelType } from "@elizaos/core";
type TaskSpec = {
id: string;
description: string;
type: string;
metadata?: Record<string, unknown>;
agentType?: string;
message?: Memory;
};
type TaskResult = {
taskId: string;
success: boolean;
output?: string;
artifacts?: Array<{ name: string; path: string; type: string }>;
error?: string;
durationMs?: number;
};
type TaskExecutor = {
readonly type: string;
readonly description: string;
canHandle(spec: TaskSpec, runtime: IAgentRuntime): boolean;
execute(spec: TaskSpec, runtime: IAgentRuntime): Promise<TaskResult>;
abort(taskId: string): Promise<void>;
};
const CODING_PATTERNS =
/\b(build|create|make|scaffold|generate|code|implement|develop|fix|debug|refactor|write)\b/i;
type CreateTaskActionLike = {
name: string;
validate?: (
runtime: IAgentRuntime,
memory: Memory,
state?: unknown,
) => Promise<boolean>;
handler: (
runtime: IAgentRuntime,
memory: Memory,
state?: unknown,
options?: { parameters?: Record<string, unknown> },
callback?: (content: Content) => Promise<Memory[]>,
) => Promise<unknown>;
};
async function rewriteCodingActionText(args: {
runtime: IAgentRuntime;
actionName: string;
text: string;
}): Promise<string> {
const text = args.text.trim();
if (!text) return args.text;
const fallback = () =>
`I ran ${args.actionName} and got a coding task result, but I couldn't format the details cleanly here.`;
if (typeof args.runtime.useModel !== "function") return fallback();
try {
const raw = await args.runtime.useModel(ModelType.TEXT_SMALL, {
prompt: [
"Rewrite this coding action output in the assistant character's user-facing voice.",
'Return strict JSON only: {"response":"..."}.',
"",
"Rules:",
"- Preserve task IDs, session IDs, file paths, status, errors, and next steps.",
"- Do not expose raw JSON, shell output, schema names, stack traces, or internal action plumbing unless an exact value is necessary.",
"- Do not claim success if the payload says failed or pending.",
"- Keep it brief and natural.",
"",
`Character: ${JSON.stringify({
name: args.runtime.character?.name,
system: args.runtime.character?.system,
bio: args.runtime.character?.bio,
style: args.runtime.character?.style,
})}`,
`Action: ${JSON.stringify(args.actionName)}`,
`Payload: ${JSON.stringify(text)}`,
].join("\n"),
maxTokens: 260,
providerOptions: { eliza: { thinking: "off" } },
});
const parsed = JSON.parse(String(raw).trim()) as { response?: unknown };
return typeof parsed.response === "string" && parsed.response.trim()
? parsed.response.trim()
: fallback();
} catch {
// error-policy:J4 the model call only prettifies already-computed output
// into the character voice; on failure the deterministic fallback preserves
// that the action ran (its authoritative success/error is carried by the
// TaskResult, not by this cosmetic rewrite).
return fallback();
}
}
function findCreateTaskAction(
runtime: IAgentRuntime,
): CreateTaskActionLike | null {
const actions = Array.isArray(runtime.actions)
? (runtime.actions as CreateTaskActionLike[])
: [];
const actionName = findCodingDelegationActionName(actions);
return actions.find((action) => action.name === actionName) ?? null;
}
function buildSyntheticTaskMemory(
runtime: IAgentRuntime,
spec: TaskSpec,
): Memory {
if (spec.message) {
return spec.message;
}
const roomId = (runtime.agentId || "room-default") as UUID;
return {
id: crypto.randomUUID() as UUID,
entityId: runtime.agentId,
agentId: runtime.agentId,
roomId,
content: {
text: spec.description,
agentType: spec.agentType,
} as Content,
createdAt: Date.now(),
};
}
/**
* Executes coding tasks by delegating to the START_CODING_TASK / CREATE_TASK
* action registered by the orchestrator plugin.
*/
export class CodingTaskExecutor implements TaskExecutor {
readonly type = "coding";
readonly description =
"Executes coding tasks using the orchestrator task contract";
canHandle(spec: TaskSpec, runtime: IAgentRuntime): boolean {
if (!findCreateTaskAction(runtime)) return false;
// Explicit type match
if (spec.type === "coding") return true;
// Heuristic: description matches coding-related verbs
return CODING_PATTERNS.test(spec.description);
}
async execute(spec: TaskSpec, runtime: IAgentRuntime): Promise<TaskResult> {
const action = findCreateTaskAction(runtime);
const startTime = Date.now();
if (!action) {
return {
taskId: spec.id,
success: false,
error: "Task orchestrator is not available",
};
}
const memory = buildSyntheticTaskMemory(runtime, spec);
const callbackLines: string[] = [];
const callback = async (content: Content): Promise<Memory[]> => {
if (typeof content.text === "string" && content.text.trim().length > 0) {
callbackLines.push(
await rewriteCodingActionText({
runtime,
actionName: action.name,
text: content.text,
}),
);
}
return [];
};
try {
if (action.validate) {
const valid = await action.validate(runtime, memory, undefined);
if (!valid) {
return {
taskId: spec.id,
success: false,
error: "Task orchestrator rejected the coding task request",
durationMs: Date.now() - startTime,
};
}
}
const result = (await action.handler(
runtime,
memory,
undefined,
{
parameters: {
task: spec.description,
...(spec.agentType ? { agentType: spec.agentType } : {}),
},
},
callback,
)) as
| {
success?: boolean;
text?: string;
data?: {
agents?: Array<{ sessionId?: string }>;
};
error?: string;
}
| undefined;
const sessionId = result?.data?.agents?.[0]?.sessionId;
const resultText =
typeof result?.text === "string" && result.text.trim()
? await rewriteCodingActionText({
runtime,
actionName: action.name,
text: result.text,
})
: undefined;
const output = sessionId || resultText || callbackLines.join("\n");
if (result?.success === false) {
return {
taskId: spec.id,
success: false,
error: result.error || result.text || "Task creation failed",
durationMs: Date.now() - startTime,
};
}
return {
taskId: spec.id,
success: true,
output,
durationMs: Date.now() - startTime,
};
} catch (error) {
// error-policy:J1 task-executor boundary; an execution failure becomes a
// structured TaskResult with success:false and the real error message.
return {
taskId: spec.id,
success: false,
error: error instanceof Error ? error.message : String(error),
durationMs: Date.now() - startTime,
};
}
}
async abort(_taskId: string): Promise<void> {
// Abort is handled through the existing PTY session stop mechanism.
}
}
@@ -0,0 +1,116 @@
/**
* `FileStateService` (serviceType `CODING_TOOLS_FILE_STATE`): tracks the mtime and
* size of each file per conversation at read time so write/edit handlers can reject
* a write when the file was modified externally since the last read — the
* read-before-write invariant that keeps agent edits from clobbering outside
* changes.
*/
import * as fs from "node:fs/promises";
import {
logger as coreLogger,
type IAgentRuntime,
Service,
} from "@elizaos/core";
import type { FileMeta } from "../types.js";
import { CODING_TOOLS_LOG_PREFIX, FILE_STATE_SERVICE } from "../types.js";
/**
* Tracks per-(conversation, file) read state. Mirrors Claude's `readFileState` —
* the gate that lets WRITE/EDIT detect whether a file was modified externally
* since the agent last read it. Without this the agent will overwrite human
* changes silently.
*
* Keyed by `${conversationId}::${absolutePath}`. State lives in memory; on
* runtime restart the agent must re-Read before Write/Edit.
*/
export class FileStateService extends Service {
static serviceType = FILE_STATE_SERVICE;
capabilityDescription =
"Per-conversation file mtime tracking for safe Write/Edit operations.";
private state = new Map<string, FileMeta>();
static async start(runtime: IAgentRuntime): Promise<FileStateService> {
const svc = new FileStateService(runtime);
coreLogger.debug(`${CODING_TOOLS_LOG_PREFIX} FileStateService started`);
return svc;
}
async stop(): Promise<void> {
this.state.clear();
}
private key(conversationId: string, absPath: string): string {
return `${conversationId}::${absPath}`;
}
async recordRead(conversationId: string, absPath: string): Promise<void> {
const stat = await fs.stat(absPath);
this.state.set(this.key(conversationId, absPath), {
path: absPath,
mtimeMs: stat.mtimeMs,
size: stat.size,
readAt: Date.now(),
});
}
async recordWrite(conversationId: string, absPath: string): Promise<void> {
const stat = await fs.stat(absPath);
this.state.set(this.key(conversationId, absPath), {
path: absPath,
mtimeMs: stat.mtimeMs,
size: stat.size,
readAt: Date.now(),
});
}
get(conversationId: string, absPath: string): FileMeta | undefined {
return this.state.get(this.key(conversationId, absPath));
}
async assertWritable(
conversationId: string,
absPath: string,
): Promise<
| { ok: true }
| { ok: false; reason: "must_read_first" | "stale_read"; message: string }
> {
let stat: Awaited<ReturnType<typeof fs.stat>>;
try {
stat = await fs.stat(absPath);
} catch {
// error-policy:J3 existence probe — any stat failure routes to the
// create-new-file path (Write is allowed; Edit re-verifies existence
// separately). A genuine permission error is not masked: the subsequent
// fs.writeFile surfaces it to the caller as an `io_error` failure.
return { ok: true };
}
const meta = this.get(conversationId, absPath);
if (!meta) {
return {
ok: false,
reason: "must_read_first",
message: `File ${absPath} exists but was not read in this session. Read it first.`,
};
}
if (stat.mtimeMs !== meta.mtimeMs) {
return {
ok: false,
reason: "stale_read",
message: `File ${absPath} was modified externally since last read (mtime ${meta.mtimeMs}${stat.mtimeMs}). Re-read before writing.`,
};
}
return { ok: true };
}
invalidate(conversationId: string, absPath: string): void {
this.state.delete(this.key(conversationId, absPath));
}
clearConversation(conversationId: string): void {
const prefix = `${conversationId}::`;
for (const k of this.state.keys()) {
if (k.startsWith(prefix)) this.state.delete(k);
}
}
}
@@ -0,0 +1,12 @@
/** Barrel for the coding-tools services and the coding-agent-context Zod schemas. */
export * from "./coding-agent-context.js";
export { CodingTaskExecutor } from "./coding-task-executor.js";
export { FileStateService } from "./file-state-service.js";
export {
type RipgrepMode,
type RipgrepOptions,
type RipgrepResult,
RipgrepService,
} from "./ripgrep-service.js";
export { SandboxService } from "./sandbox-service.js";
export { SessionCwdService } from "./session-cwd-service.js";
@@ -0,0 +1,191 @@
/**
* `RipgrepService` (serviceType `CODING_TOOLS_RIPGREP`): wraps the
* `@vscode/ripgrep` binary (falling back to a system `rg` on PATH) for the FILE
* `grep` operation. Always excludes VCS directories and enforces a hard 30 s cap.
*/
import { execFile } from "node:child_process";
import { statSync } from "node:fs";
import * as path from "node:path";
import {
logger as coreLogger,
type IAgentRuntime,
Service,
} from "@elizaos/core";
import { CODING_TOOLS_LOG_PREFIX, RIPGREP_SERVICE } from "../types.js";
const VCS_EXCLUDES = [".git", ".svn", ".hg", ".bzr", ".jj", ".sl"];
export interface RipgrepOptions {
pattern: string;
path: string;
glob?: string;
type?: string;
contextBefore?: number;
contextAfter?: number;
contextAround?: number;
caseInsensitive?: boolean;
multiline?: boolean;
showLineNumbers?: boolean;
maxCount?: number;
}
export type RipgrepMode = "content" | "files_with_matches" | "count";
export interface RipgrepResult {
mode: RipgrepMode;
output: string;
exitCode: number;
truncated: boolean;
}
/**
* Wraps the `@vscode/ripgrep` binary. Search is the only thing it does.
* Always excludes VCS directories. Bounded by `maxCount` and a hard 30s
* runtime cap.
*/
export class RipgrepService extends Service {
static serviceType = RIPGREP_SERVICE;
capabilityDescription = "Bounded ripgrep wrapper with VCS exclusion.";
private rgPath: string | undefined;
static async start(runtime: IAgentRuntime): Promise<RipgrepService> {
const svc = new RipgrepService(runtime);
await svc.locateBinary();
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} RipgrepService started (rg=${svc.rgPath ?? "system"})`,
);
return svc;
}
async stop(): Promise<void> {
// No persistent ripgrep process is held by this service.
}
private async locateBinary(): Promise<void> {
try {
const mod = (await import("@vscode/ripgrep")) as { rgPath?: string };
if (mod && typeof mod.rgPath === "string") {
this.rgPath = mod.rgPath;
return;
}
} catch {
// error-policy:J4 the bundled `@vscode/ripgrep` import is optional; when
// it is absent we degrade to a system `rg` on PATH (the resolved binary
// is logged at service start). `search()` surfaces a missing `rg` as a
// spawn error, so this fallback cannot silently hide an unusable binary.
}
this.rgPath = "rg";
}
binary(): string {
return this.rgPath ?? "rg";
}
async search(
options: RipgrepOptions,
mode: RipgrepMode,
): Promise<RipgrepResult> {
const args: string[] = ["--no-config"];
if (mode === "files_with_matches") args.push("--files-with-matches");
else if (mode === "count") args.push("--count");
else {
args.push("--no-heading");
if (options.showLineNumbers) args.push("-n");
}
if (options.caseInsensitive) args.push("-i");
if (options.multiline) args.push("--multiline", "--multiline-dotall");
if (options.glob) args.push("-g", options.glob);
if (options.type) args.push("-t", options.type);
if (options.maxCount && mode === "content") {
args.push("-m", String(options.maxCount));
}
if (options.contextBefore !== undefined)
args.push("-B", String(options.contextBefore));
if (options.contextAfter !== undefined)
args.push("-A", String(options.contextAfter));
if (options.contextAround !== undefined)
args.push("-C", String(options.contextAround));
for (const dir of VCS_EXCLUDES) {
args.push("-g", `!${dir}/**`);
}
const executionPath = resolveExecutionPath(options.path);
args.push("--", options.pattern, executionPath.searchPath);
return runRipgrep(this.binary(), args, mode, executionPath.cwd);
}
}
function resolveExecutionPath(targetPath: string): {
cwd?: string;
searchPath: string;
} {
try {
const stat = statSync(targetPath);
if (stat.isDirectory()) {
return { cwd: targetPath, searchPath: "." };
}
return {
cwd: path.dirname(targetPath),
searchPath: path.basename(targetPath),
};
} catch {
// error-policy:J3 existence probe on the search target; when stat fails the
// literal path is handed to ripgrep as the search argument so ripgrep
// itself reports the miss, rather than fabricating a cwd/searchPath split.
return { searchPath: targetPath };
}
}
function runRipgrep(
rg: string,
args: string[],
mode: RipgrepMode,
cwd: string | undefined,
): Promise<RipgrepResult> {
return new Promise((resolve) => {
const HARD_CAP_BYTES = 5_000_000;
execFile(
rg,
args,
{
encoding: "utf8",
maxBuffer: HARD_CAP_BYTES,
timeout: 30_000,
...(cwd ? { cwd } : {}),
},
(error, stdout, stderr) => {
const output = stdout || stderr;
if (!error) {
resolve({ mode, output, exitCode: 0, truncated: false });
return;
}
const err = error as NodeJS.ErrnoException & {
killed?: boolean;
signal?: NodeJS.Signals | null;
};
if (err.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER") {
resolve({ mode, output, exitCode: 0, truncated: true });
return;
}
if (typeof err.code === "number") {
resolve({ mode, output, exitCode: err.code, truncated: false });
return;
}
const timedOut = err.killed || err.signal === "SIGTERM";
resolve({
mode,
output:
output ||
(timedOut
? "ripgrep timed out after 30000ms"
: `ripgrep error: ${err.message}`),
exitCode: -1,
truncated: false,
});
},
);
});
}
@@ -0,0 +1,256 @@
/** Tests for the SandboxService path policy: blocklist defaults and allow-root enforcement. */
import { homedir } from "node:os";
import * as path from "node:path";
import type { IAgentRuntime } from "@elizaos/core";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { SandboxService } from "./sandbox-service.js";
const ENV_KEYS = [
"CODING_TOOLS_BLOCKED_PATHS",
"CODING_TOOLS_BLOCKED_PATHS_ADD",
"CODING_TOOLS_WORKSPACE_ROOTS",
"ELIZA_PLATFORM",
"ANDROID_ROOT",
"ANDROID_DATA",
] as const;
let savedEnv: Partial<Record<(typeof ENV_KEYS)[number], string | undefined>>;
function mockRuntime(settings: Record<string, unknown> = {}): IAgentRuntime {
return {
agentId: "00000000-0000-0000-0000-000000000000",
getSetting: (key: string) => settings[key],
getService: () => null,
} as IAgentRuntime;
}
describe("SandboxService default blocklist", () => {
beforeEach(() => {
savedEnv = {};
for (const key of ENV_KEYS) {
savedEnv[key] = process.env[key];
delete process.env[key];
}
});
afterEach(() => {
for (const key of ENV_KEYS) {
const previous = savedEnv[key];
if (previous === undefined) {
delete process.env[key];
} else {
process.env[key] = previous;
}
}
});
it("always blocks user-home credential dirs", async () => {
const svc = await SandboxService.start(mockRuntime());
const blocked = svc.getBlockedPaths();
const home = homedir();
for (const sub of [
".ssh",
".aws",
".gnupg",
".docker",
".kube",
".netrc",
]) {
const expected = path.join(home, sub);
expect(
blocked.some(
(b) =>
b === expected || b.startsWith(expected) || expected.startsWith(b),
),
`${expected} should appear (or its realpath) in default blocklist`,
).toBe(true);
}
expect(blocked.some((b) => b.endsWith(path.join("/", "pvt")))).toBe(true);
expect(blocked.some((b) => b.endsWith(path.join("/", "Library")))).toBe(
true,
);
});
if (process.platform === "darwin") {
it("(darwin) blocks /System and /usr/bin by default", async () => {
const svc = await SandboxService.start(mockRuntime());
const blocked = svc.getBlockedPaths();
expect(blocked).toContain("/System");
expect(blocked).toContain("/usr/bin");
expect(blocked).toContain("/usr/sbin");
expect(blocked).toContain("/Library/LaunchDaemons");
});
it("(darwin) /etc realpath-resolves to /private/etc and blocks reads under it", async () => {
const svc = await SandboxService.start(mockRuntime());
const v = await svc.validatePath(undefined, "/etc/hosts");
expect(v.ok).toBe(false);
if (!v.ok) expect(v.reason).toBe("blocked");
});
it("(darwin) blocks paths under /System", async () => {
const svc = await SandboxService.start(mockRuntime());
const v = await svc.validatePath(
undefined,
"/System/Library/Frameworks/foo",
);
expect(v.ok).toBe(false);
});
}
if (process.platform === "linux") {
it("(linux) blocks /etc, /boot, /sys, /root by default", async () => {
const svc = await SandboxService.start(mockRuntime());
const blocked = svc.getBlockedPaths();
expect(blocked).toContain("/etc");
expect(blocked).toContain("/boot");
expect(blocked).toContain("/sys");
expect(blocked).toContain("/root");
expect(blocked).toContain("/usr/bin");
});
}
if (process.platform === "win32") {
it("(win32) blocks %SystemRoot%, %ProgramFiles%, %ProgramData% by default", async () => {
const svc = await SandboxService.start(mockRuntime());
const blocked = svc.getBlockedPaths();
const sysRoot = process.env.SystemRoot ?? "C:\\Windows";
const pf = process.env.ProgramFiles ?? "C:\\Program Files";
const pd = process.env.ProgramData ?? "C:\\ProgramData";
// `loadConfig()` realpath-normalises every blocklist entry, which on
// Windows returns the canonical on-disk casing (`C:\Windows`),
// whereas `process.env.SystemRoot` is whatever case the environment
// exposes (`C:\WINDOWS`). NTFS is case-insensitive — compare lowered.
const samePath = (a: string, b: string): boolean =>
path.resolve(a).toLowerCase() === path.resolve(b).toLowerCase();
expect(blocked.some((b) => samePath(b, sysRoot))).toBe(true);
expect(blocked.some((b) => samePath(b, pf))).toBe(true);
expect(blocked.some((b) => samePath(b, pd))).toBe(true);
});
}
it("CODING_TOOLS_BLOCKED_PATHS replaces the default list", async () => {
const svc = await SandboxService.start(
mockRuntime({ CODING_TOOLS_BLOCKED_PATHS: "/tmp/only-this" }),
);
const blocked = svc.getBlockedPaths();
expect(blocked.length).toBe(1);
expect(blocked[0]).toMatch(/only-this$/);
});
it("CODING_TOOLS_BLOCKED_PATHS_ADD extends the default list", async () => {
const svc = await SandboxService.start(
mockRuntime({ CODING_TOOLS_BLOCKED_PATHS_ADD: "/tmp/extra-block" }),
);
const blocked = svc.getBlockedPaths();
expect(blocked.some((b) => b.endsWith("extra-block"))).toBe(true);
// Defaults still present.
expect(blocked.some((b) => b.endsWith(path.join(".ssh")))).toBe(true);
});
it("reads coding-tools config from process.env when runtime settings omit it", async () => {
const previous = process.env.CODING_TOOLS_BLOCKED_PATHS;
try {
process.env.CODING_TOOLS_BLOCKED_PATHS = "/tmp/env-only-block";
const svc = await SandboxService.start(mockRuntime());
expect(svc.getBlockedPaths()).toEqual(
expect.arrayContaining([expect.stringMatching(/env-only-block$/)]),
);
} finally {
if (previous === undefined) delete process.env.CODING_TOOLS_BLOCKED_PATHS;
else process.env.CODING_TOOLS_BLOCKED_PATHS = previous;
}
});
it("expands ~ and $HOME in configured paths", async () => {
const svc = await SandboxService.start(
mockRuntime({
CODING_TOOLS_BLOCKED_PATHS: "~/blocked-tilde,$HOME/blocked-home",
}),
);
const blocked = svc.getBlockedPaths();
const home = homedir();
expect(blocked).toContain(path.join(home, "blocked-tilde"));
expect(blocked).toContain(path.join(home, "blocked-home"));
});
it("rejects relative paths regardless of blocklist", async () => {
const svc = await SandboxService.start(mockRuntime());
const v = await svc.validatePath(undefined, "relative/path");
expect(v.ok).toBe(false);
if (!v.ok) expect(v.reason).toBe("not_absolute");
});
it("limits access to configured CODING_TOOLS_WORKSPACE_ROOTS", async () => {
const root = path.join(homedir(), "coding-tools-root");
const svc = await SandboxService.start(
mockRuntime({ CODING_TOOLS_WORKSPACE_ROOTS: root }),
);
expect(svc.getAllowedRoots()).toContain(root);
const inside = await svc.validatePath(
undefined,
path.join(root, "file.ts"),
);
expect(inside.ok).toBe(true);
const outside = await svc.validatePath(
undefined,
path.join(homedir(), "outside-root", "file.ts"),
);
expect(outside.ok).toBe(false);
if (!outside.ok) expect(outside.reason).toBe("outside_allowed_roots");
});
it("supports conversation-scoped allow roots", async () => {
const root = path.join(homedir(), "conversation-root");
const svc = await SandboxService.start(mockRuntime());
svc.addRoot("conversation-1", root);
const inside = await svc.validatePath(
"conversation-1",
path.join(root, "nested", "file.ts"),
);
expect(inside.ok).toBe(true);
expect(svc.getAllowedRoots("conversation-1")).toContain(root);
svc.removeRoot("conversation-1", root);
expect(svc.getAllowedRoots("conversation-1")).not.toContain(root);
});
// The Android blocklist is hard-coded as POSIX-rooted paths (`/vendor`,
// `/apex`, …) that `loadConfig` runs through `path.resolve`. On a Windows
// host that rewrites them to `C:\vendor`, so the literal `/vendor`
// assertion can't hold. The runtime never actually executes on Windows
// as an Android device, so skip on Windows rather than fabricate a fake
// platform expectation.
const itAndroidSim = process.platform === "win32" ? it.skip : it;
itAndroidSim(
"adds Android system roots to the default blocklist on AOSP/mobile Android",
async () => {
const previous = process.env.ELIZA_PLATFORM;
try {
process.env.ELIZA_PLATFORM = "android";
const svc = await SandboxService.start(mockRuntime());
const blocked = svc.getBlockedPaths();
expect(blocked).toEqual(expect.arrayContaining(["/vendor", "/apex"]));
expect(blocked.some((p) => p.toLowerCase() === "/system")).toBe(true);
const v = await svc.validatePath(undefined, "/vendor/bin/sh");
expect(v.ok).toBe(false);
if (!v.ok) expect(v.reason).toBe("blocked");
} finally {
if (previous === undefined) delete process.env.ELIZA_PLATFORM;
else process.env.ELIZA_PLATFORM = previous;
}
},
);
it("permits paths outside the blocklist", async () => {
const svc = await SandboxService.start(mockRuntime());
const v = await svc.validatePath(
undefined,
path.join(homedir(), "totally-fine-dir"),
);
expect(v.ok).toBe(true);
});
});
@@ -0,0 +1,349 @@
/**
* `SandboxService` (serviceType `CODING_TOOLS_SANDBOX`): the path-access policy for
* every filesystem operation. `validatePath` rejects paths on the blocklist
* (defaults cover `~/.ssh`, `~/.aws`, `~/.gnupg`, credential stores, and per-OS
* system paths) and, when `CODING_TOOLS_WORKSPACE_ROOTS` is set, any path outside
* the allow-roots. No file handler may touch disk without passing through here.
*/
import { homedir } from "node:os";
import * as path from "node:path";
import {
logger as coreLogger,
type IAgentRuntime,
Service,
} from "@elizaos/core";
import { readAliasedEnv } from "@elizaos/shared";
import {
isAbsolutePath,
isUncPath,
isWithin,
resolveRealPath,
} from "../lib/path-utils.js";
import { CODING_TOOLS_LOG_PREFIX, SANDBOX_SERVICE } from "../types.js";
/**
* Path-blocklist policy for the coding tools.
*
* Coding tools default to *trusted* mode: the agent can read and write
* anywhere on disk EXCEPT a small list of paths that hold private user
* data plus per-OS system paths (system binaries, kernel/boot files,
* Windows AppData crypto + cert stores).
*
* Configuration:
* CODING_TOOLS_BLOCKED_PATHS=/abs1,/abs2,... — comma-separated absolute
* paths. Replaces the default list when set.
* CODING_TOOLS_BLOCKED_PATHS_ADD=/abs1,... — comma-separated absolute
* paths to ADD to the default list (most common UI use).
* CODING_TOOLS_WORKSPACE_ROOTS=/abs1,/abs2 — optional comma-separated
* allow roots. When set, coding tools may only access paths under these
* roots after the blocklist check.
*
* Both `~` and `$HOME` are expanded.
*/
export class SandboxService extends Service {
static serviceType = SANDBOX_SERVICE;
capabilityDescription =
"Path safety policy for coding tools. Blocks sensitive paths and optionally constrains access to configured workspace roots.";
private blockedPaths: string[] = [];
private allowedRoots: string[] = [];
private conversationRoots = new Map<string, Set<string>>();
static async start(runtime: IAgentRuntime): Promise<SandboxService> {
const svc = new SandboxService(runtime);
await svc.loadConfig();
coreLogger.debug(
`${CODING_TOOLS_LOG_PREFIX} SandboxService: blocking ${svc.blockedPaths.length} path(s), allow-roots=${svc.allowedRoots.length}`,
);
return svc;
}
async stop(): Promise<void> {
this.blockedPaths = [];
this.allowedRoots = [];
this.conversationRoots.clear();
}
private async loadConfig(): Promise<void> {
const replace = readStringSetting(
this.runtime,
"CODING_TOOLS_BLOCKED_PATHS",
);
const additions = readStringSetting(
this.runtime,
"CODING_TOOLS_BLOCKED_PATHS_ADD",
);
const allowedRoots = readStringSetting(
this.runtime,
"CODING_TOOLS_WORKSPACE_ROOTS",
);
let paths: string[];
if (replace && replace.trim().length > 0) {
paths = parseList(replace);
} else {
paths = defaultBlockedPaths();
}
if (additions && additions.trim().length > 0) {
paths = paths.concat(parseList(additions));
}
// realpath each path so macOS /var ↔ /private/var (and Linux symlinked
// paths) match correctly against realpath-resolved targets in
// validatePath.
const resolved = await Promise.all(
paths.map(async (p) => resolveRealPath(path.resolve(expandHome(p)))),
);
this.blockedPaths = dedupe(resolved);
const resolvedAllowedRoots =
allowedRoots && allowedRoots.trim().length > 0
? await Promise.all(
parseList(allowedRoots).map(async (p) =>
resolveRealPath(path.resolve(expandHome(p))),
),
)
: [];
this.allowedRoots = dedupe(resolvedAllowedRoots);
}
/**
* Return the active blocklist (resolved absolute paths). Used by tests and
* the available-tools provider.
*/
getBlockedPaths(): string[] {
return this.blockedPaths.slice();
}
/**
* Return globally configured allow roots (resolved absolute paths).
* Conversation-scoped roots are intentionally omitted unless a conversation
* id is provided.
*/
getAllowedRoots(conversationId?: string): string[] {
return this.resolveAllowedRoots(conversationId).slice();
}
addRoot(conversationId: string | undefined, absPath: string): void {
if (!conversationId || !isAbsolutePath(absPath) || isUncPath(absPath)) {
return;
}
const roots = this.conversationRoots.get(conversationId) ?? new Set();
roots.add(path.resolve(expandHome(absPath)));
this.conversationRoots.set(conversationId, roots);
}
removeRoot(conversationId: string | undefined, absPath: string): void {
if (!conversationId || !isAbsolutePath(absPath) || isUncPath(absPath)) {
return;
}
const roots = this.conversationRoots.get(conversationId);
if (!roots) return;
roots.delete(path.resolve(expandHome(absPath)));
if (roots.size === 0) {
this.conversationRoots.delete(conversationId);
}
}
async validatePath(
conversationId: string | undefined,
absPath: string,
): Promise<
| { ok: true; resolved: string }
| {
ok: false;
reason:
| "not_absolute"
| "unc_path"
| "blocked"
| "outside_allowed_roots";
message: string;
}
> {
if (!isAbsolutePath(absPath)) {
return {
ok: false,
reason: "not_absolute",
message: `Path must be absolute, got ${JSON.stringify(absPath)}`,
};
}
if (isUncPath(absPath)) {
return {
ok: false,
reason: "unc_path",
message: `UNC paths are not permitted: ${absPath}`,
};
}
const resolved = await resolveRealPath(absPath);
for (const blocked of this.blockedPaths) {
if (isWithin(resolved, blocked) || resolved === blocked) {
return {
ok: false,
reason: "blocked",
message: `Path ${absPath} is under blocked location ${blocked}.`,
};
}
}
const allowedRoots = this.resolveAllowedRoots(conversationId);
if (allowedRoots.length > 0) {
const underAllowedRoot = allowedRoots.some(
(root) => isWithin(resolved, root) || resolved === root,
);
if (!underAllowedRoot) {
return {
ok: false,
reason: "outside_allowed_roots",
message: `Path ${absPath} is outside the configured coding workspace roots.`,
};
}
}
return { ok: true, resolved };
}
private resolveAllowedRoots(conversationId?: string): string[] {
const roots = [...this.allowedRoots];
if (conversationId) {
const scoped = this.conversationRoots.get(conversationId);
if (scoped) roots.push(...scoped);
}
return dedupe(roots);
}
}
function readStringSetting(
runtime: IAgentRuntime,
key: string,
): string | undefined {
const fromRuntime = runtime.getSetting(key);
if (typeof fromRuntime === "string" && fromRuntime.trim().length > 0) {
return fromRuntime;
}
const fromEnv = process.env[key];
if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
return fromEnv;
}
return undefined;
}
function parseList(s: string): string[] {
return s
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0);
}
function expandHome(p: string): string {
if (p === "~") return homedir();
if (p.startsWith("~/")) return path.join(homedir(), p.slice(2));
if (p.startsWith("$HOME/")) return path.join(homedir(), p.slice(6));
return p;
}
function defaultBlockedPaths(): string[] {
const home = homedir();
// User-private home subdirs we never want the agent to touch on any OS.
const userHome = [
path.join(home, "pvt"),
path.join(home, "Library"),
path.join(home, ".ssh"),
path.join(home, ".aws"),
path.join(home, ".gnupg"),
path.join(home, ".docker"),
path.join(home, ".kube"),
path.join(home, ".netrc"),
];
if (isAndroidRuntime()) {
return [
...userHome,
"/apex",
"/config",
"/dev",
"/etc",
"/metadata",
"/mnt/runtime",
"/mnt/vendor",
"/odm",
"/proc",
"/product",
"/sys",
"/system",
"/vendor",
"/data/misc",
"/data/system",
"/data/vendor",
];
}
switch (process.platform) {
case "darwin":
return [
...userHome,
// /etc and /var symlink to /private/{etc,var} on macOS; realpath in
// loadConfig handles that, so blocking either form catches both.
"/System",
"/Library/LaunchDaemons",
"/Library/LaunchAgents",
"/usr/bin",
"/usr/sbin",
"/usr/libexec",
"/bin",
"/sbin",
"/etc",
"/var/db",
"/var/root",
];
case "linux":
return [
...userHome,
"/etc",
"/boot",
"/sys",
"/usr/bin",
"/usr/sbin",
"/bin",
"/sbin",
"/root",
"/var/lib/dpkg",
"/var/lib/apt",
];
case "win32": {
const systemRoot = process.env.SystemRoot ?? "C:\\Windows";
const programFiles = process.env.ProgramFiles ?? "C:\\Program Files";
const programFilesX86 =
process.env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
const programData = process.env.ProgramData ?? "C:\\ProgramData";
return [
...userHome,
path.join(home, "AppData", "Roaming", "Microsoft", "Crypto"),
path.join(home, "AppData", "Local", "Microsoft", "Credentials"),
path.join(home, "AppData", "Roaming", "Microsoft", "Protect"),
path.join(
home,
"AppData",
"Roaming",
"Microsoft",
"SystemCertificates",
),
systemRoot,
programFiles,
programFilesX86,
programData,
];
}
default:
return userHome;
}
}
function isAndroidRuntime(): boolean {
return (
readAliasedEnv("ELIZA_PLATFORM")?.toLowerCase() === "android" ||
Boolean(process.env.ANDROID_ROOT || process.env.ANDROID_DATA)
);
}
function dedupe(arr: string[]): string[] {
return Array.from(new Set(arr));
}
@@ -0,0 +1,112 @@
/**
* `SessionCwdService` (serviceType `CODING_TOOLS_SESSION_CWD`): the per-conversation
* working directory, defaulting to `process.cwd()`. Glob/grep/ls/shell use it when
* no explicit path is given; the worktree actions push/pop it as a stack when
* entering and leaving worktrees.
*/
import * as fs from "node:fs/promises";
import * as path from "node:path";
import {
logger as coreLogger,
type IAgentRuntime,
Service,
} from "@elizaos/core";
import { CODING_TOOLS_LOG_PREFIX, SESSION_CWD_SERVICE } from "../types.js";
/**
* Per-conversation working directory. The "session cwd" is the default
* starting point for tools that take an optional `path` (Glob/Grep/LS) or
* `cwd` (Bash) parameter.
*
* - Default for a fresh conversation = `process.cwd()`.
* - EnterWorktree sets it to the new worktree root.
* - ExitWorktree restores it.
* - Bash invocations inherit it but cannot mutate it (changes inside the
* command don't persist, matching Claude's semantics).
*
* Note: tools requiring `file_path` (Read/Write/Edit/NotebookEdit) ignore
* this and demand absolute paths.
*/
export class SessionCwdService extends Service {
static serviceType = SESSION_CWD_SERVICE;
capabilityDescription =
"Per-conversation working directory for coding tools.";
private cwdByConversation = new Map<string, string>();
private frames = new Map<
string,
Array<{ previousCwd: string; entered: string }>
>();
static async start(runtime: IAgentRuntime): Promise<SessionCwdService> {
const svc = new SessionCwdService(runtime);
coreLogger.debug(`${CODING_TOOLS_LOG_PREFIX} SessionCwdService started`);
return svc;
}
async stop(): Promise<void> {
this.cwdByConversation.clear();
this.frames.clear();
}
defaultCwd(): string {
return path.resolve(process.cwd());
}
getCwd(conversationId: string | undefined): string {
if (!conversationId) return this.defaultCwd();
return this.cwdByConversation.get(conversationId) ?? this.defaultCwd();
}
async getExistingCwd(conversationId: string | undefined): Promise<{
cwd: string;
previousCwd?: string;
reset: boolean;
}> {
const cwd = this.getCwd(conversationId);
if (await isDirectory(cwd)) {
return { cwd, reset: false };
}
const fallback = this.defaultCwd();
if (conversationId) {
this.cwdByConversation.set(conversationId, fallback);
}
return { cwd: fallback, previousCwd: cwd, reset: true };
}
setCwd(conversationId: string, absPath: string): void {
this.cwdByConversation.set(conversationId, path.resolve(absPath));
}
pushWorktree(conversationId: string, absPath: string): string {
const resolved = path.resolve(absPath);
const list = this.frames.get(conversationId) ?? [];
list.push({ previousCwd: this.getCwd(conversationId), entered: resolved });
this.frames.set(conversationId, list);
this.cwdByConversation.set(conversationId, resolved);
return resolved;
}
popWorktree(
conversationId: string,
): { previousCwd: string; entered: string } | undefined {
const list = this.frames.get(conversationId);
if (!list || list.length === 0) return undefined;
const frame = list.pop();
if (!frame) return undefined;
if (list.length === 0) this.frames.delete(conversationId);
else this.frames.set(conversationId, list);
this.cwdByConversation.set(conversationId, frame.previousCwd);
return { previousCwd: frame.previousCwd, entered: frame.entered };
}
}
async function isDirectory(absPath: string): Promise<boolean> {
try {
return (await fs.stat(absPath)).isDirectory();
} catch {
// error-policy:J3 existence/type probe; a stat failure (ENOENT) means the
// path is absent — false is the expected-miss signal.
return false;
}
}
@@ -0,0 +1,58 @@
/**
* Test double for the core capability router that reports every plugin capability
* as unavailable, so tests can exercise the coding tools' plugin-absent code paths
* without wiring a real router.
*/
import {
CapabilityError,
type ElizaCapabilityRouter,
type RemotePluginCapability,
} from "@elizaos/core";
type RouterWithoutPlugin = Omit<ElizaCapabilityRouter, "plugin">;
function pluginUnavailable(method: string): never {
throw new CapabilityError({
code: "CAPABILITY_UNAVAILABLE",
message: "plugin capability unavailable",
capability: "plugin",
method,
});
}
export const unavailablePluginCapability: RemotePluginCapability = {
listModules: async () => pluginUnavailable("plugin.modules.list"),
invokeAction: async () => pluginUnavailable("plugin.action.invoke"),
getProvider: async () => pluginUnavailable("plugin.provider.get"),
callRoute: async () => pluginUnavailable("plugin.route.call"),
getAsset: async () => pluginUnavailable("plugin.asset.get"),
shouldRunEvaluator: async () =>
pluginUnavailable("plugin.evaluator.shouldRun"),
prepareEvaluator: async () => pluginUnavailable("plugin.evaluator.prepare"),
promptEvaluator: async () => pluginUnavailable("plugin.evaluator.prompt"),
processEvaluator: async () => pluginUnavailable("plugin.evaluator.process"),
shouldRunResponseHandlerEvaluator: async () =>
pluginUnavailable("plugin.responseHandler.evaluator.shouldRun"),
evaluateResponseHandlerEvaluator: async () =>
pluginUnavailable("plugin.responseHandler.evaluator.evaluate"),
shouldRunResponseHandlerFieldEvaluator: async () =>
pluginUnavailable("plugin.responseHandler.fieldEvaluator.shouldRun"),
parseResponseHandlerFieldEvaluator: async () =>
pluginUnavailable("plugin.responseHandler.fieldEvaluator.parse"),
handleResponseHandlerFieldEvaluator: async () =>
pluginUnavailable("plugin.responseHandler.fieldEvaluator.handle"),
callLifecycle: async () => pluginUnavailable("plugin.lifecycle.call"),
handleEvent: async () => pluginUnavailable("plugin.event.handle"),
invokeModel: async () => pluginUnavailable("plugin.model.invoke"),
callService: async () => pluginUnavailable("plugin.service.call"),
callAppBridge: async () => pluginUnavailable("plugin.appBridge.call"),
};
export function withUnavailablePlugin(
router: RouterWithoutPlugin,
): ElizaCapabilityRouter {
return {
...router,
plugin: unavailablePluginCapability,
};
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Shared constants and result types for the coding-tools plugin: the
* `CODING_TOOLS_*` service-type identifiers, the `CODING_TOOLS_CONTEXTS` list, the
* log prefix, and the `ToolResult`/`ToolFailure` shapes (plus `success`/`failure`
* constructors) that every action handler returns. Imported across actions,
* services, and lib so all layers agree on one set of names and envelopes.
*/
import type { ActionResult, UUID } from "@elizaos/core";
export const CODING_TOOLS_LOG_PREFIX = "[CodingTools]";
export const FILE_STATE_SERVICE = "CODING_TOOLS_FILE_STATE";
export const SANDBOX_SERVICE = "CODING_TOOLS_SANDBOX";
export const SESSION_CWD_SERVICE = "CODING_TOOLS_SESSION_CWD";
export const RIPGREP_SERVICE = "CODING_TOOLS_RIPGREP";
export const CODING_TOOLS_CONTEXTS = [
"code",
"terminal",
"automation",
] as const;
export type CodingToolsContext = (typeof CODING_TOOLS_CONTEXTS)[number];
export interface FileMeta {
path: string;
mtimeMs: number;
size: number;
readAt: number;
}
export type ToolFailureReason =
| "disabled"
| "missing_param"
| "invalid_param"
| "path_blocked"
| "stale_read"
| "no_match"
| "unchanged"
| "command_failed"
| "timeout"
| "io_error"
| "internal";
export interface ToolFailure {
reason: ToolFailureReason;
message: string;
}
export type ToolResult<T> =
| { ok: true; value: T }
| { ok: false; failure: ToolFailure };
export interface SessionKey {
conversationId: string;
agentId: UUID;
}
export type ActionResultData = NonNullable<ActionResult["data"]>;
export const FAILURE_TEXT_PREFIX = "[CodingTools]";
export function failure<T>(
reason: ToolFailureReason,
message: string,
): ToolResult<T> {
return { ok: false, failure: { reason, message } };
}
export function success<T>(value: T): ToolResult<T> {
return { ok: true, value };
}
@@ -0,0 +1,13 @@
{
"extends": "./tsconfig.json",
"exclude": ["node_modules", "dist", "**/*.test.ts", "__tests__"],
"include": ["src/**/*"],
"compilerOptions": {
"rootDir": "./src",
"paths": {
"@elizaos/shared": ["../../packages/shared/dist/index.d.ts"],
"@elizaos/shared/*": ["../../packages/shared/dist/*"]
},
"types": ["node", "bun-types"]
}
}
+43
View File
@@ -0,0 +1,43 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "./dist",
"rootDir": "../..",
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"allowImportingTsExtensions": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"exactOptionalPropertyTypes": false,
"types": ["node", "bun-types"],
"paths": {
"@elizaos/core": ["../../packages/core/src/index.node.ts"],
"@elizaos/core/*": ["../../packages/core/src/*"],
"@elizaos/logger": ["../../packages/logger/src/index.ts"],
"@elizaos/logger/*": ["../../packages/logger/src/*"],
"@elizaos/shared": ["../../packages/shared/src/index.ts"],
"@elizaos/shared/*": ["../../packages/shared/src/*"]
}
},
"include": ["./**/*"],
"exclude": [
"node_modules",
"dist",
"**/__tests__/**",
"**/*.test.ts",
"**/*.test.tsx",
"**/*.e2e.test.ts"
]
}
@@ -0,0 +1,47 @@
/** Vitest config for the coding-tools package: resolves `@elizaos/*` to workspace source. */
import path from "node:path";
import { fileURLToPath } from "node:url";
import { defineConfig } from "vitest/config";
const pluginRoot = path.dirname(fileURLToPath(import.meta.url));
const repoRoot = path.resolve(pluginRoot, "../..");
export default defineConfig({
resolve: {
alias: [
{
find: /^@elizaos\/core$/,
replacement: path.join(repoRoot, "packages/core/src/index.node.ts"),
},
{
find: /^@elizaos\/core\/(.+)$/,
replacement: path.join(repoRoot, "packages/core/src/$1"),
},
{
find: /^@elizaos\/logger$/,
replacement: path.join(repoRoot, "packages/logger/src/index.ts"),
},
{
find: /^@elizaos\/logger\/(.+)$/,
replacement: path.join(repoRoot, "packages/logger/src/$1"),
},
],
conditions: ["node"],
},
ssr: {
resolve: {
conditions: ["node"],
},
},
test: {
environment: "node",
include: ["__tests__/**/*.test.ts", "src/**/*.test.ts"],
testTimeout: 15_000,
pool: "forks",
server: {
deps: {
inline: ["@elizaos/core"],
},
},
},
});