Skip to content
Closed
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
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,15 @@ WORKSPACE_HOST_DIR=./workspace
# Libs meta cache TTL in seconds. 0 = no cache (hot-reload). Default: 60
# LIBS_CACHE_TTL_SECONDS=60

# Prefer Anthropic sandbox-runtime when available.
# true: use srt (if installed); false: fallback to legacy sandbox backend.
SANDBOX_USE_SRT=true

# Semantic search default switch.
ENABLE_SEMANTIC_SEARCH=true

# -----------------------------------------------------------------------------
# Web Search (BoCha API)
# Used by libs/assignable_skills/default/tools/web_search.md
# -----------------------------------------------------------------------------
BOCHA_API_KEY=
16 changes: 16 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ RUN useradd -m -u 1000 appuser

WORKDIR /app

# 安装 sandbox-runtime (srt) 运行时依赖和 Node.js
# srt 在 Linux 上需要: bubblewrap, socat, ripgrep
# Node.js 用于运行 @anthropic-ai/sandbox-runtime CLI
RUN apt-get update && apt-get install -y --no-install-recommends \
bubblewrap socat ripgrep curl ca-certificates gnupg \
&& mkdir -p /etc/apt/keyrings \
&& curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg \
&& echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_20.x nodistro main" \
> /etc/apt/sources.list.d/nodesource.list \
&& apt-get update && apt-get install -y --no-install-recommends nodejs \
&& npm install -g @anthropic-ai/sandbox-runtime \
&& apt-get purge -y gnupg \
&& apt-get autoremove -y \
&& apt-get clean && rm -rf /var/lib/apt/lists/*

# 配置 pip 使用阿里云镜像源,加速下载
RUN mkdir -p /home/appuser/.pip && \
echo "[global]" > /home/appuser/.pip/pip.conf && \
Expand Down
4 changes: 3 additions & 1 deletion README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,12 @@ curl http://localhost:8000/health
| `ANTHROPIC_MODEL` | | Claude model name |
| `WORKSPACE_BASE` | | Workspace directory, default `./workspace` |
| `LIBS_CACHE_TTL_SECONDS` | | Libs meta cache TTL (seconds); 0=disable cache (hot-reload); default 60 |
| `SANDBOX_USE_SRT` | | Whether to prefer srt sandbox backend, default `true` |
| `ENABLE_SEMANTIC_SEARCH` | | Semantic search default switch, default `true` |

In TopicLab integrated mode, topic/domain business storage belongs to `topiclab-backend`; Resonnet only handles Agent SDK execution, workspace artifacts, and runtime orchestration, so no topic business database is required here.

See [docs/config.md](docs/config.md) for details. All libraries (experts, moderator_modes, mcps, assignable_skills, prompts) load from `libs/`; no scenario config needed.
See [docs/config.md](docs/config.md) for details. srt install/verification/fallback is maintained there as the single source of truth. All libraries (experts, moderator_modes, mcps, assignable_skills, prompts) load from `libs/`; no scenario config needed.

## Testing

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,10 +155,12 @@ curl http://localhost:8000/health
| `ANTHROPIC_MODEL` | | Claude 模型名 |
| `WORKSPACE_BASE` | | 工作区目录,默认 `./workspace` |
| `LIBS_CACHE_TTL_SECONDS` | | 库 meta 缓存 TTL(秒);0=禁用缓存即热更新;默认 60 |
| `SANDBOX_USE_SRT` | | 是否优先使用 srt 沙箱,默认 `true` |
| `ENABLE_SEMANTIC_SEARCH` | | 语义搜索默认开关,默认 `true` |

在 TopicLab 集成模式下,topic 主业务数据库归 `topiclab-backend` 持有;Resonnet 只负责 Agent SDK 执行、workspace 产物和运行时编排,因此这里不再要求配置 topic 业务数据库。

详见 [docs/config.md](docs/config.md)。所有库(experts、moderator_modes、mcps、assignable_skills、prompts)从 `libs/` 加载,无需配置 scenarios。
详见 [docs/config.md](docs/config.md)。`srt` 的安装、验证、回退策略统一在该文档维护。所有库(experts、moderator_modes、mcps、assignable_skills、prompts)从 `libs/` 加载,无需配置 scenarios。

## 测试

Expand Down
41 changes: 35 additions & 6 deletions app/agent/discussion.py
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,10 @@ def _expert_tools_from_moderator_tools(moderator_tools: list[str]) -> list[str]:
def _load_mcp_servers_for_sdk(workspace_dir: Path) -> dict[str, dict]:
"""Load MCP config from workspace config/mcp.json and convert to SDK format.

When sandbox-runtime (srt) is available and enabled, each MCP server
command is wrapped with ``srt --settings <config>`` so the server runs
inside its own filesystem sandbox.

Returns dict suitable for ClaudeAgentOptions(mcp_servers=...).
Supports both:
- stdio: {"command": str, "args": list, "env": dict | None}
Expand All @@ -63,10 +67,30 @@ def _load_mcp_servers_for_sdk(workspace_dir: Path) -> dict[str, dict]:
cfg = load_mcp_config_from_path(path)
if not cfg.mcpServers:
return {}

from app.agent.sandbox_exec import SRT_AVAILABLE
from app.core.config import get_sandbox_use_srt

use_srt = SRT_AVAILABLE and get_sandbox_use_srt()
mcp_srt_settings_path: str | None = None

if use_srt:
from app.agent.srt_config import build_mcp_srt_settings, write_srt_settings_file

ws_abs = str(workspace_dir.resolve())
mcp_settings = build_mcp_srt_settings(topic_workspace=ws_abs)
# Write to workspace config/ dir — cleaned up with the workspace.
config_dir = str(workspace_dir / "config")
os.makedirs(config_dir, exist_ok=True)
mcp_srt_settings_path = write_srt_settings_file(
mcp_settings, target_dir=config_dir,
)
Comment thread
Oops-maker marked this conversation as resolved.
logger.info("[Discussion] MCP servers will be sandboxed with srt")

result: dict[str, dict] = {}
for sid, srv in cfg.mcpServers.items():
# HTTP type MCP server (Claude CLI style)
if srv.is_http():
# Emit Claude Code style HTTP MCP config
url = srv.url
if not url:
logger.warning("MCP %s is marked as HTTP type but has no url; skipping", sid)
Expand All @@ -80,8 +104,16 @@ def _load_mcp_servers_for_sdk(workspace_dir: Path) -> dict[str, dict]:
result[sid] = entry
continue

# Stdio type with optional SRT sandbox
if srv.command:
entry = {"command": srv.command, "args": srv.args or []}
if use_srt and mcp_srt_settings_path:
entry = {
"command": "srt",
"args": ["--settings", mcp_srt_settings_path, srv.command]
+ (srv.args or []),
}
else:
entry = {"command": srv.command, "args": srv.args or []}
if srv.env:
entry["env"] = srv.env
result[sid] = entry
Expand Down Expand Up @@ -175,7 +207,7 @@ async def run_discussion(
if isinstance(message, ResultMessage):
logger.info(f"Finished: turns={message.num_turns}, cost={message.total_cost_usd}, usage={message.usage}")
result_info["num_turns"] = message.num_turns
# Use custom per-model pricing if configured, otherwise fall back to SDK value
# Use custom per-model pricing if configured
custom_cost = calculate_cost_from_usage(model or "", message.usage) if model else None
result_info["total_cost_usd"] = custom_cost if custom_cost is not None else message.total_cost_usd
except Exception as e:
Expand Down Expand Up @@ -227,8 +259,6 @@ async def run_discussion_for_topic(
topic_text = f"{topic_title}\n\n{topic_body}"

# Acquire exclusive topic sandbox lock for the duration of the discussion.
# This prevents concurrent discussion runs and blocks new expert @mentions
# from starting while the topic workspace is being written to.
with exclusive_topic_sandbox(topic_id, ws_path, "discussion"):
result_info = await run_discussion(
workspace_dir=ws_path,
Expand All @@ -238,7 +268,6 @@ async def run_discussion_for_topic(
expert_names=expert_names,
max_turns=max_turns,
max_budget_usd=max_budget_usd,
allowed_tools=allowed_tools,
)
filtered_sources = sanitize_discussion_turn_sources(ws_path)
if filtered_sources:
Expand Down
65 changes: 53 additions & 12 deletions app/agent/sandbox_exec.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
"""OS-level sandbox execution for agent operations.

Wraps agent subprocess execution under macOS sandbox-exec (Apple Seatbelt) or
Linux bubblewrap (bwrap), physically restricting filesystem access to the
topic workspace directory.
Wraps agent subprocess execution under Anthropic's sandbox-runtime (srt),
or falls back to macOS sandbox-exec (Apple Seatbelt) / Linux bubblewrap
(bwrap), physically restricting filesystem access to the topic workspace
directory.

Design doc: docs/sandbox-isolation-design.md
Backend selection priority (highest first):
1. **srt** (sandbox-runtime) — preferred; Anthropic-maintained, supports
filesystem *and* network isolation via JSON config. Requires Node.js
and ``npm install -g @anthropic-ai/sandbox-runtime``.
2. **sandbox-exec** — macOS legacy; custom Seatbelt profile.
3. **bwrap** — Linux legacy; custom bubblewrap command.

Set ``SANDBOX_USE_SRT=false`` to skip srt and use the legacy backend.

Design doc: docs/sandbox-isolation.md

## Why subprocess?

Expand All @@ -13,7 +23,7 @@
its parent (sandbox_runner.py) inside the OS sandbox — all child processes
inherit the sandbox restrictions automatically.

## macOS Seatbelt profile
## macOS Seatbelt profile (legacy fallback)

The Seatbelt profile uses `(deny default)` then allowlists:
- macOS system paths (/usr, /System, /Library) — read-only
Expand Down Expand Up @@ -177,17 +187,26 @@ def is_linux_bwrap_available() -> bool:
return False


def is_srt_available() -> bool:
"""Check if Anthropic sandbox-runtime (srt) CLI is installed."""
return shutil.which("srt") is not None


# Check at import time (cached for performance)
SRT_AVAILABLE: bool = is_srt_available()
MACOS_SANDBOX: bool = is_macos_sandbox_available()
LINUX_BWRAP: bool = is_linux_bwrap_available()
SANDBOX_AVAILABLE: bool = MACOS_SANDBOX or LINUX_BWRAP

if SANDBOX_AVAILABLE:
sandbox_type = "macos-sandbox-exec" if MACOS_SANDBOX else "linux-bwrap"
logger.info("[SandboxExec] OS sandbox available: %s", sandbox_type)
SANDBOX_AVAILABLE: bool = SRT_AVAILABLE or MACOS_SANDBOX or LINUX_BWRAP
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Honor SANDBOX_USE_SRT when computing sandbox availability

SANDBOX_AVAILABLE is now true whenever srt exists, even if SANDBOX_USE_SRT=false. In that configuration, callers such as run_expert_reply_sandboxed() still choose the sandbox path, but run_in_os_sandbox() skips srt and falls through to the legacy backend; on hosts without sandbox-exec/bwrap, this leads to launch-time failures instead of the intended graceful fallback. This makes the new config toggle brittle and can break expert-reply execution on machines that only have srt installed.

Useful? React with 👍 / 👎.


if SRT_AVAILABLE:
logger.info("[SandboxExec] sandbox-runtime (srt) available — preferred sandbox backend")
elif MACOS_SANDBOX:
logger.info("[SandboxExec] OS sandbox available: macos-sandbox-exec (legacy)")
elif LINUX_BWRAP:
logger.info("[SandboxExec] OS sandbox available: linux-bwrap (legacy)")
else:
logger.warning(
"[SandboxExec] No OS sandbox available (sandbox-exec/bwrap not found). "
"[SandboxExec] No OS sandbox available (srt/sandbox-exec/bwrap not found). "
"Agent isolation will use soft prompt constraints only."
)

Expand Down Expand Up @@ -334,16 +353,38 @@ def run_in_os_sandbox(task_config: dict[str, Any]) -> dict[str, Any]:
)

# Build the sandboxed command
if MACOS_SANDBOX:
from app.core.config import get_sandbox_use_srt

use_srt = SRT_AVAILABLE and get_sandbox_use_srt()
srt_settings_path: str | None = None

if use_srt:
from app.agent.srt_config import build_srt_settings, write_srt_settings_file

srt_settings = build_srt_settings(
topic_workspace=ws_abs,
ipc_dir=ipc_dir,
)
srt_settings_path = write_srt_settings_file(
srt_settings, target_dir=ipc_dir,
)
cmd = [
"srt", "--settings", srt_settings_path,
python_exec, runner_path, input_path, output_path,
]
logger.info("[SandboxExec] Using sandbox-runtime (srt) backend")
elif MACOS_SANDBOX:
profile = build_macos_profile(ws_abs, ipc_dir)
cmd = [
"sandbox-exec", "-p", profile,
python_exec, runner_path, input_path, output_path,
]
logger.info("[SandboxExec] Using legacy macOS sandbox-exec backend")
else:
cmd = _build_linux_bwrap_cmd(
ws_abs, ipc_dir, python_exec, runner_path, input_path, output_path
)
logger.info("[SandboxExec] Using legacy Linux bwrap backend")

logger.info(
"[SandboxExec] Launching sandboxed subprocess: task_type=%s ws=%s",
Expand Down
Loading
Loading