Merge pull request '#5 Работа с FS и shell' (#8) from #5-filesystem-and-shell into main

Reviewed-on: #8
This commit is contained in:
Егор Кандрушин 2026-04-14 23:39:02 +00:00
commit 0709f5433b
7 changed files with 139 additions and 10 deletions

View file

@ -7,3 +7,4 @@
*.pyc
__pycache__/
.env
workspace/

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
workspace/
.idea/
workspace/

View file

@ -1,4 +1,4 @@
FROM python:3.14-slim as base
FROM python:3.14-slim AS base
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
@ -6,7 +6,13 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
WORKDIR /app
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

View file

@ -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

View file

@ -0,0 +1,3 @@
from src.agent.backends.isolated_shell import IsolatedShellBackend
__all__ = ["IsolatedShellBackend"]

View file

@ -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 "<no output>"
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)

View file

@ -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,
)