Skip to content
Merged
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: 1 addition & 0 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ COPY . .
RUN useradd --create-home clawith && \
mkdir -p /data/agents && \
chmod +x /app/entrypoint.sh && \
chmod u+s /usr/bin/bwrap && \
chown -R clawith:clawith /app /data

# Note: USER is removed to allow entrypoint.sh to fix permissions of mounted volumes
Expand Down
8 changes: 8 additions & 0 deletions backend/app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ class Settings(BaseSettings):
FEISHU_REDIRECT_URI: str = ""
PUBLIC_BASE_URL: str = ""
HTTP_PROXY: str = ""
HTTPS_PROXY: str = ""
NO_PROXY: str = ""

# CORS
CORS_ORIGINS: list[str] = ["http://localhost:3000", "http://localhost:5173"]
Expand All @@ -192,6 +194,9 @@ class Settings(BaseSettings):
SANDBOX_ALLOW_UNSAFE_FALLBACK_WHEN_BWRAP_MISSING: bool = _default_allow_unsafe_bwrap_fallback()
SANDBOX_DEFAULT_TIMEOUT: int = 30
SANDBOX_MAX_TIMEOUT: int = 60
SANDBOX_HTTP_PROXY: str = ""
SANDBOX_HTTPS_PROXY: str = ""
SANDBOX_NO_PROXY: str = ""

@field_validator(
"LANGGRAPH_CHECKPOINT_DATABASE_URL",
Expand Down Expand Up @@ -256,4 +261,7 @@ def get_sandbox_config() -> SandboxConfig:
allow_unsafe_fallback_when_bwrap_missing=settings.SANDBOX_ALLOW_UNSAFE_FALLBACK_WHEN_BWRAP_MISSING,
default_timeout=settings.SANDBOX_DEFAULT_TIMEOUT,
max_timeout=settings.SANDBOX_MAX_TIMEOUT,
http_proxy=settings.SANDBOX_HTTP_PROXY or settings.HTTP_PROXY or None,
https_proxy=settings.SANDBOX_HTTPS_PROXY or settings.HTTPS_PROXY or None,
no_proxy=settings.SANDBOX_NO_PROXY or settings.NO_PROXY or None,
)
8 changes: 8 additions & 0 deletions backend/app/services/sandbox/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,11 @@ class SandboxConfig(BaseModel):
default_timeout: int = Field(default=30, ge=1, le=3600)
max_timeout: int = Field(default=60, ge=1, le=3600)

# Proxy options
http_proxy: Optional[str] = None
https_proxy: Optional[str] = None
no_proxy: Optional[str] = None

# Language mapping for API sandboxes
# Maps our internal language names to API-specific language IDs
language_mapping: dict[str, str] = Field(default_factory=lambda: {
Expand Down Expand Up @@ -113,5 +118,8 @@ def get_value(key: str, default=None, encrypt: bool = False):
),
default_timeout=get_value("default_timeout", 30),
max_timeout=get_value("max_timeout", 60),
http_proxy=get_value("http_proxy", None),
https_proxy=get_value("https_proxy", None),
no_proxy=get_value("no_proxy", None),
)
return result
16 changes: 14 additions & 2 deletions backend/app/services/sandbox/local/docker_backend.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Local docker-based sandbox backend."""

import os
import time
from pathlib import Path

from app.services.sandbox.base import BaseSandboxBackend, ExecutionResult, SandboxCapabilities
from app.services.sandbox.config import SandboxConfig
Expand Down Expand Up @@ -109,6 +109,18 @@ async def execute(
"HOME": "/root",
"PYTHONDONTWRITEBYTECODE": "1",
}
http_proxy = self.config.http_proxy or os.environ.get("http_proxy") or os.environ.get("HTTP_PROXY")
https_proxy = self.config.https_proxy or os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY")
no_proxy = self.config.no_proxy or os.environ.get("no_proxy") or os.environ.get("NO_PROXY")
if http_proxy:
env["http_proxy"] = http_proxy
env["HTTP_PROXY"] = http_proxy
if https_proxy:
env["https_proxy"] = https_proxy
env["HTTPS_PROXY"] = https_proxy
if no_proxy:
env["no_proxy"] = no_proxy
env["NO_PROXY"] = no_proxy

# Build docker run command
if language == "python":
Expand Down Expand Up @@ -179,7 +191,7 @@ async def execute(
except Exception as e:
duration_ms = int((time.time() - start_time) * 1000)
error_msg = str(e)
logger.exception(f"[Docker] Execution error")
logger.exception("[Docker] Execution error")

# Handle timeout specifically
if "timeout" in error_msg.lower():
Expand Down
28 changes: 25 additions & 3 deletions backend/app/services/sandbox/local/subprocess_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,18 @@ def _build_safe_env(self, work_path: Path) -> dict[str, str]:
"PIP_CACHE_DIR": str(workspace_tmp / "pip-cache"),
"PIP_DISABLE_PIP_VERSION_CHECK": "1",
}
http_proxy = self.config.http_proxy or os.environ.get("http_proxy") or os.environ.get("HTTP_PROXY")
https_proxy = self.config.https_proxy or os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY")
no_proxy = self.config.no_proxy or os.environ.get("no_proxy") or os.environ.get("NO_PROXY")
if http_proxy:
env["http_proxy"] = http_proxy
env["HTTP_PROXY"] = http_proxy
if https_proxy:
env["https_proxy"] = https_proxy
env["HTTPS_PROXY"] = https_proxy
if no_proxy:
env["no_proxy"] = no_proxy
env["NO_PROXY"] = no_proxy
return env

def _bind_if_exists(self, host_path: str, guest_path: str | None = None, *, read_only: bool = True) -> list[str]:
Expand Down Expand Up @@ -288,11 +300,10 @@ def _build_bwrap_command(self, command: list[str], work_path: Path, venv_path: P
bwrap,
"--die-with-parent",
"--new-session",
"--unshare-user",
"--unshare-ipc",
"--unshare-pid",
"--unshare-uts",
"--unshare-cgroup",
"--unshare-cgroup-try",
*base_binds,
"--bind", "/data/agents/.uv-cache", "/uv-cache",
"--bind", str(work_path), "/workspace",
Expand All @@ -312,8 +323,19 @@ def _build_bwrap_command(self, command: list[str], work_path: Path, venv_path: P
"--setenv", "PIP_CACHE_DIR", "/workspace/.tmp/pip-cache",
"--setenv", "PIP_DISABLE_PIP_VERSION_CHECK", "1",
"--setenv", "UV_CACHE_DIR", "/uv-cache",
"--chdir", "/workspace",
]
http_proxy = self.config.http_proxy or os.environ.get("http_proxy") or os.environ.get("HTTP_PROXY")
https_proxy = self.config.https_proxy or os.environ.get("https_proxy") or os.environ.get("HTTPS_PROXY")
no_proxy = self.config.no_proxy or os.environ.get("no_proxy") or os.environ.get("NO_PROXY")
if http_proxy:
cmd.extend(["--setenv", "http_proxy", http_proxy, "--setenv", "HTTP_PROXY", http_proxy])
if https_proxy:
cmd.extend(["--setenv", "https_proxy", https_proxy, "--setenv", "HTTPS_PROXY", https_proxy])
if no_proxy:
cmd.extend(["--setenv", "no_proxy", no_proxy, "--setenv", "NO_PROXY", no_proxy])

cmd.append("--chdir")
cmd.append("/workspace")
if not self.config.allow_network:
cmd.append("--unshare-net")
cmd.extend(command)
Expand Down
50 changes: 50 additions & 0 deletions backend/tests/test_sandbox_subprocess_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,3 +73,53 @@ async def fake_create(*_args, **_kwargs):
await backend._ensure_workspace_venv(tmp_path / ".venv")

assert terminated == [456]


def test_subprocess_backend_proxy_env_propagation(tmp_path: Path) -> None:
config = SandboxConfig(
http_proxy="http://127.0.0.1:8080",
https_proxy="http://127.0.0.1:8081",
no_proxy="localhost,127.0.0.1",
)
backend = SubprocessBackend(config)
env = backend._build_safe_env(tmp_path)

assert env.get("http_proxy") == "http://127.0.0.1:8080"
assert env.get("HTTP_PROXY") == "http://127.0.0.1:8080"
assert env.get("https_proxy") == "http://127.0.0.1:8081"
assert env.get("HTTPS_PROXY") == "http://127.0.0.1:8081"
assert env.get("no_proxy") == "localhost,127.0.0.1"
assert env.get("NO_PROXY") == "localhost,127.0.0.1"


def test_subprocess_backend_proxy_bwrap_command(monkeypatch, tmp_path: Path) -> None:
monkeypatch.setattr("shutil.which", lambda cmd: "/usr/bin/bwrap" if cmd == "bwrap" else None)
config = SandboxConfig(
http_proxy="http://proxy.example.com:8080",
https_proxy="http://proxy.example.com:8443",
)
backend = SubprocessBackend(config)
cmd = backend._build_bwrap_command(["python3", "-c", "print(1)"], tmp_path, tmp_path / ".venv")

assert cmd is not None
assert "--unshare-cgroup-try" in cmd
assert "--unshare-cgroup" not in cmd
assert "--unshare-user" not in cmd
assert "--setenv" in cmd
idx_http = cmd.index("http_proxy")
assert cmd[idx_http + 1] == "http://proxy.example.com:8080"
idx_https = cmd.index("https_proxy")
assert cmd[idx_https + 1] == "http://proxy.example.com:8443"


def test_sandbox_config_proxy_parsing() -> None:
data = {
"http_proxy": "http://10.0.0.1:3128",
"https_proxy": "http://10.0.0.1:3128",
"no_proxy": ".local,10.0.0.0/8",
}
config = SandboxConfig.from_dict(data)
assert config.http_proxy == "http://10.0.0.1:3128"
assert config.https_proxy == "http://10.0.0.1:3128"
assert config.no_proxy == ".local,10.0.0.0/8"