Metadata-Version: 2.5
Name: sandbox-agent
Version: 0.2.0
Summary: Run LLM agents inside disposable Docker sandboxes.
Project-URL: Homepage, https://cnb.cool/maikebuke/Tools/DockerHub/opencode
Project-URL: Repository, https://cnb.cool/maikebuke/Tools/DockerHub/opencode.git
Author-email: Birkhoff <admin@maikebuke.com>
License: GPL-3.0-or-later
Keywords: agent,docker,llm,opencode,sandbox
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Python: >=3.12
Description-Content-Type: text/markdown

# sandbox-agent

把 LLM agent CLI 关进一次性 Docker 容器里跑。

agent 会写文件、跑命令、装依赖。让它在你的开发机上直接干这些事是个坏主意。
这个库给它一个用完就扔的容器，并把结果安全地取出来。

**零运行时依赖**，只要求宿主机有 `docker`。

```python
from SandboxAgent import SafeAgent, OpenCodeDriver

driver = OpenCodeDriver(provider="zhipuai-coding-plan", api_key=API_KEY)

with SafeAgent(driver=driver, model="zhipuai-coding-plan/glm-5-turbo") as agent:
    print(agent.ask("用 Python 写个快排, 存到 /tmp/qs.py"))

    code, out = agent.container.exec(["python", "/tmp/qs.py"])  # 验收产出
    agent.container.download("/tmp/qs.py", "./qs.py")           # 取回宿主机
# 退出 with 块 -> 容器销毁
```

## 安装

```bash
uv add sandbox-agent      # 或 pip install sandbox-agent
```

还需要一个装好了 agent CLI 的镜像，见 [`docker/`](docker/) 与 [`.ci/build_docker.sh`](.ci/build_docker.sh)。

### 包和镜像的对应关系

**装了哪个版本的包，就唯一确定了该拉哪个镜像。** 不靠约定，靠推导：

```
pip install sandbox-agent==0.1.4
  └─ wheel 里带着 src/SandboxAgent/versions.json
       claude_version = 2.1.235
       image_date     = 20260819
     └─ donaldtrump/sandbox-agent:claude-2.1.235-20260819
```

tag 格式就是 `{repo}:{cli}-{cli版本}-{image_date}`，三段都来自那一份
[`versions.json`](src/SandboxAgent/versions.json)——构建脚本和 Python 运行时读的是
同一份，两边各存一份就会得到一个**名字和内容对不上的镜像**，而且不报错。

几条刻意的选择：

- **包版本不进 tag。** 快照内容已经唯一确定了 tag，再把「这是哪个快照」编码进去是
  多绕一圈。反过来 `pyproject.toml` 也不读 `versions.json`：打包版本是打包的事，
  镜像日期是运行时的事，粘在一个文件里只会互相绑架。
- **`image_date` 是手写的声明，不是构建时观测的。** 它管的是「三个 CLI 一个都没升，
  但基础镜像要重建」那种情况——CLI 版本号挡不住，日期能，而且比 `-1` `-2` 有信息量。
  构建脚本里写 `date +%Y%m%d` 会让运行时算出的 tag 指向一个从没构建过的镜像，
  且不报错，所以日期只读不算（有测试盯着）。
- **不用 `latest`。** 同一份代码在不同时间跑出不同行为，「验证过」三个字就没了意义。
  构建脚本只推钉死的 tag。

代价是人得记得改日期，所以 `build_docker.sh --push` 会先查 registry，tag 已存在就
拒绝推送（同一天反复调试用 `--force`）；`build_pypi.sh --push` 会反过来查三个镜像
是否都已就位，缺一个就拒绝发布。**发布顺序被钉死了：先镜像，后包。**

**不传 `image=` 时用 driver 自己的默认镜像**——每个 agent CLI 有自己的镜像，一个全局
默认值只能对其中一个是对的。优先级：显式 `image=` > `SANDBOX_AGENT_IMAGE_NAME`
（换私有 registry 用）> `driver.default_image` > 内置默认值。

## 本地 LLM 服务（可选）

[`runtime/litellm/`](runtime/litellm/) 是一个独立的 compose 栈，把 CNB 的 CodeBuddy
网关包装成本地的 OpenAI 兼容 API：

```bash
cd runtime/litellm && docker compose up -d --build   # -> http://127.0.0.1:4000/v1
```

跟本库的 `SafeAgent` 没有代码耦合，各起各的。想让沙箱容器用上它，见那边的 README。

## 能干什么

| | |
|---|---|
| **对话** | `chat()` 拿可迭代的流式事件，推进主线；`ask()` 开旁支问一句，问完即扔 |
| **会话** | `sessions.list()` / `.fork()` / `.dump()` / `.load()` —— 会话能跨容器搬 |
| **容器操作** | `container.upload()` / `.download()` / `.read()` / `.write()` / `.exec()` |
| **能力注入** | `skills.add()` / `.add_many()` / `.list()`，`rules.install()` |
| **可观测** | `token_usage`、`AgentEvent` 流、按 tag 筛容器 |
| **可审计** | 每次会话自动归档到 `/tmp/SandboxAgent/`，**默认开** |

驱动内置 `OpenCodeDriver`、`CodexDriver` 与 `ClaudeCodeDriver`。接别的 agent CLI
就继承 `BaseDriver` 实现那 11 个抽象成员。

**driver 能碰容器的只有三个原语**（`ContainerOps`：`run` / `read_file` /
`write_file`）——driver 不该知道 docker 存在。这也意味着 driver 的单测只要伪造三个
方法，不用 mock 一个上千行的类。

想连容器一起换（换成 podman、换成远程执行器），那是另一个扩展点：按
`ContainerRuntime` 那 11 个方法写一个，传给 `SafeAgent(runtime_factory=...)`。

完整 API 见 [docs/sandbox_agent.md](docs/sandbox_agent.md)，可跑的完整示例见 [main.py](main.py)：

```bash
uv run python main.py            # 全套 opencode demo, 十几个容器几分钟
uv run python main.py --fast     # 只跑第一个, 够验证"改完还能跑"
uv run python main.py --claude   # 只跑 ClaudeCodeDriver 那个
```

前两档要 `ZHIPUAI_CODING_TOKEN` 和装好 opencode 的镜像；`--claude` 两样都不要，
但要 claude 镜像和一个 Anthropic 兼容网关（[`runtime/litellm/`](runtime/litellm/) 就是）。

## 会话审计

每次会话自动落盘，事后能查 agent 到底做了什么。**默认开着**——出事之后才想起来
打开的开关等于没有。

```
/tmp/SandboxAgent/20260819/ses_9f2/
    events.jsonl    抽象事件，一行一个
    session.jsonl   原始 stdout 行，逐字节照抄
    status.json     元数据 + 每一轮的结果
```

两个文件回答两个不同的问题：

```bash
# agent 干了什么 —— 三个 CLI 归一之后的形式，读这个就够
jq -r '"\(.kind)\t\(.payload.command // .payload.path // .payload.text)"' events.jsonl
```
```
tool.exec   printf 'sandbox-agent-io-ok\n' > /workspace/_claude_demo.txt
text        done
skill       Launching skill: zebra-protocol
text        zebra code 是 ZC-8842。house rule 是 HR-7391。
```

工具调用、参数、结果、最终回答——「agent 干了什么」一条不漏。

#### `thinking` 事件：取决于后端，真 Anthropic API 上拿不到

`EventKind` 里有 `thinking`，但它**在真 Anthropic API 上一条都不会产生**。实测
（非流式 / 流式 SSE / 真 CLI 过沙箱，三条路径一致）：

```
content 块类型: ['thinking', 'text']
thinking 键   : ['signature', 'thinking', 'type']
thinking 长度 : 0                     ← 正文是空的
usage.thinking_tokens: 77             ← 但它确实思考了 77 个 token
```

模型思考了、计费了、块也返回了，**正文被抹掉，只留一个 signature**（那是给多轮
续接做完整性校验的，不是给人读的）。所以 `thinking` 事件只在**转发推理模型的
OpenAI 兼容网关**下才有内容——比如 [`runtime/litellm/`](runtime/litellm/) 那套把
DeepSeek 接成 Anthropic 协议的栈，DeepSeek 会把原始推理链吐出来。

有内容时，多个 thinking 块按原顺序各成一条事件，不合并：那个「想 → 调工具 → 再想」
的交错顺序本身就是信息。

```bash
# driver 归对了没有 —— 某条事件是从哪一行解析出来的
sed -n "$(jq -r 'select(.kind=="other") | .line' events.jsonl)p" session.jsonl
```

`session.jsonl` 是**逐字节照抄**，不做任何过滤。`system/thinking_tokens` 那种进度
心跳照样全量落盘——真 Anthropic API 下一轮只有个位数，但经网关转发推理模型时能占到
97% 的行。那是它的原始输出，我们不替你判断什么重要。要过滤是**读的时候**的事：

```bash
jq -c 'select(.subtype != "thinking_tokens")' session.jsonl
```

`events.jsonl` 的每条带一个 `line` 字段回指 `session.jsonl` 的行号——两个文件行数
必然对不上（`step_start` 这类不产出事件），一个整数把对应关系还回来。

同一个 `session_id` 的多轮对话落进同一个目录，两个 jsonl 跨轮追加。流式写入，
跑着的会话可以直接 `tail -f`。

```python
SafeAgent(driver=driver, audit=False)     # 显式关闭
```

目录和保留天数是 `audit` 模块的两个常量：

```python
from SandboxAgent import audit
audit.AUDIT_ROOT = "/data/sandbox-audit"
audit.RETENTION_DAYS = 30                 # 0 = 永不清理
```

清理按天数：早于 `today - N` 的**整个日期目录**删掉，每进程跑一次。日期分区让这件事
退化成一次 `listdir` 加比字符串，不用 walk 也不用 stat；名字不像日期的目录一概不碰。

> **归档失败不会影响 chat。** 可观测性功能不能反过来搞死被观测的东西：磁盘满了就发
> 一条 `RuntimeWarning` 然后自己降级成 no-op，该跑的照跑。
>
> **`ask()` 的旁支在容器里删了，归档留着。** 审计的意义就是留痕。

## 容器生命周期

容器**闲置** `keepalive_seconds`（默认 3600s）后自杀，不是"最多活一小时"——
只要还有命令在跑就会一直续期。正常路径下 `close()` / `with` 退出即销毁，
自杀只是兜底，防止调用方崩溃后留下垃圾。

## 配置

优先级：环境变量 > `~/.config/safe_agent/config.json` > 内置默认值。

| 环境变量 | JSON key | 默认值 |
|---|---|---|
| `SANDBOX_AGENT_IMAGE_NAME` | `image` | driver 自己的 `default_image`（见下） |
| `SANDBOX_AGENT_RESOURCE_PREFIX` | `prefix` | `safe-agent` |
| `SANDBOX_AGENT_CONTAINER_MEMORY` | `memory` | `1g` |
| `SANDBOX_AGENT_CONTAINER_CPUS` | `cpus` | `1.0` |
| `SANDBOX_AGENT_KEEPALIVE_SECONDS` | `keepalive_seconds` | `3600`（`0` = 不自杀） |
| `SANDBOX_AGENT_MAX_CONTAINERS` | `max_containers` | `20` |
| `SANDBOX_AGENT_START_TIMEOUT` | `start_timeout` | `120` |
| `SANDBOX_AGENT_STOP_TIMEOUT` | `stop_timeout` | `30` |
| `SANDBOX_AGENT_EXEC_TIMEOUT` | `exec_timeout` | `60` |
| `SANDBOX_AGENT_CHAT_TIMEOUT` | `chat_timeout` | `300` |
| `SANDBOX_AGENT_SESSION_TIMEOUT` | `session_timeout` | `120` |
| `SANDBOX_AGENT_TRANSFER_TIMEOUT` | `transfer_timeout` | `300` |

所有带 `timeout` 的方法参数一律 **`None` = 用这类操作的配置预算**。落到哪一项取决于
操作的性质：短命令是常数耗时，会话操作随会话长度增长，传输随字节数增长 —— 三种预算，
一条规则。

## 开发

```bash
uv sync --all-extras

uv run pytest                    # 单元测试, 全 mock, 不碰 docker
uv run pytest -m integration     # 集成测试, 起真容器, 约 100s
```

集成测试默认跳过。它们用 `debian:stable-slim`（不需要 agent CLI），
可用 `SANDBOX_AGENT_TEST_IMAGE` 换掉。**busybox 的 `timeout` 超时不返回 124，
别拿 alpine 顶替。**

镜像必须**事先拉好**——测试不会替你 pull（那会让一次"只跑单测"的命令悄悄卡在
一个几百秒的下载上）。没拉的话集成测试会跳过，并在输出里给出要敲的命令：

```bash
docker pull debian:stable-slim
```

已知限制与后续计划见 [TODO.md](TODO.md)，变更历史见 [CHANGELOG.md](CHANGELOG.md)。

## 发布

顺序不能反——包里的 `versions.json` 指向镜像，镜像还没推包就发不出去
（`build_pypi.sh` 会拒绝）：

```bash
# 0. 改了 CLI 版本或动了 Dockerfile.base? 先 bump src/SandboxAgent/versions.json
#    的 image_date —— 它是手写的，没人替你算
./.ci/build_docker.sh --push     # 1. 镜像（tag 已存在会拒绝，加 --force 覆盖）
./.ci/build_pypi.sh   --push     # 2. 包（三个镜像不齐会拒绝）
```

## License

GPL-3.0-or-later
