Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion backend/app/agent/factory/browser.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ def browser_agent(options: Chat):
options.project_id,
Agents.browser_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
terminal_toolkit = message_integration.register_functions(
Expand Down
1 change: 0 additions & 1 deletion backend/app/agent/factory/developer.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,6 @@ async def developer_agent(options: Chat):
options.project_id,
Agents.developer_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
terminal_toolkit = message_integration.register_toolkits(terminal_toolkit)
Expand Down
1 change: 0 additions & 1 deletion backend/app/agent/factory/document.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ async def document_agent(options: Chat):
options.project_id,
Agents.document_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
terminal_toolkit = message_integration.register_toolkits(terminal_toolkit)
Expand Down
1 change: 0 additions & 1 deletion backend/app/agent/factory/multi_modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,6 @@ def multi_modal_agent(options: Chat):
options.project_id,
agent_name=Agents.multi_modal_agent,
working_directory=working_directory,
safe_mode=True,
clone_current_env=True,
)
terminal_toolkit = message_integration.register_toolkits(terminal_toolkit)
Expand Down
59 changes: 59 additions & 0 deletions backend/app/agent/toolkit/abstract_toolkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,16 @@
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import logging

from camel.toolkits.function_tool import FunctionTool
from inflection import titleize

from app.model.enums import ApprovalAction
from app.service.task import get_task_lock

logger = logging.getLogger("abstract_toolkit")


class AbstractToolkit:
api_task_id: str
Expand All @@ -28,3 +35,55 @@ def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
@classmethod
def toolkit_name(cls) -> str:
return titleize(cls.__name__)

async def _request_user_approval(self, action_data) -> str | None:
"""Request user approval for a dangerous operation.

Follows the same pattern as HumanToolkit.ask_human_via_gui:
push an SSE event via ``put_queue``, then ``await`` the
response on an agent-specific asyncio.Queue (keyed by
``self.agent_name``).

Args:
action_data: A Pydantic model (e.g. ActionCommandApprovalData)
to send to the frontend via SSE.

Returns:
None if the operation is approved (caller should proceed),
or an error message string if rejected.
"""
task_lock = get_task_lock(self.api_task_id)
if task_lock.auto_approve.get(self.agent_name, False):
return None

logger.info(
"[APPROVAL] Pushing approval event to SSE queue, "
"api_task_id=%s, agent=%s, action=%s",
self.api_task_id,
self.agent_name,
action_data.action,
)

# Push the approval prompt to the SSE stream
# (same as ask_human_via_gui's put_queue call)
await task_lock.put_queue(action_data)

logger.info("[APPROVAL] Event pushed, waiting for user response")

# Wait for the user's response via agent-specific asyncio.Queue
# (same as ask_human_via_gui's get_human_input call)
approval = await task_lock.get_approval_input(self.agent_name)

logger.info("[APPROVAL] Received response: %s", approval)

# Fail-closed: only explicitly approved values proceed;
# unrecognised responses default to rejection.
if approval == ApprovalAction.approve_once:
return None
if approval == ApprovalAction.auto_approve:
task_lock.auto_approve[self.agent_name] = True
return None
# ApprovalAction.reject or any unexpected value —
# The frontend will also send a skip-task request to stop the
# task entirely, so this return value is mainly a safety net.
return "Operation rejected by user. The task is being stopped."
113 changes: 101 additions & 12 deletions backend/app/agent/toolkit/terminal_toolkit.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
import platform
import shutil
import subprocess
import threading
from concurrent.futures import ThreadPoolExecutor

from camel.toolkits.terminal_toolkit import (
Expand All @@ -28,17 +27,24 @@

from app.agent.toolkit.abstract_toolkit import AbstractToolkit
from app.component.environment import env
from app.hitl.terminal_command import (
is_dangerous_command,
validate_cd_within_working_dir,
)
from app.service.task import (
Action,
ActionCommandApprovalData,
ActionTerminalData,
Agents,
get_task_lock,
get_task_lock_if_exists,
process_task,
)
from app.utils.listen.toolkit_listen import auto_listen_toolkit

logger = logging.getLogger("terminal_toolkit")


# App version - should match electron app version
# TODO: Consider getting this from a shared config
APP_VERSION = "0.0.84"
Expand All @@ -58,7 +64,6 @@ def get_terminal_base_venv_path() -> str:
class TerminalToolkit(BaseTerminalToolkit, AbstractToolkit):
agent_name: str = Agents.developer_agent
_thread_pool: ThreadPoolExecutor | None = None
_thread_local = threading.local()

def __init__(
self,
Expand All @@ -69,7 +74,6 @@ def __init__(
use_docker_backend: bool = False,
docker_container_name: str | None = None,
session_logs_dir: str | None = None,
safe_mode: bool = True,
allowed_commands: list[str] | None = None,
clone_current_env: bool = True,
):
Expand Down Expand Up @@ -100,24 +104,36 @@ def __init__(
max_workers=1, thread_name_prefix="terminal_toolkit"
)

self._use_docker_backend = use_docker_backend
self._working_directory = working_directory

# Read terminal_approval from the project-level TaskLock so every
# agent factory does not need to thread the setting through.
task_lock = get_task_lock_if_exists(api_task_id)
self._terminal_approval = (
task_lock.hitl_options.terminal_approval if task_lock else False
)

# When terminal_approval is ON we handle dangerous commands via
# user approval prompts. Camel's safe_mode would hard-block them
# instead, so we invert it.
camel_safe_mode = not self._terminal_approval
super().__init__(
timeout=timeout,
working_directory=working_directory,
use_docker_backend=use_docker_backend,
docker_container_name=docker_container_name,
session_logs_dir=session_logs_dir,
safe_mode=safe_mode,
safe_mode=camel_safe_mode,
allowed_commands=allowed_commands,
clone_current_env=True,
install_dependencies=[],
)

# Auto-register with TaskLock for cleanup when task ends
from app.service.task import get_task_lock_if_exists

task_lock = get_task_lock_if_exists(api_task_id)
if task_lock:
task_lock.register_toolkit(self)
task_lock.add_approval_input_listen(self.agent_name)
logger.info(
"TerminalToolkit registered for cleanup",
extra={
Expand Down Expand Up @@ -326,17 +342,17 @@ def _run_coro_in_thread(coro, task_lock):
"""
Execute coro in the thread pool, with each thread bound to a long-term event loop
"""
if not hasattr(TerminalToolkit._thread_local, "loop"):
if not hasattr(AbstractToolkit._thread_local, "loop"):
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
TerminalToolkit._thread_local.loop = loop
AbstractToolkit._thread_local.loop = loop
else:
loop = TerminalToolkit._thread_local.loop
loop = AbstractToolkit._thread_local.loop

if loop.is_closed():
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
TerminalToolkit._thread_local.loop = loop
AbstractToolkit._thread_local.loop = loop

try:
task = loop.create_task(coro)
Expand All @@ -349,7 +365,7 @@ def _run_coro_in_thread(coro, task_lock):
exc_info=True,
)

def shell_exec(
async def shell_exec(
self,
command: str,
id: str | None = None,
Expand All @@ -358,6 +374,25 @@ def shell_exec(
) -> str:
r"""Executes a shell command in blocking or non-blocking mode.

When Safe Mode is on, dangerous commands (e.g. rm) trigger user
approval before execution.

.. note:: Async override of a sync base method

Camel's ``BaseTerminalToolkit.shell_exec`` is **sync-only** (no
async variant exists in the upstream library). We override it
as ``async def`` so the HITL approval flow can ``await`` the
SSE queue and the approval-response queue — following the same
asyncio.Queue pattern used by ``HumanToolkit.ask_human_via_gui``.

Because the base method is sync and this override is async, the
``listen_toolkit`` decorator applies a ``__wrapped__`` fix (see
``toolkit_listen.py``) to ensure Camel's ``FunctionTool``
dispatches this method via ``async_call`` on the **main** event
loop, rather than via the sync ``__call__`` path which would run
it on a background loop where cross-loop ``asyncio.Queue`` awaits
silently fail.

Args:
command (str): The shell command to execute.
id (str, optional): A unique identifier for the command's session.
Expand All @@ -368,12 +403,66 @@ def shell_exec(
Returns:
str: The output of the command execution.
"""
import sys

print(
f"[APPROVAL-PRINT] shell_exec ENTERED, id={id}, terminal_approval={self._terminal_approval}",
flush=True,
)
print(
f"[APPROVAL-PRINT] command={command[:120]!r}",
flush=True,
file=sys.stderr,
)
logger.info(
"[APPROVAL] async shell_exec called, id=%s, terminal_approval=%s",
id,
self._terminal_approval,
)
# Auto-generate ID if not provided
if id is None:
import time

id = f"auto_{int(time.time() * 1000)}"

# Non-Docker: validate cd does not escape working_directory (issue #1306)
if not self._use_docker_backend:
ok, err = validate_cd_within_working_dir(
command, self._working_directory
)
if not ok:
return err or "cd not allowed."

is_dangerous = (
is_dangerous_command(command) if self._terminal_approval else False
)
print(
f"[APPROVAL-PRINT] terminal_approval={self._terminal_approval}, is_dangerous={is_dangerous}",
flush=True,
)
logger.info(
"[APPROVAL] terminal_approval=%s, is_dangerous=%s, command=%r",
self._terminal_approval,
is_dangerous,
command[:120],
)
if self._terminal_approval and is_dangerous:
print("[APPROVAL-PRINT] ENTERING approval flow!", flush=True)
logger.info(
"[APPROVAL] Entering approval flow for dangerous command"
)
approval_data = ActionCommandApprovalData(
data={"command": command, "agent": self.agent_name}
)
rejection = await self._request_user_approval(approval_data)
print(
f"[APPROVAL-PRINT] Approval result: rejection={rejection}",
flush=True,
)
logger.info("[APPROVAL] Approval result: rejection=%s", rejection)
if rejection is not None:
return rejection

result = super().shell_exec(
id=id, command=command, block=block, timeout=timeout
)
Expand Down
15 changes: 15 additions & 0 deletions backend/app/controller/chat_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
from app.exception.exception import UserException
from app.model.chat import (
AddTaskRequest,
ApprovalRequest,
Chat,
HumanReply,
McpServers,
Expand Down Expand Up @@ -176,6 +177,7 @@ async def post(data: Chat, request: Request):
)

task_lock = get_or_create_task_lock(data.project_id)
task_lock.hitl_options = data.hitl_options

# Set user-specific environment path for this thread
set_user_env_path(data.env_path)
Expand Down Expand Up @@ -416,6 +418,19 @@ def human_reply(id: str, data: HumanReply):
return Response(status_code=201)


@router.post("/chat/{id}/approval")
def approval(id: str, data: ApprovalRequest):
"""Accept user approval for a dangerous command."""
chat_logger.info(
"Approval received",
extra={"task_id": id, "agent": data.agent, "approval": data.approval},
)
task_lock = get_task_lock(id)
asyncio.run(task_lock.put_approval_input(data.agent, data.approval))
chat_logger.debug("Approval processed", extra={"task_id": id})
return Response(status_code=201)


@router.post("/chat/{id}/install-mcp")
def install_mcp(id: str, data: McpServers):
chat_logger.info(
Expand Down
13 changes: 13 additions & 0 deletions backend/app/hitl/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
Loading