diff --git a/.dockerignore b/.dockerignore index e557a44..2197ab1 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,3 +7,4 @@ *.pyc __pycache__/ .env +workspace/ diff --git a/.gitignore b/.gitignore index 974ad1b..7e2ecb5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +workspace/ + .idea/ workspace/ diff --git a/Dockerfile b/Dockerfile index e090658..b9bc44e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,12 +1,18 @@ -FROM python:3.14-slim as base +FROM python:3.14-slim AS base ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 WORKDIR /app -RUN apt update && apt install make -y +RUN apt update && apt install make -y -FROM base as builder +ENV AGENT_USER="agent" +ENV WORKSPACE_DIR="/workspace/" +RUN useradd --shell /bin/bash $AGENT_USER \ + && mkdir -p $WORKSPACE_DIR /home/$AGENT_USER \ + && chown -R agent:agent $WORKSPACE_DIR /home/$AGENT_USER + +FROM base AS builder RUN apt install git -y RUN pip install uv @@ -15,9 +21,7 @@ COPY pyproject.toml uv.lock ./ RUN uv sync --frozen --no-install-project --no-dev RUN uv pip install git+https://git.lambda.coredump.ru/platform/agent_api.git -FROM base as production - -RUN useradd --create-home --shell /bin/bash appuser +FROM base AS production COPY --from=builder /app/.venv /app/.venv ENV PATH="/app/.venv/bin:$PATH" @@ -25,14 +29,15 @@ ENV PATH="/app/.venv/bin:$PATH" COPY src/ /app/src/ COPY Makefile ./ COPY .mk/ ./.mk/ - -USER appuser +RUN chown root:root /app && chmod 700 /app +RUN apt install sudo -y && \ + echo "agent ALL=(ALL) NOPASSWD: /usr/bin/apt*" >> /etc/sudoers EXPOSE 8000 CMD ["make", "uvicorn-prod"] -FROM base as development +FROM base AS development RUN pip install uv @@ -46,6 +51,9 @@ ENV PATH="/app/.venv/bin:$PATH" COPY Makefile ./ COPY .mk/ ./.mk/ +RUN chown root:root /app && chmod 700 /app +RUN apt install sudo -y && \ + echo "agent ALL=(ALL) NOPASSWD: /usr/bin/apt*" >> /etc/sudoers EXPOSE 8000 diff --git a/docker-compose.yml b/docker-compose.yml index 95960f4..27ba539 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,9 +20,14 @@ services: volumes: - ./src:/app/src - ${AGENT_API_PATH}:/agent-api/ + - ./workspace:/workspace/ ports: - "8000:8000" env_file: - .env + cap_add: # для работы bwrap + - SYS_ADMIN + security_opt: # для работы bwrap + - seccomp:unconfined profiles: - dev diff --git a/src/agent/backends/__init__.py b/src/agent/backends/__init__.py new file mode 100644 index 0000000..3bfb87d --- /dev/null +++ b/src/agent/backends/__init__.py @@ -0,0 +1,3 @@ +from src.agent.backends.isolated_shell import IsolatedShellBackend + +__all__ = ["IsolatedShellBackend"] diff --git a/src/agent/backends/isolated_shell.py b/src/agent/backends/isolated_shell.py new file mode 100644 index 0000000..468fb1b --- /dev/null +++ b/src/agent/backends/isolated_shell.py @@ -0,0 +1,98 @@ +import os +import pwd +import subprocess +from typing import Any + +from deepagents.backends.local_shell import LocalShellBackend + + +class IsolatedShellBackend(LocalShellBackend): + """LocalShellBackend с изоляцией shell-команд через отдельного пользователя.""" + + def __init__( + self, + user: str, + **kwargs: Any, + ): + super().__init__(**kwargs) + self._user = user + self._uid = pwd.getpwnam(user).pw_uid # type: ignore[attr-defined] + self._gid = pwd.getpwnam(user).pw_gid # type: ignore[attr-defined] + + def execute( + self, + command: str, + *, + timeout: int | None = None, + ) -> Any: + if not command or not isinstance(command, str): + return type(self)._error_response("Command must be a non-empty string.") + + effective_timeout = timeout if timeout is not None else self._default_timeout + if effective_timeout <= 0: + return type(self)._error_response( + f"timeout must be positive, got {effective_timeout}" + ) + + proc: subprocess.Popen | None = None + try: + print(f"Running shell: {command}") + proc = subprocess.Popen( + command, + shell=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + cwd=str(self.cwd), + env=self._env, + preexec_fn=lambda: ( + os.setgid(self._gid) or os.setuid(self._uid) # type: ignore[attr-defined] + ), + ) + + stdout, stderr = proc.communicate(timeout=effective_timeout) + + output_parts = [] + if stdout: + output_parts.append(stdout) + if stderr: + stderr_lines = stderr.strip().split("\n") + output_parts.extend(f"[stderr] {line}" for line in stderr_lines) + + output = "\n".join(output_parts) if output_parts else "" + + truncated = False + if len(output) > self._max_output_bytes: + output = output[: self._max_output_bytes] + output += f"\n\n... Output truncated at {self._max_output_bytes} bytes." + truncated = True + + if proc.returncode != 0: + output = f"{output.rstrip()}\n\nExit code: {proc.returncode}" + + result = self._make_response(output, proc.returncode, truncated) + print(result) + return result + + except subprocess.TimeoutExpired: + proc.kill() + proc.communicate() + msg = f"Error: Command timed out after {effective_timeout} seconds." + return self._make_response(msg, 124, False) + + except Exception as e: + return self._make_response( + f"Error executing command ({type(e).__name__}): {e}", 1, False + ) + + @staticmethod + def _error_response(message: str): + from deepagents.backends.protocol import ExecuteResponse + + return ExecuteResponse(output=message, exit_code=1, truncated=False) + + @staticmethod + def _make_response(output: str, exit_code: int, truncated: bool): + from deepagents.backends.protocol import ExecuteResponse + + return ExecuteResponse(output=output, exit_code=exit_code, truncated=truncated) diff --git a/src/agent/base.py b/src/agent/base.py index f635ba8..fe08072 100644 --- a/src/agent/base.py +++ b/src/agent/base.py @@ -1,8 +1,11 @@ import os + from deepagents import create_deep_agent from langchain_openai import ChatOpenAI from langgraph.checkpoint.memory import MemorySaver +from src.agent.backends import IsolatedShellBackend + def create_agent(): model = ChatOpenAI( @@ -11,9 +14,18 @@ def create_agent(): api_key=os.environ["PROVIDER_API_KEY"], ) + workspace_dir = os.environ["WORKSPACE_DIR"] + agent_user = os.environ.get("AGENT_USER", "agent") + + backend = IsolatedShellBackend( + user=agent_user, + root_dir=workspace_dir, + virtual_mode=True, + ) + return create_deep_agent( model=model, system_prompt="You are a helpful assistant.", checkpointer=MemorySaver(), + backend=backend, ) -