elizaos-plugin-code 2.0.0a4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- elizaos_plugin_code-2.0.0a4/.gitignore +12 -0
- elizaos_plugin_code-2.0.0a4/PKG-INFO +30 -0
- elizaos_plugin_code-2.0.0a4/README.md +4 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/__init__.py +58 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/__init__.py +19 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/change_directory.py +32 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/common.py +25 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/edit_file.py +33 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/execute_shell.py +32 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/git.py +32 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/list_files.py +29 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/read_file.py +37 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/search_files.py +42 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/actions/write_file.py +32 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/config.py +40 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/path_utils.py +54 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/providers/__init__.py +3 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/providers/coder_status.py +64 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/py.typed +1 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/service.py +351 -0
- elizaos_plugin_code-2.0.0a4/elizaos_plugin_eliza_coder/types.py +42 -0
- elizaos_plugin_code-2.0.0a4/pyproject.toml +79 -0
- elizaos_plugin_code-2.0.0a4/tests/conftest.py +27 -0
- elizaos_plugin_code-2.0.0a4/tests/test_actions.py +87 -0
- elizaos_plugin_code-2.0.0a4/tests/test_provider.py +12 -0
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: elizaos-plugin-code
|
|
3
|
+
Version: 2.0.0a4
|
|
4
|
+
Summary: elizaOS Coder Plugin - filesystem + shell + git (restricted)
|
|
5
|
+
Project-URL: Homepage, https://github.com/elizaos/eliza
|
|
6
|
+
Project-URL: Repository, https://github.com/elizaos/eliza
|
|
7
|
+
Author: elizaOS Contributors
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: agents,ai,coding,elizaos,filesystem,git,shell
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Python: >=3.11
|
|
18
|
+
Requires-Dist: pydantic>=2.10.0
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: mypy>=1.14.0; extra == 'dev'
|
|
21
|
+
Requires-Dist: pytest-asyncio>=0.24.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest-cov>=6.0.0; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
24
|
+
Requires-Dist: ruff>=0.9.0; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# elizaos-plugin-code (Python)
|
|
28
|
+
|
|
29
|
+
Python implementation of the Eliza Coder plugin: filesystem + shell + git, restricted to an allowed directory.
|
|
30
|
+
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
from elizaos_plugin_eliza_coder.actions import (
|
|
2
|
+
ChangeDirectoryAction,
|
|
3
|
+
EditFileAction,
|
|
4
|
+
ExecuteShellAction,
|
|
5
|
+
GitAction,
|
|
6
|
+
ListFilesAction,
|
|
7
|
+
ReadFileAction,
|
|
8
|
+
SearchFilesAction,
|
|
9
|
+
WriteFileAction,
|
|
10
|
+
)
|
|
11
|
+
from elizaos_plugin_eliza_coder.config import load_coder_config
|
|
12
|
+
from elizaos_plugin_eliza_coder.path_utils import (
|
|
13
|
+
DEFAULT_FORBIDDEN_COMMANDS,
|
|
14
|
+
extract_base_command,
|
|
15
|
+
is_forbidden_command,
|
|
16
|
+
is_safe_command,
|
|
17
|
+
validate_path,
|
|
18
|
+
)
|
|
19
|
+
from elizaos_plugin_eliza_coder.providers import CoderStatusProvider
|
|
20
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
21
|
+
from elizaos_plugin_eliza_coder.types import (
|
|
22
|
+
CoderConfig,
|
|
23
|
+
CommandHistoryEntry,
|
|
24
|
+
CommandResult,
|
|
25
|
+
FileOperation,
|
|
26
|
+
FileOperationType,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
__version__ = "1.0.0"
|
|
30
|
+
|
|
31
|
+
PLUGIN_NAME = "code"
|
|
32
|
+
PLUGIN_DESCRIPTION = "Filesystem + shell + git tools within a restricted directory"
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"CoderConfig",
|
|
36
|
+
"CommandResult",
|
|
37
|
+
"CommandHistoryEntry",
|
|
38
|
+
"FileOperation",
|
|
39
|
+
"FileOperationType",
|
|
40
|
+
"CoderService",
|
|
41
|
+
"ReadFileAction",
|
|
42
|
+
"WriteFileAction",
|
|
43
|
+
"EditFileAction",
|
|
44
|
+
"ListFilesAction",
|
|
45
|
+
"SearchFilesAction",
|
|
46
|
+
"ChangeDirectoryAction",
|
|
47
|
+
"ExecuteShellAction",
|
|
48
|
+
"GitAction",
|
|
49
|
+
"CoderStatusProvider",
|
|
50
|
+
"load_coder_config",
|
|
51
|
+
"validate_path",
|
|
52
|
+
"is_safe_command",
|
|
53
|
+
"extract_base_command",
|
|
54
|
+
"is_forbidden_command",
|
|
55
|
+
"DEFAULT_FORBIDDEN_COMMANDS",
|
|
56
|
+
"PLUGIN_NAME",
|
|
57
|
+
"PLUGIN_DESCRIPTION",
|
|
58
|
+
]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from elizaos_plugin_eliza_coder.actions.change_directory import ChangeDirectoryAction
|
|
2
|
+
from elizaos_plugin_eliza_coder.actions.edit_file import EditFileAction
|
|
3
|
+
from elizaos_plugin_eliza_coder.actions.execute_shell import ExecuteShellAction
|
|
4
|
+
from elizaos_plugin_eliza_coder.actions.git import GitAction
|
|
5
|
+
from elizaos_plugin_eliza_coder.actions.list_files import ListFilesAction
|
|
6
|
+
from elizaos_plugin_eliza_coder.actions.read_file import ReadFileAction
|
|
7
|
+
from elizaos_plugin_eliza_coder.actions.search_files import SearchFilesAction
|
|
8
|
+
from elizaos_plugin_eliza_coder.actions.write_file import WriteFileAction
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ReadFileAction",
|
|
12
|
+
"WriteFileAction",
|
|
13
|
+
"EditFileAction",
|
|
14
|
+
"ListFilesAction",
|
|
15
|
+
"SearchFilesAction",
|
|
16
|
+
"ChangeDirectoryAction",
|
|
17
|
+
"ExecuteShellAction",
|
|
18
|
+
"GitAction",
|
|
19
|
+
]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
|
|
6
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ChangeDirectoryAction:
|
|
11
|
+
@property
|
|
12
|
+
def name(self) -> str:
|
|
13
|
+
return "CHANGE_DIRECTORY"
|
|
14
|
+
|
|
15
|
+
async def validate(self, _message: Message, _state: dict) -> bool:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
async def handler(
|
|
19
|
+
self,
|
|
20
|
+
message: Message,
|
|
21
|
+
state: dict,
|
|
22
|
+
service: CoderService | None = None,
|
|
23
|
+
) -> ActionResult:
|
|
24
|
+
if service is None:
|
|
25
|
+
return ActionResult(False, "Coder service is not available.", "missing_service")
|
|
26
|
+
|
|
27
|
+
target = str(state.get("path", "")).strip()
|
|
28
|
+
if not target:
|
|
29
|
+
return ActionResult(False, "Missing path.", "missing_path")
|
|
30
|
+
|
|
31
|
+
result = await service.change_directory(conversation_id(message), target)
|
|
32
|
+
return ActionResult(result.success, result.stdout if result.success else result.stderr)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import TypedDict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class MessageContent(TypedDict, total=False):
|
|
8
|
+
text: str
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class Message(TypedDict, total=False):
|
|
12
|
+
content: MessageContent
|
|
13
|
+
room_id: str
|
|
14
|
+
agent_id: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class ActionResult:
|
|
19
|
+
success: bool
|
|
20
|
+
text: str
|
|
21
|
+
error: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def conversation_id(message: Message) -> str:
|
|
25
|
+
return message.get("room_id") or message.get("agent_id") or "default"
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
|
|
6
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class EditFileAction:
|
|
11
|
+
@property
|
|
12
|
+
def name(self) -> str:
|
|
13
|
+
return "EDIT_FILE"
|
|
14
|
+
|
|
15
|
+
async def validate(self, _message: Message, _state: dict) -> bool:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
async def handler(
|
|
19
|
+
self, message: Message, state: dict, service: CoderService | None = None
|
|
20
|
+
) -> ActionResult:
|
|
21
|
+
if service is None:
|
|
22
|
+
return ActionResult(False, "Coder service is not available.", "missing_service")
|
|
23
|
+
|
|
24
|
+
filepath = str(state.get("filepath", "")).strip()
|
|
25
|
+
old_str = str(state.get("old_str", ""))
|
|
26
|
+
new_str = str(state.get("new_str", ""))
|
|
27
|
+
if not filepath or not old_str:
|
|
28
|
+
return ActionResult(False, "Missing filepath or old_str.", "missing_args")
|
|
29
|
+
|
|
30
|
+
ok, err = await service.edit_file(conversation_id(message), filepath, old_str, new_str)
|
|
31
|
+
if not ok:
|
|
32
|
+
return ActionResult(False, err, "edit_failed")
|
|
33
|
+
return ActionResult(True, f"Edited {filepath}")
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
|
|
6
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ExecuteShellAction:
|
|
11
|
+
@property
|
|
12
|
+
def name(self) -> str:
|
|
13
|
+
return "EXECUTE_SHELL"
|
|
14
|
+
|
|
15
|
+
async def validate(self, _message: Message, _state: dict) -> bool:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
async def handler(
|
|
19
|
+
self,
|
|
20
|
+
message: Message,
|
|
21
|
+
state: dict,
|
|
22
|
+
service: CoderService | None = None,
|
|
23
|
+
) -> ActionResult:
|
|
24
|
+
if service is None:
|
|
25
|
+
return ActionResult(False, "Coder service is not available.", "missing_service")
|
|
26
|
+
|
|
27
|
+
cmd = str(state.get("command", "")).strip()
|
|
28
|
+
if not cmd:
|
|
29
|
+
return ActionResult(False, "Missing command.", "missing_command")
|
|
30
|
+
|
|
31
|
+
res = await service.execute_shell(conversation_id(message), cmd)
|
|
32
|
+
return ActionResult(res.success, res.stdout if res.success else res.stderr)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
|
|
6
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class GitAction:
|
|
11
|
+
@property
|
|
12
|
+
def name(self) -> str:
|
|
13
|
+
return "GIT"
|
|
14
|
+
|
|
15
|
+
async def validate(self, _message: Message, _state: dict) -> bool:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
async def handler(
|
|
19
|
+
self,
|
|
20
|
+
message: Message,
|
|
21
|
+
state: dict,
|
|
22
|
+
service: CoderService | None = None,
|
|
23
|
+
) -> ActionResult:
|
|
24
|
+
if service is None:
|
|
25
|
+
return ActionResult(False, "Coder service is not available.", "missing_service")
|
|
26
|
+
|
|
27
|
+
args = str(state.get("args", "")).strip()
|
|
28
|
+
if not args:
|
|
29
|
+
return ActionResult(False, "Missing args.", "missing_args")
|
|
30
|
+
|
|
31
|
+
res = await service.git(conversation_id(message), args)
|
|
32
|
+
return ActionResult(res.success, res.stdout if res.success else res.stderr)
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
|
|
6
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ListFilesAction:
|
|
11
|
+
@property
|
|
12
|
+
def name(self) -> str:
|
|
13
|
+
return "LIST_FILES"
|
|
14
|
+
|
|
15
|
+
async def validate(self, _message: Message, _state: dict) -> bool:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
async def handler(
|
|
19
|
+
self, message: Message, state: dict, service: CoderService | None = None
|
|
20
|
+
) -> ActionResult:
|
|
21
|
+
if service is None:
|
|
22
|
+
return ActionResult(False, "Coder service is not available.", "missing_service")
|
|
23
|
+
|
|
24
|
+
path_arg = str(state.get("path", ".")).strip() or "."
|
|
25
|
+
ok, res = await service.list_files(conversation_id(message), path_arg)
|
|
26
|
+
if not ok:
|
|
27
|
+
return ActionResult(False, str(res), "list_failed")
|
|
28
|
+
items = res if isinstance(res, list) else []
|
|
29
|
+
return ActionResult(True, "\n".join(items) if items else "(empty)")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
|
|
6
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class ReadFileAction:
|
|
11
|
+
@property
|
|
12
|
+
def name(self) -> str:
|
|
13
|
+
return "READ_FILE"
|
|
14
|
+
|
|
15
|
+
async def validate(self, _message: Message, _state: dict) -> bool:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
async def handler(
|
|
19
|
+
self, message: Message, _state: dict, service: CoderService | None = None
|
|
20
|
+
) -> ActionResult:
|
|
21
|
+
if service is None:
|
|
22
|
+
return ActionResult(False, "Coder service is not available.", "missing_service")
|
|
23
|
+
|
|
24
|
+
text = (message.get("content", {}) or {}).get("text", "")
|
|
25
|
+
filepath = ""
|
|
26
|
+
if '"' in text:
|
|
27
|
+
parts = text.split('"')
|
|
28
|
+
if len(parts) >= 2:
|
|
29
|
+
filepath = parts[1]
|
|
30
|
+
|
|
31
|
+
if not filepath:
|
|
32
|
+
return ActionResult(False, "Missing filepath.", "missing_filepath")
|
|
33
|
+
|
|
34
|
+
ok, out = await service.read_file(conversation_id(message), filepath)
|
|
35
|
+
if not ok:
|
|
36
|
+
return ActionResult(False, out, "read_failed")
|
|
37
|
+
return ActionResult(True, out)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
|
|
6
|
+
from elizaos_plugin_eliza_coder.service import CoderService, SearchMatch
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class SearchFilesAction:
|
|
11
|
+
@property
|
|
12
|
+
def name(self) -> str:
|
|
13
|
+
return "SEARCH_FILES"
|
|
14
|
+
|
|
15
|
+
async def validate(self, _message: Message, _state: dict) -> bool:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
async def handler(
|
|
19
|
+
self, message: Message, state: dict, service: CoderService | None = None
|
|
20
|
+
) -> ActionResult:
|
|
21
|
+
if service is None:
|
|
22
|
+
return ActionResult(False, "Coder service is not available.", "missing_service")
|
|
23
|
+
|
|
24
|
+
pattern = str(state.get("pattern", "")).strip()
|
|
25
|
+
dirpath = str(state.get("path", ".")).strip() or "."
|
|
26
|
+
max_matches = (
|
|
27
|
+
int(state.get("max_matches", 50)) if str(state.get("max_matches", "")).strip() else 50
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
if not pattern:
|
|
31
|
+
return ActionResult(False, "Missing pattern.", "missing_pattern")
|
|
32
|
+
|
|
33
|
+
ok, res = await service.search_files(
|
|
34
|
+
conversation_id(message), pattern, dirpath, max_matches
|
|
35
|
+
)
|
|
36
|
+
if not ok:
|
|
37
|
+
return ActionResult(False, str(res), "search_failed")
|
|
38
|
+
matches = res if isinstance(res, list) else []
|
|
39
|
+
if not matches:
|
|
40
|
+
return ActionResult(True, f'No matches for "{pattern}".')
|
|
41
|
+
lines = [f"{m.file}:L{m.line}: {m.content}" for m in matches if isinstance(m, SearchMatch)]
|
|
42
|
+
return ActionResult(True, "\n".join(lines))
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from elizaos_plugin_eliza_coder.actions.common import ActionResult, Message, conversation_id
|
|
6
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass
|
|
10
|
+
class WriteFileAction:
|
|
11
|
+
@property
|
|
12
|
+
def name(self) -> str:
|
|
13
|
+
return "WRITE_FILE"
|
|
14
|
+
|
|
15
|
+
async def validate(self, _message: Message, _state: dict) -> bool:
|
|
16
|
+
return True
|
|
17
|
+
|
|
18
|
+
async def handler(
|
|
19
|
+
self, message: Message, state: dict, service: CoderService | None = None
|
|
20
|
+
) -> ActionResult:
|
|
21
|
+
if service is None:
|
|
22
|
+
return ActionResult(False, "Coder service is not available.", "missing_service")
|
|
23
|
+
|
|
24
|
+
filepath = str(state.get("filepath", "")).strip()
|
|
25
|
+
content = str(state.get("content", ""))
|
|
26
|
+
if not filepath:
|
|
27
|
+
return ActionResult(False, "Missing filepath.", "missing_filepath")
|
|
28
|
+
|
|
29
|
+
ok, err = await service.write_file(conversation_id(message), filepath, content)
|
|
30
|
+
if not ok:
|
|
31
|
+
return ActionResult(False, err, "write_failed")
|
|
32
|
+
return ActionResult(True, f"Wrote {filepath} ({len(content)} chars)")
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from elizaos_plugin_eliza_coder.types import CoderConfig
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _parse_bool(value: str | None) -> bool:
|
|
10
|
+
v = (value or "").strip().lower()
|
|
11
|
+
return v in {"1", "true", "yes", "on"}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _parse_int(value: str | None, fallback: int) -> int:
|
|
15
|
+
raw = (value or "").strip()
|
|
16
|
+
try:
|
|
17
|
+
parsed = int(raw)
|
|
18
|
+
except ValueError:
|
|
19
|
+
return fallback
|
|
20
|
+
if parsed <= 0:
|
|
21
|
+
return fallback
|
|
22
|
+
return parsed
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def load_coder_config() -> CoderConfig:
|
|
26
|
+
enabled = _parse_bool(os.environ.get("CODER_ENABLED"))
|
|
27
|
+
allowed_raw = (os.environ.get("CODER_ALLOWED_DIRECTORY") or "").strip()
|
|
28
|
+
allowed = Path(allowed_raw).resolve() if allowed_raw else Path.cwd().resolve()
|
|
29
|
+
timeout_ms = _parse_int(os.environ.get("CODER_TIMEOUT"), 30_000)
|
|
30
|
+
forbidden = [
|
|
31
|
+
s.strip()
|
|
32
|
+
for s in (os.environ.get("CODER_FORBIDDEN_COMMANDS") or "").split(",")
|
|
33
|
+
if s.strip()
|
|
34
|
+
]
|
|
35
|
+
return CoderConfig(
|
|
36
|
+
enabled=enabled,
|
|
37
|
+
allowed_directory=os.fspath(allowed),
|
|
38
|
+
timeout_ms=timeout_ms,
|
|
39
|
+
forbidden_commands=forbidden,
|
|
40
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
DEFAULT_FORBIDDEN_COMMANDS: list[str] = [
|
|
7
|
+
"rm -rf /",
|
|
8
|
+
"rm -rf ~",
|
|
9
|
+
"sudo rm",
|
|
10
|
+
"mkfs",
|
|
11
|
+
"dd if=/dev",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def extract_base_command(command: str) -> str:
|
|
16
|
+
trimmed = command.strip()
|
|
17
|
+
if not trimmed:
|
|
18
|
+
return ""
|
|
19
|
+
return trimmed.split()[0]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def is_safe_command(command: str) -> bool:
|
|
23
|
+
c = command.strip()
|
|
24
|
+
if not c:
|
|
25
|
+
return False
|
|
26
|
+
if "&&" in c or "||" in c or ";" in c:
|
|
27
|
+
return False
|
|
28
|
+
if "$(" in c or "`" in c:
|
|
29
|
+
return False
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_forbidden_command(command: str, additional_forbidden: list[str]) -> bool:
|
|
34
|
+
lower = command.lower()
|
|
35
|
+
for f in DEFAULT_FORBIDDEN_COMMANDS:
|
|
36
|
+
if f.lower() in lower:
|
|
37
|
+
return True
|
|
38
|
+
for f in additional_forbidden:
|
|
39
|
+
if f.strip() and f.lower() in lower:
|
|
40
|
+
return True
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def validate_path(target_path: str, allowed_directory: str, current_directory: str) -> str | None:
|
|
45
|
+
allowed = Path(allowed_directory).resolve()
|
|
46
|
+
base = Path(current_directory).resolve() if current_directory else allowed
|
|
47
|
+
resolved = (base / target_path).resolve()
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
resolved.relative_to(allowed)
|
|
51
|
+
except ValueError:
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
return os.fspath(resolved)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from elizaos_plugin_eliza_coder.actions.common import Message, conversation_id
|
|
6
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class ProviderResult:
|
|
11
|
+
values: dict[str, str]
|
|
12
|
+
text: str
|
|
13
|
+
data: dict[str, str | int]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CoderStatusProvider:
|
|
17
|
+
@property
|
|
18
|
+
def name(self) -> str:
|
|
19
|
+
return "CODER_STATUS"
|
|
20
|
+
|
|
21
|
+
@property
|
|
22
|
+
def description(self) -> str:
|
|
23
|
+
return "Provides current working directory, allowed directory, and recent command history"
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def position(self) -> int:
|
|
27
|
+
return 99
|
|
28
|
+
|
|
29
|
+
async def get(
|
|
30
|
+
self,
|
|
31
|
+
message: Message,
|
|
32
|
+
_state: dict,
|
|
33
|
+
service: CoderService | None = None,
|
|
34
|
+
) -> ProviderResult:
|
|
35
|
+
if service is None:
|
|
36
|
+
return ProviderResult(
|
|
37
|
+
values={
|
|
38
|
+
"coderStatus": "Coder service is not available",
|
|
39
|
+
"currentWorkingDirectory": "N/A",
|
|
40
|
+
"allowedDirectory": "N/A",
|
|
41
|
+
},
|
|
42
|
+
text="# Coder Status\n\nCoder service is not available",
|
|
43
|
+
data={"historyCount": 0, "cwd": "N/A", "allowedDir": "N/A"},
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
conv = conversation_id(message)
|
|
47
|
+
history = service.get_command_history(conv, limit=10)
|
|
48
|
+
cwd = service.get_current_directory(conv)
|
|
49
|
+
allowed = service.allowed_directory
|
|
50
|
+
|
|
51
|
+
history_text = "No commands in history."
|
|
52
|
+
if history:
|
|
53
|
+
history_text = "\n".join([f"{h.working_directory}> {h.command}" for h in history])
|
|
54
|
+
|
|
55
|
+
text = f"Current Directory: {cwd}\nAllowed Directory: {allowed}\n\n{history_text}"
|
|
56
|
+
return ProviderResult(
|
|
57
|
+
values={
|
|
58
|
+
"coderStatus": history_text,
|
|
59
|
+
"currentWorkingDirectory": cwd,
|
|
60
|
+
"allowedDirectory": allowed,
|
|
61
|
+
},
|
|
62
|
+
text=text,
|
|
63
|
+
data={"historyCount": len(history), "cwd": cwd, "allowedDir": allowed},
|
|
64
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
import shlex
|
|
6
|
+
import subprocess
|
|
7
|
+
import time
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from elizaos_plugin_eliza_coder.config import load_coder_config
|
|
12
|
+
from elizaos_plugin_eliza_coder.path_utils import (
|
|
13
|
+
is_forbidden_command,
|
|
14
|
+
is_safe_command,
|
|
15
|
+
validate_path,
|
|
16
|
+
)
|
|
17
|
+
from elizaos_plugin_eliza_coder.types import (
|
|
18
|
+
CommandHistoryEntry,
|
|
19
|
+
CommandResult,
|
|
20
|
+
FileOperation,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class SearchMatch:
|
|
28
|
+
file: str
|
|
29
|
+
line: int
|
|
30
|
+
content: str
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class CoderService:
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._config = load_coder_config()
|
|
36
|
+
self._cwd_by_conversation: dict[str, str] = {}
|
|
37
|
+
self._history_by_conversation: dict[str, list[CommandHistoryEntry]] = {}
|
|
38
|
+
self._max_history = 100
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def allowed_directory(self) -> str:
|
|
42
|
+
return self._config.allowed_directory
|
|
43
|
+
|
|
44
|
+
def get_current_directory(self, conversation_id: str) -> str:
|
|
45
|
+
return self._cwd_by_conversation.get(conversation_id, self._config.allowed_directory)
|
|
46
|
+
|
|
47
|
+
def _set_current_directory(self, conversation_id: str, directory: str) -> None:
|
|
48
|
+
self._cwd_by_conversation[conversation_id] = directory
|
|
49
|
+
|
|
50
|
+
def get_command_history(self, conversation_id: str, limit: int) -> list[CommandHistoryEntry]:
|
|
51
|
+
if limit <= 0:
|
|
52
|
+
return []
|
|
53
|
+
return self._history_by_conversation.get(conversation_id, [])[-limit:]
|
|
54
|
+
|
|
55
|
+
def _add_history(
|
|
56
|
+
self,
|
|
57
|
+
conversation_id: str,
|
|
58
|
+
command: str,
|
|
59
|
+
result: CommandResult,
|
|
60
|
+
file_ops: list[FileOperation] | None = None,
|
|
61
|
+
) -> None:
|
|
62
|
+
items = self._history_by_conversation.get(conversation_id, [])
|
|
63
|
+
items.append(
|
|
64
|
+
CommandHistoryEntry(
|
|
65
|
+
timestamp=time.time(),
|
|
66
|
+
working_directory=result.executed_in,
|
|
67
|
+
command=command,
|
|
68
|
+
stdout=result.stdout,
|
|
69
|
+
stderr=result.stderr,
|
|
70
|
+
exit_code=result.exit_code,
|
|
71
|
+
file_operations=file_ops,
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
if len(items) > self._max_history:
|
|
75
|
+
items = items[-self._max_history :]
|
|
76
|
+
self._history_by_conversation[conversation_id] = items
|
|
77
|
+
|
|
78
|
+
def _ensure_enabled(self) -> str | None:
|
|
79
|
+
if self._config.enabled:
|
|
80
|
+
return None
|
|
81
|
+
return "Coder plugin is disabled. Set CODER_ENABLED=true to enable."
|
|
82
|
+
|
|
83
|
+
def _resolve_within(self, conversation_id: str, target: str) -> str | None:
|
|
84
|
+
cwd = self.get_current_directory(conversation_id)
|
|
85
|
+
return validate_path(target, self._config.allowed_directory, cwd)
|
|
86
|
+
|
|
87
|
+
async def change_directory(self, conversation_id: str, target: str) -> CommandResult:
|
|
88
|
+
disabled = self._ensure_enabled()
|
|
89
|
+
if disabled:
|
|
90
|
+
return CommandResult(
|
|
91
|
+
success=False,
|
|
92
|
+
stdout="",
|
|
93
|
+
stderr=disabled,
|
|
94
|
+
exit_code=1,
|
|
95
|
+
error="Coder disabled",
|
|
96
|
+
executed_in=self.get_current_directory(conversation_id),
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
resolved = self._resolve_within(conversation_id, target)
|
|
100
|
+
if not resolved:
|
|
101
|
+
return CommandResult(
|
|
102
|
+
success=False,
|
|
103
|
+
stdout="",
|
|
104
|
+
stderr="Cannot navigate outside allowed directory",
|
|
105
|
+
exit_code=1,
|
|
106
|
+
error="Permission denied",
|
|
107
|
+
executed_in=self.get_current_directory(conversation_id),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
p = Path(resolved)
|
|
111
|
+
if not p.is_dir():
|
|
112
|
+
return CommandResult(
|
|
113
|
+
success=False,
|
|
114
|
+
stdout="",
|
|
115
|
+
stderr="Not a directory",
|
|
116
|
+
exit_code=1,
|
|
117
|
+
error="Not a directory",
|
|
118
|
+
executed_in=self.get_current_directory(conversation_id),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
self._set_current_directory(conversation_id, resolved)
|
|
122
|
+
return CommandResult(
|
|
123
|
+
success=True,
|
|
124
|
+
stdout=f"Changed directory to: {resolved}",
|
|
125
|
+
stderr="",
|
|
126
|
+
exit_code=0,
|
|
127
|
+
executed_in=resolved,
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
async def read_file(self, conversation_id: str, filepath: str) -> tuple[bool, str]:
|
|
131
|
+
disabled = self._ensure_enabled()
|
|
132
|
+
if disabled:
|
|
133
|
+
return False, disabled
|
|
134
|
+
|
|
135
|
+
resolved = self._resolve_within(conversation_id, filepath)
|
|
136
|
+
if not resolved:
|
|
137
|
+
return False, "Cannot access path outside allowed directory"
|
|
138
|
+
|
|
139
|
+
p = Path(resolved)
|
|
140
|
+
if p.is_dir():
|
|
141
|
+
return False, "Path is a directory"
|
|
142
|
+
if not p.exists():
|
|
143
|
+
return False, "File not found"
|
|
144
|
+
return True, p.read_text(encoding="utf-8")
|
|
145
|
+
|
|
146
|
+
async def write_file(
|
|
147
|
+
self, conversation_id: str, filepath: str, content: str
|
|
148
|
+
) -> tuple[bool, str]:
|
|
149
|
+
"""Write content to a file, creating parent directories as needed."""
|
|
150
|
+
disabled = self._ensure_enabled()
|
|
151
|
+
if disabled:
|
|
152
|
+
return False, disabled
|
|
153
|
+
|
|
154
|
+
if not filepath or not filepath.strip():
|
|
155
|
+
return False, "File path cannot be empty"
|
|
156
|
+
|
|
157
|
+
resolved = self._resolve_within(conversation_id, filepath)
|
|
158
|
+
if not resolved:
|
|
159
|
+
return False, "Cannot access path outside allowed directory"
|
|
160
|
+
|
|
161
|
+
p = Path(resolved)
|
|
162
|
+
if p.parent != Path(resolved).parent or str(p.parent):
|
|
163
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
164
|
+
p.write_text(content, encoding="utf-8")
|
|
165
|
+
return True, ""
|
|
166
|
+
|
|
167
|
+
async def edit_file(
|
|
168
|
+
self, conversation_id: str, filepath: str, old_str: str, new_str: str
|
|
169
|
+
) -> tuple[bool, str]:
|
|
170
|
+
disabled = self._ensure_enabled()
|
|
171
|
+
if disabled:
|
|
172
|
+
return False, disabled
|
|
173
|
+
|
|
174
|
+
resolved = self._resolve_within(conversation_id, filepath)
|
|
175
|
+
if not resolved:
|
|
176
|
+
return False, "Cannot access path outside allowed directory"
|
|
177
|
+
|
|
178
|
+
p = Path(resolved)
|
|
179
|
+
if not p.exists():
|
|
180
|
+
return False, "File not found"
|
|
181
|
+
content = p.read_text(encoding="utf-8")
|
|
182
|
+
if old_str not in content:
|
|
183
|
+
return False, "Could not find old_str in file"
|
|
184
|
+
p.write_text(content.replace(old_str, new_str, 1), encoding="utf-8")
|
|
185
|
+
return True, ""
|
|
186
|
+
|
|
187
|
+
async def list_files(self, conversation_id: str, dirpath: str) -> tuple[bool, list[str] | str]:
|
|
188
|
+
disabled = self._ensure_enabled()
|
|
189
|
+
if disabled:
|
|
190
|
+
return False, disabled
|
|
191
|
+
|
|
192
|
+
resolved = self._resolve_within(conversation_id, dirpath)
|
|
193
|
+
if not resolved:
|
|
194
|
+
return False, "Cannot access path outside allowed directory"
|
|
195
|
+
|
|
196
|
+
p = Path(resolved)
|
|
197
|
+
if not p.exists():
|
|
198
|
+
return False, "Directory not found"
|
|
199
|
+
if not p.is_dir():
|
|
200
|
+
return False, "Not a directory"
|
|
201
|
+
|
|
202
|
+
items: list[str] = []
|
|
203
|
+
for child in sorted(p.iterdir(), key=lambda x: x.name):
|
|
204
|
+
if child.name.startswith("."):
|
|
205
|
+
continue
|
|
206
|
+
items.append(f"{child.name}/" if child.is_dir() else child.name)
|
|
207
|
+
return True, items
|
|
208
|
+
|
|
209
|
+
async def search_files(
|
|
210
|
+
self, conversation_id: str, pattern: str, dirpath: str, max_matches: int
|
|
211
|
+
) -> tuple[bool, list[SearchMatch] | str]:
|
|
212
|
+
disabled = self._ensure_enabled()
|
|
213
|
+
if disabled:
|
|
214
|
+
return False, disabled
|
|
215
|
+
|
|
216
|
+
needle = pattern.strip()
|
|
217
|
+
if not needle:
|
|
218
|
+
return False, "Missing pattern"
|
|
219
|
+
|
|
220
|
+
resolved = self._resolve_within(conversation_id, dirpath)
|
|
221
|
+
if not resolved:
|
|
222
|
+
return False, "Cannot access path outside allowed directory"
|
|
223
|
+
|
|
224
|
+
limit = max(1, min(500, int(max_matches))) if max_matches > 0 else 50
|
|
225
|
+
matches: list[SearchMatch] = []
|
|
226
|
+
await self._search_dir(Path(resolved), needle.lower(), matches, limit)
|
|
227
|
+
return True, matches
|
|
228
|
+
|
|
229
|
+
async def _search_dir(
|
|
230
|
+
self, dirpath: Path, needle_lower: str, matches: list[SearchMatch], limit: int
|
|
231
|
+
) -> None:
|
|
232
|
+
if len(matches) >= limit:
|
|
233
|
+
return
|
|
234
|
+
for entry in sorted(dirpath.iterdir(), key=lambda x: x.name):
|
|
235
|
+
if len(matches) >= limit:
|
|
236
|
+
break
|
|
237
|
+
if entry.name.startswith("."):
|
|
238
|
+
continue
|
|
239
|
+
if entry.is_dir():
|
|
240
|
+
if entry.name in {"node_modules", "dist", "build", "coverage", ".git"}:
|
|
241
|
+
continue
|
|
242
|
+
await self._search_dir(entry, needle_lower, matches, limit)
|
|
243
|
+
continue
|
|
244
|
+
if not entry.is_file():
|
|
245
|
+
continue
|
|
246
|
+
try:
|
|
247
|
+
text = entry.read_text(encoding="utf-8")
|
|
248
|
+
except UnicodeDecodeError:
|
|
249
|
+
continue
|
|
250
|
+
for idx, line in enumerate(text.splitlines(), start=1):
|
|
251
|
+
if len(matches) >= limit:
|
|
252
|
+
break
|
|
253
|
+
if needle_lower in line.lower():
|
|
254
|
+
matches.append(
|
|
255
|
+
SearchMatch(
|
|
256
|
+
file=str(entry.relative_to(Path(self.allowed_directory))),
|
|
257
|
+
line=idx,
|
|
258
|
+
content=line.strip()[:240],
|
|
259
|
+
)
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
async def execute_shell(self, conversation_id: str, command: str) -> CommandResult:
|
|
263
|
+
disabled = self._ensure_enabled()
|
|
264
|
+
if disabled:
|
|
265
|
+
return CommandResult(
|
|
266
|
+
success=False,
|
|
267
|
+
stdout="",
|
|
268
|
+
stderr=disabled,
|
|
269
|
+
exit_code=1,
|
|
270
|
+
error="Coder disabled",
|
|
271
|
+
executed_in=self.get_current_directory(conversation_id),
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
trimmed = command.strip()
|
|
275
|
+
if not trimmed:
|
|
276
|
+
return CommandResult(
|
|
277
|
+
success=False,
|
|
278
|
+
stdout="",
|
|
279
|
+
stderr="Invalid command",
|
|
280
|
+
exit_code=1,
|
|
281
|
+
error="Empty command",
|
|
282
|
+
executed_in=self.get_current_directory(conversation_id),
|
|
283
|
+
)
|
|
284
|
+
|
|
285
|
+
if not is_safe_command(trimmed):
|
|
286
|
+
return CommandResult(
|
|
287
|
+
success=False,
|
|
288
|
+
stdout="",
|
|
289
|
+
stderr="Command contains forbidden patterns",
|
|
290
|
+
exit_code=1,
|
|
291
|
+
error="Security policy violation",
|
|
292
|
+
executed_in=self.get_current_directory(conversation_id),
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
if is_forbidden_command(trimmed, self._config.forbidden_commands):
|
|
296
|
+
return CommandResult(
|
|
297
|
+
success=False,
|
|
298
|
+
stdout="",
|
|
299
|
+
stderr="Command is forbidden by security policy",
|
|
300
|
+
exit_code=1,
|
|
301
|
+
error="Forbidden command",
|
|
302
|
+
executed_in=self.get_current_directory(conversation_id),
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
cwd = self.get_current_directory(conversation_id)
|
|
306
|
+
use_shell = any(ch in trimmed for ch in [">", "<", "|"])
|
|
307
|
+
try:
|
|
308
|
+
if use_shell:
|
|
309
|
+
cmd_args = ["sh", "-c", trimmed]
|
|
310
|
+
else:
|
|
311
|
+
cmd_args = shlex.split(trimmed)
|
|
312
|
+
|
|
313
|
+
timeout_seconds = self._config.timeout_ms / 1000.0
|
|
314
|
+
proc = await asyncio.wait_for(
|
|
315
|
+
asyncio.create_subprocess_exec(
|
|
316
|
+
*cmd_args,
|
|
317
|
+
stdout=subprocess.PIPE,
|
|
318
|
+
stderr=subprocess.PIPE,
|
|
319
|
+
cwd=cwd,
|
|
320
|
+
),
|
|
321
|
+
timeout=timeout_seconds,
|
|
322
|
+
)
|
|
323
|
+
stdout_b, stderr_b = await asyncio.wait_for(
|
|
324
|
+
proc.communicate(),
|
|
325
|
+
timeout=timeout_seconds,
|
|
326
|
+
)
|
|
327
|
+
stdout = stdout_b.decode("utf-8", errors="replace")
|
|
328
|
+
stderr = stderr_b.decode("utf-8", errors="replace")
|
|
329
|
+
result = CommandResult(
|
|
330
|
+
success=proc.returncode == 0,
|
|
331
|
+
stdout=stdout,
|
|
332
|
+
stderr=stderr,
|
|
333
|
+
exit_code=proc.returncode,
|
|
334
|
+
executed_in=cwd,
|
|
335
|
+
)
|
|
336
|
+
self._add_history(conversation_id, trimmed, result)
|
|
337
|
+
return result
|
|
338
|
+
except TimeoutError:
|
|
339
|
+
result = CommandResult(
|
|
340
|
+
success=False,
|
|
341
|
+
stdout="",
|
|
342
|
+
stderr="Command timed out",
|
|
343
|
+
exit_code=None,
|
|
344
|
+
error="Command execution timeout",
|
|
345
|
+
executed_in=cwd,
|
|
346
|
+
)
|
|
347
|
+
self._add_history(conversation_id, trimmed, result)
|
|
348
|
+
return result
|
|
349
|
+
|
|
350
|
+
async def git(self, conversation_id: str, args: str) -> CommandResult:
|
|
351
|
+
return await self.execute_shell(conversation_id, f"git {args}")
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from typing import Literal
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class CoderConfig:
|
|
9
|
+
enabled: bool
|
|
10
|
+
allowed_directory: str
|
|
11
|
+
timeout_ms: int
|
|
12
|
+
forbidden_commands: list[str]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
FileOperationType = Literal["read", "write", "edit", "list", "search"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class FileOperation:
|
|
20
|
+
type: FileOperationType
|
|
21
|
+
target: str
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class CommandResult:
|
|
26
|
+
success: bool
|
|
27
|
+
stdout: str
|
|
28
|
+
stderr: str
|
|
29
|
+
exit_code: int | None
|
|
30
|
+
executed_in: str
|
|
31
|
+
error: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class CommandHistoryEntry:
|
|
36
|
+
timestamp: float
|
|
37
|
+
working_directory: str
|
|
38
|
+
command: str
|
|
39
|
+
stdout: str
|
|
40
|
+
stderr: str
|
|
41
|
+
exit_code: int | None
|
|
42
|
+
file_operations: list[FileOperation] | None = None
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "elizaos-plugin-code"
|
|
7
|
+
version = "2.0.0a4"
|
|
8
|
+
description = "elizaOS Coder Plugin - filesystem + shell + git (restricted)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
authors = [{ name = "elizaOS Contributors" }]
|
|
13
|
+
keywords = ["ai", "agents", "coding", "filesystem", "shell", "git", "elizaos"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Typing :: Typed",
|
|
22
|
+
]
|
|
23
|
+
dependencies = ["pydantic>=2.10.0"]
|
|
24
|
+
|
|
25
|
+
[project.optional-dependencies]
|
|
26
|
+
dev = [
|
|
27
|
+
"pytest>=8.0.0",
|
|
28
|
+
"pytest-asyncio>=0.24.0",
|
|
29
|
+
"pytest-cov>=6.0.0",
|
|
30
|
+
"mypy>=1.14.0",
|
|
31
|
+
"ruff>=0.9.0",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.urls]
|
|
35
|
+
Homepage = "https://github.com/elizaos/eliza"
|
|
36
|
+
Repository = "https://github.com/elizaos/eliza"
|
|
37
|
+
|
|
38
|
+
[tool.hatch.build.targets.wheel]
|
|
39
|
+
packages = ["elizaos_plugin_eliza_coder"]
|
|
40
|
+
|
|
41
|
+
[tool.hatch.build.targets.sdist]
|
|
42
|
+
include = ["/elizaos_plugin_eliza_coder", "/tests", "/README.md"]
|
|
43
|
+
|
|
44
|
+
[tool.pytest.ini_options]
|
|
45
|
+
testpaths = ["tests"]
|
|
46
|
+
python_files = "test_*.py"
|
|
47
|
+
python_functions = "test_*"
|
|
48
|
+
addopts = "-v -p no:anchorpy"
|
|
49
|
+
asyncio_mode = "auto"
|
|
50
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
51
|
+
|
|
52
|
+
[tool.mypy]
|
|
53
|
+
python_version = "3.11"
|
|
54
|
+
strict = true
|
|
55
|
+
warn_return_any = true
|
|
56
|
+
warn_unused_ignores = true
|
|
57
|
+
disallow_untyped_defs = true
|
|
58
|
+
disallow_incomplete_defs = true
|
|
59
|
+
check_untyped_defs = true
|
|
60
|
+
no_implicit_optional = true
|
|
61
|
+
|
|
62
|
+
[tool.ruff]
|
|
63
|
+
target-version = "py311"
|
|
64
|
+
line-length = 100
|
|
65
|
+
|
|
66
|
+
[tool.ruff.lint]
|
|
67
|
+
select = ["E", "W", "F", "I", "B", "C4", "UP", "ANN", "S"]
|
|
68
|
+
ignore = [
|
|
69
|
+
"E501",
|
|
70
|
+
"ANN101",
|
|
71
|
+
"ANN102",
|
|
72
|
+
"S101",
|
|
73
|
+
"S603",
|
|
74
|
+
"S607",
|
|
75
|
+
]
|
|
76
|
+
|
|
77
|
+
[tool.ruff.lint.isort]
|
|
78
|
+
known-first-party = ["elizaos_plugin_eliza_coder"]
|
|
79
|
+
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import pytest
|
|
7
|
+
|
|
8
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@pytest.fixture
|
|
12
|
+
def tmp_allowed_dir(tmp_path: Path) -> Path:
|
|
13
|
+
return tmp_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture
|
|
17
|
+
def coder_env(tmp_allowed_dir: Path) -> None:
|
|
18
|
+
os.environ["CODER_ENABLED"] = "true"
|
|
19
|
+
os.environ["CODER_ALLOWED_DIRECTORY"] = str(tmp_allowed_dir)
|
|
20
|
+
os.environ["CODER_TIMEOUT"] = "30000"
|
|
21
|
+
os.environ.pop("CODER_FORBIDDEN_COMMANDS", None)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def service(coder_env: None) -> CoderService:
|
|
26
|
+
# Reads config from env during init.
|
|
27
|
+
return CoderService()
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from elizaos_plugin_eliza_coder.actions import (
|
|
4
|
+
ChangeDirectoryAction,
|
|
5
|
+
EditFileAction,
|
|
6
|
+
ExecuteShellAction,
|
|
7
|
+
GitAction,
|
|
8
|
+
ListFilesAction,
|
|
9
|
+
ReadFileAction,
|
|
10
|
+
SearchFilesAction,
|
|
11
|
+
WriteFileAction,
|
|
12
|
+
)
|
|
13
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _message(text: str) -> dict:
|
|
17
|
+
return {"content": {"text": text}, "room_id": "room-1", "agent_id": "agent-1"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
async def test_write_then_read(service: CoderService) -> None:
|
|
21
|
+
write = WriteFileAction()
|
|
22
|
+
res = await write.handler(_message("write"), {"filepath": "a.txt", "content": "hello"}, service)
|
|
23
|
+
assert res.success
|
|
24
|
+
|
|
25
|
+
read = ReadFileAction()
|
|
26
|
+
res2 = await read.handler(_message('"a.txt"'), {}, service)
|
|
27
|
+
assert res2.success
|
|
28
|
+
assert "hello" in res2.text
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
async def test_edit_file(service: CoderService) -> None:
|
|
32
|
+
write = WriteFileAction()
|
|
33
|
+
await write.handler(_message("write"), {"filepath": "b.txt", "content": "abc"}, service)
|
|
34
|
+
|
|
35
|
+
edit = EditFileAction()
|
|
36
|
+
res = await edit.handler(
|
|
37
|
+
_message("edit"),
|
|
38
|
+
{"filepath": "b.txt", "old_str": "b", "new_str": "B"},
|
|
39
|
+
service,
|
|
40
|
+
)
|
|
41
|
+
assert res.success
|
|
42
|
+
|
|
43
|
+
read = ReadFileAction()
|
|
44
|
+
res2 = await read.handler(_message('"b.txt"'), {}, service)
|
|
45
|
+
assert "aBc" in res2.text
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def test_list_files(service: CoderService) -> None:
|
|
49
|
+
write = WriteFileAction()
|
|
50
|
+
await write.handler(_message("write"), {"filepath": "c.txt", "content": "x"}, service)
|
|
51
|
+
|
|
52
|
+
ls = ListFilesAction()
|
|
53
|
+
res = await ls.handler(_message("list"), {"path": "."}, service)
|
|
54
|
+
assert res.success
|
|
55
|
+
assert "c.txt" in res.text
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
async def test_search_files(service: CoderService) -> None:
|
|
59
|
+
write = WriteFileAction()
|
|
60
|
+
await write.handler(_message("write"), {"filepath": "d.txt", "content": "needle here"}, service)
|
|
61
|
+
|
|
62
|
+
search = SearchFilesAction()
|
|
63
|
+
res = await search.handler(
|
|
64
|
+
_message("search"),
|
|
65
|
+
{"pattern": "needle", "path": ".", "max_matches": 50},
|
|
66
|
+
service,
|
|
67
|
+
)
|
|
68
|
+
assert res.success
|
|
69
|
+
assert "d.txt" in res.text
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def test_change_directory_rejects_escape(service: CoderService) -> None:
|
|
73
|
+
cd = ChangeDirectoryAction()
|
|
74
|
+
res = await cd.handler(_message("cd"), {"path": ".."}, service)
|
|
75
|
+
assert not res.success
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def test_execute_shell_pwd(service: CoderService) -> None:
|
|
79
|
+
sh = ExecuteShellAction()
|
|
80
|
+
res = await sh.handler(_message("shell"), {"command": "pwd"}, service)
|
|
81
|
+
assert res.success
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
async def test_git_requires_repo(service: CoderService) -> None:
|
|
85
|
+
g = GitAction()
|
|
86
|
+
res = await g.handler(_message("git"), {"args": "status"}, service)
|
|
87
|
+
assert not res.success
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from elizaos_plugin_eliza_coder.providers import CoderStatusProvider
|
|
4
|
+
from elizaos_plugin_eliza_coder.service import CoderService
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def test_coder_status_provider(service: CoderService) -> None:
|
|
8
|
+
provider = CoderStatusProvider()
|
|
9
|
+
result = await provider.get(
|
|
10
|
+
{"room_id": "room-1", "agent_id": "agent-1", "content": {"text": ""}}, {}, service
|
|
11
|
+
)
|
|
12
|
+
assert result.values["allowedDirectory"] == service.allowed_directory
|