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
14 changes: 11 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ Pion is a lightweight, extensible Python coding agent inspired by the open-sourc
- **Small, readable core** — a streamed agent loop with parallel tool execution and hooks.
- **Terminal-native workflow** — an inline TUI that keeps the conversation in your scrollback.
- **Open extension surface** — add Python tools, lifecycle hooks, slash commands, or stdio MCP servers.
- **Optional isolation** — run file and shell tools through the standalone [sandbox-docker-mcp](https://github.com/Ariasu123/Agent-Toolkit/tree/main/Personal/MCP-Hub/sandbox-docker-mcp) package.
- **Optional isolation** — run file and shell tools inside Docker via the standalone [sandbox-docker-mcp](https://github.com/Ariasu123/Agent-Toolkit/tree/main/Personal/MCP-Hub/sandbox-docker-mcp) MCP server, installed on demand through the `sandbox` extra (not part of the default install).

## Quick start

The one-line installer supports macOS and Linux. It installs [uv](https://docs.astral.sh/uv/) when needed; uv then provides a compatible Python runtime. Docker is optional and only required for sandboxed execution.
The one-line installer supports macOS and Linux. It installs [uv](https://docs.astral.sh/uv/) when needed; uv then provides a compatible Python runtime. The default install runs tools on the host; both Docker and the optional `sandbox` extra are needed only for sandboxed execution (`--sandbox mcp`, see below).

```bash
curl -LsSf https://raw.githubusercontent.com/Ariasu123/Pion/main/install.sh | sh
Expand Down Expand Up @@ -117,7 +117,15 @@ Pion currently supports MCP tools over stdio, not resources, prompts, or Streama
<details>
<summary><strong>Docker sandbox</strong></summary>

`uv run pion --sandbox mcp` starts the independently maintained [sandbox-docker-mcp](https://github.com/Ariasu123/Agent-Toolkit/tree/main/Personal/MCP-Hub/sandbox-docker-mcp) server through Pion's compatibility entry point. It runs in a disposable, non-root container. Only the current project is bind-mounted; Git metadata is read-only by default, protected files such as `.env` are masked, and host environment variables and the Docker socket are not injected.
The sandbox runs as a separate `pion mcp` server process, so its code ships as an optional `sandbox` extra that is **not** installed by default. Install the extra (the main `pion` process is only an MCP client and never imports it) and make sure the Docker engine is running:

```bash
uv tool install 'pion[sandbox] @ git+https://github.com/Ariasu123/Pion.git' # global tool
pip install 'pion[sandbox] @ git+https://github.com/Ariasu123/Pion.git' # into an environment
uv sync --extra sandbox # from a source checkout
```

`uv run pion --sandbox mcp` starts the independently maintained [sandbox-docker-mcp](https://github.com/Ariasu123/Agent-Toolkit/tree/main/Personal/MCP-Hub/sandbox-docker-mcp) server through Pion's compatibility entry point. It runs in a disposable, non-root container. Only the current project is bind-mounted; Git metadata is read-only by default, protected files such as `.env` are masked, and host environment variables and the Docker socket are not injected. If the extra is missing or Docker is not running, `pion mcp` prints a clear error and the CLI fails fast instead of starting with no tools.

Useful options: `--sandbox-image IMAGE`, `--sandbox-network bridge|none`, `--sandbox-git-write`, and `--allow-project-extensions`. The default bridge network permits outbound access; use `--sandbox-network none` for untrusted repositories. Project extensions execute on the host and are disabled in sandbox mode unless explicitly allowed.

Expand Down
14 changes: 11 additions & 3 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ Pion 是一个受开源项目 pi agent 启发的轻量、可扩展 Python 编码
- **小而清晰的核心** —— 流式 Agent 循环、并行工具执行和生命周期钩子。
- **原生终端工作流** —— 内联 TUI 将完整对话保留在终端 scrollback 中。
- **开放的扩展能力** —— 添加 Python 工具、钩子、斜杠命令或 stdio MCP 服务。
- **可选隔离执行** —— 通过独立开源的 [sandbox-docker-mcp](https://github.com/Ariasu123/Agent-Toolkit/tree/main/Personal/MCP-Hub/sandbox-docker-mcp) 将文件和 shell 工具放入一次性 Docker 沙盒。
- **可选隔离执行** —— 通过独立开源的 [sandbox-docker-mcp](https://github.com/Ariasu123/Agent-Toolkit/tree/main/Personal/MCP-Hub/sandbox-docker-mcp) MCP server 把文件和 shell 工具放进一次性 Docker 沙盒;它作为可选的 `sandbox` extra 按需安装,默认安装不包含

## 快速开始

一键安装器支持 macOS 和 Linux。缺少 [uv](https://docs.astral.sh/uv/) 时会自动安装,uv 随后会提供兼容的 Python 运行时。Docker 是可选依赖,仅在沙盒执行时需要
一键安装器支持 macOS 和 Linux。缺少 [uv](https://docs.astral.sh/uv/) 时会自动安装,uv 随后会提供兼容的 Python 运行时。默认安装的工具都在宿主机运行;Docker 引擎与可选的 `sandbox` extra 仅在沙盒执行(`--sandbox mcp`,见下文)时才需要

```bash
curl -LsSf https://raw.githubusercontent.com/Ariasu123/Pion/main/install.sh | sh
Expand Down Expand Up @@ -117,7 +117,15 @@ Pion 目前只支持基于 stdio 的 MCP tools,尚不支持 resources、prompt
<details>
<summary><strong>Docker 沙盒</strong></summary>

`uv run pion --sandbox mcp` 会通过 Pion 的兼容入口启动独立维护的 [sandbox-docker-mcp](https://github.com/Ariasu123/Agent-Toolkit/tree/main/Personal/MCP-Hub/sandbox-docker-mcp)。服务运行在一次性非 root 容器中,只绑定挂载当前项目;Git 元数据默认只读,`.env` 等受保护文件会被遮蔽,宿主环境变量和 Docker socket 不会注入。
沙盒以独立的 `pion mcp` server 子进程运行,因此其代码是可选的 `sandbox` extra,**默认不安装**。安装该 extra(主进程 `pion` 只是 MCP client,永远不 import 它),并确保本机 Docker 引擎已启动:

```bash
uv tool install 'pion[sandbox] @ git+https://github.com/Ariasu123/Pion.git' # 全局工具
pip install 'pion[sandbox] @ git+https://github.com/Ariasu123/Pion.git' # 安装进某个环境
uv sync --extra sandbox # 源码目录内
```

`uv run pion --sandbox mcp` 会通过 Pion 的兼容入口启动独立维护的 [sandbox-docker-mcp](https://github.com/Ariasu123/Agent-Toolkit/tree/main/Personal/MCP-Hub/sandbox-docker-mcp)。服务运行在一次性非 root 容器中,只绑定挂载当前项目;Git 元数据默认只读,`.env` 等受保护文件会被遮蔽,宿主环境变量和 Docker socket 不会注入。若缺少该 extra 或 Docker 未启动,`pion mcp` 会打印清晰的错误,CLI 随即 fail-fast,而不是在没有工具的情况下启动。

常用选项包括 `--sandbox-image IMAGE`、`--sandbox-network bridge|none`、`--sandbox-git-write` 和 `--allow-project-extensions`。默认 bridge 网络允许出站访问;处理不可信仓库时请使用 `--sandbox-network none`。项目 extension 在宿主机执行,因此沙盒模式下默认禁用,除非显式允许。

Expand Down
3 changes: 1 addition & 2 deletions pion/cli/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from .. import __version__
from ..config import default_config_path
from ..mcp import MCPClientManager
from ..sandbox import build_runtime, check_docker_available
from ..sandbox import build_runtime
from ._shared import console, err_console
from .app import app, main, mcp_command
from .bootstrap import (
Expand Down Expand Up @@ -53,7 +53,6 @@
"app",
"build_runtime",
"build_system_prompt",
"check_docker_available",
"configure_profile",
"console",
"default_config_path",
Expand Down
30 changes: 21 additions & 9 deletions pion/cli/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,15 +182,10 @@ async def _async_main(
mcp_manager: MCPClientManager | None = None
try:
if sandbox_backend == "mcp":
try:
# Fail before constructing the Agent or issuing any model request.
await _cli.check_docker_available()
except SandboxError as exc:
err_console.print(
f"[red]Sandbox startup failed:[/red] {escape(str(exc))}",
soft_wrap=True,
)
raise typer.Exit(1) from exc
# The Docker preflight now runs inside the `pion mcp` server child,
# which owns the optional sandbox_docker_mcp dependency; the main
# process never imports it. A sandbox server that fails to start is
# caught after the MCP manager connects, below.
if sandbox_settings.network == "bridge":
err_console.print(
"[yellow]Sandbox notice:[/yellow] Docker bridge networking is enabled; "
Expand Down Expand Up @@ -281,6 +276,23 @@ async def _async_main(
else:
default_tools = build_default_tools(runtime)

if sandbox_backend == "mcp" and (
mcp_manager is None
or "sandbox" not in mcp_manager.connected_server_names
):
# In MCP mode the sandbox server is the only source of tools; if it
# failed to start (Docker down, or the sandbox extra not installed —
# the reason is printed above), fail fast instead of running the
# agent with no tools.
err_console.print(
"[red]Sandbox startup failed:[/red] the sandbox MCP server "
"(`pion mcp`) did not start; see the error above. Ensure Docker "
"is running and the sandbox extra is installed: "
"pip install 'pion[sandbox] @ git+<repo-url>'.",
soft_wrap=True,
)
raise typer.Exit(1)

# Session: resume an existing JSONL file, or start a new one.
if session_path is not None and session_path.exists():
try:
Expand Down
5 changes: 5 additions & 0 deletions pion/mcp/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,11 @@ def __init__(self, servers: dict[str, MCPServerConfig]) -> None:
def connected_server_count(self) -> int:
return len(self.connections)

@property
def connected_server_names(self) -> set[str]:
"""Names of servers that finished startup (failed ones are excluded)."""
return {connection.name for connection in self.connections}

async def start(self, reserved_tool_names: set[str] | None = None) -> None:
used_names = set(reserved_tool_names or ())
for name, config in self.servers.items():
Expand Down
40 changes: 36 additions & 4 deletions pion/mcp/sandbox_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,28 @@

import asyncio
import os
import sys
from pathlib import Path

from sandbox_docker_mcp.server import serve as external_serve

from ..config import load_config
from ..sandbox import (
DockerSandboxRuntime,
HostSandboxRuntime,
SandboxError,
SandboxRuntime,
SandboxSettings,
)
from ..sandbox.docker import to_external_settings

# The optional `sandbox_docker_mcp` package (the `sandbox` extra) is imported
# lazily inside serve()/build_server_runtime() so that importing this module
# — e.g. `from pion.mcp import sandbox_server` — never requires the extra. Only
# this `pion mcp` server process actually runs the Docker sandbox.
_MISSING_SANDBOX_EXTRA = (
"pion mcp: the Docker sandbox needs the optional 'sandbox' extra "
"(package sandbox_docker_mcp). Install it with:\n"
" pip install 'pion[sandbox] @ "
"git+https://github.com/Ariasu123/Agent-Toolkit.git"
"#subdirectory=Personal/MCP-Hub/sandbox-docker-mcp'"
)


def resolve_server_settings() -> SandboxSettings:
Expand Down Expand Up @@ -74,12 +84,34 @@ def build_server_runtime(settings: SandboxSettings, workspace: Path) -> SandboxR

if os.environ.get("PION_SANDBOX_BACKEND") == "off":
return HostSandboxRuntime(workspace, settings)
from ..sandbox.docker import DockerSandboxRuntime

return DockerSandboxRuntime(workspace, settings) # type: ignore[return-value]


async def serve(workspace: Path | None = None) -> None:
try:
from sandbox_docker_mcp.server import serve as external_serve

from ..sandbox.docker import check_docker_available, to_external_settings
except ModuleNotFoundError as exc:
print(f"{_MISSING_SANDBOX_EXTRA}\n({exc})", file=sys.stderr)
raise SystemExit(1) from exc

active_workspace = workspace or Path.cwd()
settings = resolve_server_settings()

# Preflight Docker here, in the `pion mcp` server child that owns the
# optional dependency, so a stopped daemon fails with a clear stderr message
# (relayed to the user's terminal) instead of surfacing in the parent as an
# opaque MCP connection failure. Skipped for the host test-compat backend.
if os.environ.get("PION_SANDBOX_BACKEND") != "off":
try:
await check_docker_available()
except SandboxError as exc:
print(f"pion mcp: Docker sandbox unavailable: {exc}", file=sys.stderr)
raise SystemExit(1) from exc

runtime = build_server_runtime(settings, active_workspace)
await external_serve(
workspace=active_workspace,
Expand Down
9 changes: 6 additions & 3 deletions pion/sandbox/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,14 @@
SandboxSettings,
SandboxUnavailableError,
)
from .docker import DockerSandboxRuntime, check_docker_available
from .workspace import WorkspaceAccessError, WorkspaceGuard

# NOTE: the Docker sandbox adapter (`.docker`) is intentionally NOT imported
# here. It pulls in the optional `sandbox_docker_mcp` package, which only the
# `pion mcp` server child process needs. Importing it at package import time
# would make the whole CLI crash when the optional extra is absent, even for
# the default `--sandbox off` mode.


def build_runtime(settings: SandboxSettings, workspace: Path) -> SandboxRuntime:
"""Construct the runtime for the default (unsandboxed) host mode.
Expand All @@ -35,7 +40,6 @@ def build_runtime(settings: SandboxSettings, workspace: Path) -> SandboxRuntime:


__all__ = [
"DockerSandboxRuntime",
"HostSandboxRuntime",
"SandboxBackend",
"SandboxCommandResult",
Expand All @@ -47,5 +51,4 @@ def build_runtime(settings: SandboxSettings, workspace: Path) -> SandboxRuntime:
"WorkspaceAccessError",
"WorkspaceGuard",
"build_runtime",
"check_docker_available",
]
8 changes: 7 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,17 @@ dependencies = [
"pydantic>=2.6",
"typer>=0.12",
"rich>=13.7",
"sandbox-docker-mcp @ git+https://github.com/Ariasu123/Agent-Toolkit.git@006b345b3cd9edbc1ed7453873bf9403d5d7495d#subdirectory=Personal/MCP-Hub/sandbox-docker-mcp",
"wcwidth>=0.8.2",
"jsonschema>=4.20",
]

[project.optional-dependencies]
# The Docker sandbox is an independent MCP server. Only the `pion mcp` server
# process needs this package; the default CLI (--sandbox off) never imports it.
sandbox = [
"sandbox-docker-mcp @ git+https://github.com/Ariasu123/Agent-Toolkit.git@006b345b3cd9edbc1ed7453873bf9403d5d7495d#subdirectory=Personal/MCP-Hub/sandbox-docker-mcp",
]

[dependency-groups]
dev = [
"pyte>=0.8.2",
Expand Down
64 changes: 52 additions & 12 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from __future__ import annotations

import re
import subprocess
import sys
from datetime import datetime

Expand Down Expand Up @@ -526,6 +527,9 @@ def __init__(self, servers):
self.tools = []
self.errors = []
self.connected_server_count = 1
# The mounted sandbox server "connects" so the mcp-backend
# fail-fast (no sandbox tools) is not triggered here.
self.connected_server_names = set(servers)

async def start(self, reserved):
pass
Expand All @@ -540,11 +544,7 @@ def __init__(self, *args, **kwargs):
async def run_print(self, text):
return None

async def noop_docker_check():
return None

monkeypatch.setattr(cli, "build_runtime", lambda settings, workspace: runtime)
monkeypatch.setattr(cli, "check_docker_available", noop_docker_check)
monkeypatch.setattr(cli, "MCPClientManager", FakeMCPManager)
monkeypatch.setattr(cli, "extension_dirs", capture_extension_dirs)
monkeypatch.setattr(cli, "Repl", NoopRepl)
Expand Down Expand Up @@ -619,15 +619,15 @@ async def test_async_startup_mcp_backend_mounts_internal_sandbox(
) -> None:
seen = {}

async def fake_docker_check():
seen["preflight"] = True

class FakeMCPManager:
def __init__(self, servers):
seen["servers"] = servers
self.tools = []
self.errors = []
self.connected_server_count = 1
# The sandbox server connected: the child owns the Docker preflight
# now, so the main process only checks that it came up.
self.connected_server_names = set(servers)

async def start(self, reserved):
seen["reserved"] = reserved
Expand All @@ -646,7 +646,6 @@ def fail_build_runtime(settings, workspace):
raise AssertionError("mcp backend must not build a host runtime")

monkeypatch.setattr(cli, "build_runtime", fail_build_runtime)
monkeypatch.setattr(cli, "check_docker_available", fake_docker_check)
monkeypatch.setattr(cli, "MCPClientManager", FakeMCPManager)
monkeypatch.setattr(cli, "Repl", NoopRepl)

Expand All @@ -661,7 +660,6 @@ def fail_build_runtime(settings, workspace):
None,
)

assert seen["preflight"]
sandbox_server = seen["servers"]["sandbox"]
assert sandbox_server.command == sys.executable
assert sandbox_server.args == ["-m", "pion.cli", "mcp"]
Expand All @@ -676,10 +674,23 @@ async def test_async_startup_mcp_backend_fails_closed_without_docker(
tmp_path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def failing_docker_check():
raise SandboxUnavailableError("no daemon")
# The Docker preflight now lives in the `pion mcp` child; when it fails, the
# sandbox server never connects, so the main process must fail fast rather
# than run the agent with no tools.
class FakeMCPManager:
def __init__(self, servers):
self.tools = []
self.errors = ["sandbox: Docker sandbox unavailable: no daemon"]
self.connected_server_count = 0
self.connected_server_names = set()

async def start(self, reserved):
pass

monkeypatch.setattr(cli, "check_docker_available", failing_docker_check)
async def close(self):
pass

monkeypatch.setattr(cli, "MCPClientManager", FakeMCPManager)
monkeypatch.setattr(
cli, "build_runtime", lambda *a: pytest.fail("must not build runtime")
)
Expand All @@ -698,6 +709,35 @@ async def failing_docker_check():
assert excinfo.value.exit_code == 1


def test_main_process_imports_without_sandbox_extra() -> None:
"""The default CLI must import with the optional `sandbox` extra absent.

Poison ``sys.modules`` so any `import sandbox_docker_mcp` raises, then import
the modules the `pion` entry point pulls in and run the default (host)
`build_runtime` path. A regression — a top-level import of the extra sneaking
back into the main process — surfaces here as a ModuleNotFoundError, even
though this venv has the extra installed for the adapter tests.
"""
script = (
"import sys\n"
"sys.modules['sandbox_docker_mcp'] = None\n"
"import pion.cli\n"
"import pion.config\n"
"from pathlib import Path\n"
"from pion.sandbox import SandboxSettings, build_runtime\n"
"build_runtime(SandboxSettings(backend='off'), Path('.'))\n"
"assert sys.modules.get('sandbox_docker_mcp') is None\n"
"print('OK')\n"
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
)
assert result.returncode == 0, result.stderr
assert result.stdout.strip().endswith("OK")


# ---------------------------------------------------------------------------
# Pure helpers
# ---------------------------------------------------------------------------
Expand Down
Loading
Loading