diff --git a/.github/workflows/codebuddy-integration.yml b/.github/workflows/codebuddy-integration.yml new file mode 100644 index 000000000..d6ac3fe30 --- /dev/null +++ b/.github/workflows/codebuddy-integration.yml @@ -0,0 +1,80 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +name: codebuddy-integration + +on: + pull_request: + paths: + - "examples/codebuddy-integration/**" + - "docs/**/integrations/codebuddy.md" + - ".github/workflows/codebuddy-integration.yml" + push: + branches: [master] + +jobs: + static: + name: Static checks (compile + unit tests) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + working-directory: examples/codebuddy-integration + run: pip install -r requirements.txt + + - name: Compile host driver scripts + working-directory: examples/codebuddy-integration + run: | + python3 -m py_compile env_utils.py _codebuddy_common.py \ + run_codebuddy.py resume_codebuddy.py network_policy.py + + - name: Run unit tests + working-directory: examples/codebuddy-integration + run: python3 -m pytest tests/ -v + + - name: Smoke-check --help output + working-directory: examples/codebuddy-integration + run: | + python3 run_codebuddy.py --help + python3 resume_codebuddy.py --help + python3 network_policy.py --help + + dockerfile: + name: Dockerfile builds cleanly + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build (cache miss is fine; --load is just for smoke) + uses: docker/build-push-action@v6 + with: + context: examples/codebuddy-integration + push: false + load: true + tags: codebuddy-cube:ci + cache-from: type=gha + cache-to: type=gha,mode=max + + - name: codebuddy --version inside the image + run: | + cid=$(docker run -d --rm codebuddy-cube:ci) + docker exec "$cid" codebuddy --version + # envd /health reachable means the readiness probe will succeed; + # add a retry loop because envd may still be booting on cold cache. + for i in $(seq 30); do + if docker exec "$cid" curl -fsS -o /dev/null \ + http://127.0.0.1:49983/health; then + break + fi + sleep 1 + done + docker rm -f "$cid" diff --git a/docs/guide/integrations/codebuddy.md b/docs/guide/integrations/codebuddy.md new file mode 100644 index 000000000..b33f89ff4 --- /dev/null +++ b/docs/guide/integrations/codebuddy.md @@ -0,0 +1,371 @@ +--- +title: CodeBuddy Code Integration Guide +author: pei-pei45 +date: 2026-07-16 +tags: + - integration + - codebuddy + - coding-agent + - agent +lang: en-US +--- + +# CodeBuddy Code Integration Guide + +[中文文档](../../zh/guide/integrations/codebuddy.md) + +Run the [Tencent CodeBuddy Code CLI](https://www.codebuddy.ai/docs/cli/README) +— a terminal-native AI coding agent — inside CubeSandbox MicroVMs. This guide +covers image build, key injection, egress control, and snapshot-based session +persistence, and pairs with the runnable +[`examples/codebuddy-integration`](https://github.com/TencentCloud/CubeSandbox/tree/master/examples/codebuddy-integration) +project. + +## Integration Target and Version + +| Component | Version | +|---|---| +| CodeBuddy Code | `@tencent-ai/codebuddy-code` (pinned via `--build-arg CODEBUDDY_VERSION=x.y.z`) | +| Node.js | 20 (installed via NodeSource) | +| CubeSandbox base image | `ghcr.io/tencentcloud/cubesandbox-base:2026.16` | +| E2B SDK (host driver) | `e2b` (latest) | +| CubeSandbox platform | `>= 0.3.0` (pause/resume) / `>= 0.4.0` (CubeEgress credential vault) | + +## Prerequisites + +- A running CubeSandbox deployment; CubeAPI reachable at `http://:3000`. +- `cubemastercli` on `$PATH`, connected to the cluster. +- Docker on the build workstation, plus a registry the Cube nodes can pull from. +- A CodeBuddy Code account, or a custom upstream API key. CodeBuddy Code can be + pointed at the international CodeBuddy platform (`CODEBUDDY_INTERNET_ENVIRONMENT=io`), + the China platform (`internal`), the iOA enterprise platform (`ioa`), or any + Anthropic- / OpenAI-compatible endpoint via `CODEBUDDY_BASE_URL` / + `ANTHROPIC_BASE_URL`. +- Python 3.10+ for the host driver scripts. + +## Why Run CodeBuddy Inside a Sandbox + +CodeBuddy Code is a terminal agent that edits files, runs commands, and installs +packages. Running it directly on a workstation blends the agent's blast radius +with your dev environment. Running it inside CubeSandbox gives you: + +| Concern | CubeSandbox provides | +|---|---| +| **Isolation** | KVM MicroVM per session, dedicated guest kernel | +| **Reproducibility** | Every session boots from the same template snapshot | +| **Fast spin-up** | Sub-60 ms cold start, so N-parallel agents are cheap | +| **Long tasks** | `sandbox.pause()` snapshots VM + rootfs; resume later | +| **Key hygiene** | CubeEgress injects the auth header on the wire — the VM never sees the real key | +| **Egress audit** | Every request to the LLM API is recorded in the egress audit log | + +## Integration Steps + +### 1. Build the template image + +The image stacks Node.js 20 and the CodeBuddy CLI on top of `cubesandbox-base`, +so envd is already listening on `:49983`. + +```dockerfile +# examples/codebuddy-integration/Dockerfile (excerpt) +ARG CUBE_BASE_IMAGE=ghcr.io/tencentcloud/cubesandbox-base:2026.16 +FROM ${CUBE_BASE_IMAGE} + +ARG NODE_MAJOR=20 +ARG CODEBUDDY_VERSION=2.117.1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl git gnupg jq less procps python3 python3-pip ripgrep \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg \ + && gpg --show-keys /etc/apt/keyrings/nodesource.gpg 2>/dev/null \ + | grep -q "6F71F525282841EEDAF851B42F59B5F99B1BE0B4" \ + || (echo "ERROR: NodeSource GPG fingerprint mismatch" && exit 1) \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g --omit=dev --ignore-scripts \ + "@tencent-ai/codebuddy-code@${CODEBUDDY_VERSION}" \ + && codebuddy --version \ + && rm -rf /root/.npm + +# CodeBuddy runs as an unprivileged user. UID/GID are auto-assigned because the +# base image already owns uid=1000 as the ``user`` exec account; pause/resume +# snapshots preserve identity by username, not by numeric id. The MicroVM +# provides the outer isolation; this user drop is defense-in-depth for +# prompt-injection scenarios where the LLM agent is tricked into shell commands. +# +# /workspace is world-writable because the e2b exec channel constrains us to +# the SDK's allowed-users list (``root``, ``user``) and ``user`` cannot write +# to a codebuddy-owned directory. The CodeBuddy state dir is rooted under +# /workspace/.codebuddy so the same permission model applies and pause/resume +# snapshots capture it alongside the project tree. +RUN groupadd --system codebuddy \ + && useradd --system --gid codebuddy \ + --home-dir /home/codebuddy --shell /bin/bash \ + --no-create-home codebuddy \ + && install -d -o codebuddy -g codebuddy -m 0700 /home/codebuddy \ + && install -d -o codebuddy -g codebuddy -m 0777 /workspace + +ENV CODEBUDDY_CONFIG_DIR=/workspace/.codebuddy \ + DISABLE_TELEMETRY=1 \ + DISABLE_ERROR_REPORTING=1 \ + DISABLE_AUTOUPDATER=1 \ + DISABLE_FEEDBACK_COMMAND=1 \ + CODEBUDDY_INTERNET_ENVIRONMENT=io + +WORKDIR /workspace +USER codebuddy +``` + +Build and push (from the repository root, so the relative build context +`examples/codebuddy-integration` resolves correctly): + +```bash +docker build --pull --platform linux/amd64 \ + -t /codebuddy-cube:latest \ + examples/codebuddy-integration +docker push /codebuddy-cube:latest +``` + +### 2. Register as a Cube template + +```bash +cubemastercli tpl create-from-image \ + --image /codebuddy-cube:latest \ + --writable-layer-size 4G \ + --expose-port 49983 \ + --probe 49983 \ + --probe-path /health + +cubemastercli tpl watch --job-id +``` + +Once the job reaches `READY`, note the `template_id` — you pass it to every +`Sandbox.create()` call. `4G` writable layer suits medium tasks; bump to `8G+` +if the agent installs large toolchains. + +### 3. Wire up the host driver + +```bash +cd examples/codebuddy-integration +cp .env.example .env +# fill in E2B_API_URL, CUBE_TEMPLATE_ID, CODEBUDDY_INTERNET_ENVIRONMENT, and your provider key +pip install -r requirements.txt +``` + +| Variable | Where it flows | Notes | +|---|---|---| +| `E2B_API_URL` | Local process | CubeAPI address (`http://:3000`) | +| `E2B_API_KEY` | Local process | Any non-empty string in local dev | +| `CUBE_TEMPLATE_ID` | `Sandbox.create(template=...)` | From step 2 | +| `CODEBUDDY_INTERNET_ENVIRONMENT` | CodeBuddy CLI | `io` (default, international), `internal` (China), `ioa` (Tencent enterprise) | +| `CODEBUDDY_MODEL` / `CODEBUDDY_BASE_URL` | CodeBuddy CLI flags | Model id and optional custom upstream endpoint | +| `CODEBUDDY_API_KEY` / `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / ... | `envs=...` (direct) or CubeEgress inject (vault) | Provider key | +| `CODEBUDDY_LLM_HOST` | `network_policy.py` | LLM host allowed under default-deny egress | + +### 4. Runtime Configuration and API Key Injection + +CodeBuddy is built headlessly with `-p` (process the prompt and exit, no TUI) +and `-y` / `--dangerously-skip-permissions` (required for any non-interactive +run that touches files or runs commands — without it the CLI blocks on a +permission prompt that cannot be answered over the exec channel). The prompt is +the trailing positional argument. Two key-flow flavors share the same template: + +**Direct flavor** — forward the key per command. `e2b`'s `commands.run(envs=...)` +puts the environment into the exec envelope, not into a persistent file inside +the VM, so the key lives only for the lifetime of that command. The exec +channel only accepts the usernames `root` and `user` (the same `user` that +`_codebuddy_common.run_command` defaults to); passing `user="codebuddy"` +raises `invalid username: 'codebuddy'`. Inside the image the agent still runs +unprivileged because the Dockerfile drops to `USER codebuddy`; the SDK-level +`user` argument only constrains the exec channel identity, not the container's +process identity: + +```python +result = sandbox.commands.run( + "cd /workspace && codebuddy -p -y --model claude-sonnet-4-6 'do something'", + envs={"ANTHROPIC_API_KEY": key}, + user="user", + timeout=900, +) +``` + +**Vault flavor** — keep the key out of the VM entirely (see step 6). + +### 5. Session Persistence (pause / resume) + +```bash +cd examples/codebuddy-integration +python resume_codebuddy.py +``` + +This mirrors the [snapshot / clone / rollback](../snapshot-rollback-clone.md) +engine at the SDK layer: + +- `sandbox.pause()` snapshots the running VM (memory + rootfs) and frees compute. +- `Sandbox.connect(sandbox_id)` resumes with `/workspace`, CodeBuddy's state + directory (`/workspace/.codebuddy`), and every other file intact. Turn 2 then + calls `codebuddy -p -y -c` to continue the most recent session CodeBuddy + recorded under `$CODEBUDDY_CONFIG_DIR/projects/`. + +> **Lifecycle caveat:** manage the sandbox lifecycle with `try/finally`, not a +> `with Sandbox.create(...)` context manager. On `__exit__` the context manager +> kills the sandbox, which would undo the pause. The example creates the sandbox +> explicitly and only calls `sandbox.kill()` in `finally`. + +```python +sandbox = Sandbox.create(template=template_id, timeout=1800) +try: + run_turn(sandbox, prompt_1) # writes /workspace/plan.md + sandbox_id = sandbox.pause() or sandbox.sandbox_id + sandbox = Sandbox.connect(sandbox_id) + assert_state_survived(sandbox) # /workspace + /workspace/.codebuddy intact + run_turn(sandbox, prompt_2, continue_session=True) +finally: + sandbox.kill() +``` + +### 6. Network and Egress Policy (credential vault) + +Run `network_policy.py` from `examples/codebuddy-integration/` (after step 3): + +```bash +cd examples/codebuddy-integration +python network_policy.py +``` + +The script demonstrates the recommended pattern for shared clusters: +default-deny egress plus on-the-wire key injection. + +```python +# Credential injection uses the native cubesandbox SDK (see security-proxy.md). +from cubesandbox import Sandbox, Rule, Match, Action, Inject + +host = "api.anthropic.com" +rules = [ + Rule( + name="allow_anthropic_llm", + match=Match(scheme="https", sni=host, host=host), + action=Action(allow=True, audit="metadata", inject=[ + Inject(header="x-api-key", secret=ANTHROPIC_API_KEY, format="${SECRET}"), + Inject(header="anthropic-version", secret="2023-06-01", format="${SECRET}"), + ]), + ), +] + +sandbox = Sandbox.create( + template=CUBE_TEMPLATE_ID, + allow_internet_access=False, # default-deny; the rule's host is auto-allowed + network={"rules": rules}, +) +``` + +Effect: + +- `printenv ANTHROPIC_API_KEY` inside the sandbox shows only a placeholder. +- Every request to the LLM host gets the auth header attached on the wire. +- Anything else is dropped by CubeVS at L3/L4 (`allow_internet_access=False`) and never leaves the sandbox. +- Every allow / deny decision lands in the egress audit log. + +For non-Anthropic providers the example injects an `Authorization: Bearer` header +instead. If a provider does not accept a header-injected key, fall back to the +direct flavor (`envs=...`) — but never write the key into a persistent file +inside the sandbox. + +## Use Cases and Best Practices + +- **Isolated development.** Run the coding agent inside the sandbox so its file + edits and shell commands cannot touch the host. +- **Execute agent-generated code and collect results.** Have the agent write to + `/workspace`, then read artifacts back via `sandbox.files` or `commands.run`. +- **Checkpoint / resume long tasks.** Use `pause()` + `connect()` to snapshot a + long refactor and resume later, or fork multiple task variants off one snapshot. +- **Switch LLM providers without rebuilding the image.** CodeBuddy itself keys + off `CODEBUDDY_INTERNET_ENVIRONMENT` + `CODEBUDDY_API_KEY`; override the + upstream via `CODEBUDDY_BASE_URL` to point at Anthropic / OpenAI / DeepSeek / + Gemini while keeping the same template. +- **Preinstall heavy dependencies** into the template rather than fetching them + at runtime, especially under a default-deny egress policy. + +## Key Code Snippets + +### Headless CodeBuddy invocation + +```python +cmd = ( + "cd /workspace && codebuddy -p -y --model claude-sonnet-4-6 " + "'Inspect the project, run app.py, and summarize the result.'" +) +result = sandbox.commands.run(cmd, envs=codebuddy_env, timeout=900) +``` + +### Preflight version check + +```python +version = sandbox.commands.run("codebuddy --version", timeout=60) +``` + +## Caveats + +- **Node.js version.** CodeBuddy requires Node 18.20+; the base image ships an + older apt Node, so always install via NodeSource (the Dockerfile does). +- **Non-interactive mode needs a pre-set key.** `codebuddy -p` never falls back + to a browser login flow — the run will block on the auth popup if you forget + to set `CODEBUDDY_API_KEY` (or the matching provider env). `run_codebuddy.py` + raises before booting the sandbox if none is set. +- **Permission mode.** `-y` skips every tool-call prompt; required because + permission prompts cannot be answered over the non-interactive exec channel. + In higher-security workflows tighten the allow-list via `settings.json` + (`permissions.defaultMode`, `permissions.allow`, ...) rather than passing `-y`. +- **Agent state directory.** `/workspace/.codebuddy` holds CodeBuddy's + session cache (config, history, sessions, plans, file-history). Keep it empty + in the image to avoid leaking sessions across tenants; the Dockerfile creates + it but does not populate it with any credentials. +- **Direct-flavor key persistence.** With the direct flavor (`envs=`) the key + is scoped to the exec call, but CodeBuddy may cache provider credentials + under its state dir (`/workspace/.codebuddy/`), which survives `pause()` / + `resume()`. For strict isolation prefer the vault flavor (`network_policy.py`), + where the key never enters the VM. +- **CubeEgress CA (Node).** For the vault flavor the sandbox must trust the + CubeEgress root CA, which the base image installs into the system bundle. + CodeBuddy ships as a Node.js bundle that ignores the system store, so + `network_policy.py` also sets `NODE_EXTRA_CA_CERTS` (override via + `CODEBUDDY_NODE_EXTRA_CA_CERTS`) — without it the vault path fails with + "unable to verify the first certificate". +- **Egress side-effects.** Tasks that `npm install` or fetch MCP tools need + those hosts allowed or preinstalled into the template. +- **Interactive TTY features.** The CodeBuddy TUI is not available over the + E2B protocol. Use headless `-p -y` and drive multi-turn conversations from + the host script (`-c` / `--resume` for continuation, `--session-id` to pin). + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `codebuddy: command not found` in preflight | Template not rebuilt after CLI change | Rebuild the image, re-register the template | +| Browser login popup blocks the run | `-p` mode requires a pre-set API key, never falls back to interactive login | Set `CODEBUDDY_API_KEY` (or the matching provider env) before launching | +| Permission prompt hangs the run | Forgot `-y` / `--dangerously-skip-permissions` on a run that touches files or commands | Add `-y`, or tighten `settings.json` permissions for non-`y` runs | +| Provider auth failure | Key not forwarded (direct) or missing inject rule (vault) | Pass `envs={...}` or fix the rule's `sni`/`host` | +| `403 Forbidden - CubeEgress` | Default-deny with no matching allow rule | Add the LLM host (and any extra hosts) to the rules | +| `Connection error` / TLS failure from CodeBuddy (vault) | CodeBuddy's Node runtime ignores the system CA store, so it won't trust the CubeEgress CA | The example sets `NODE_EXTRA_CA_CERTS`; override with `CODEBUDDY_NODE_EXTRA_CA_CERTS` if the CA lives elsewhere | +| Template creation stuck in `PULLING` | Registry unreachable from Cube nodes | Push to a registry the cluster can reach; supply auth if needed | +| Readiness probe timeout | Base image without envd | Ensure `FROM ghcr.io/tencentcloud/cubesandbox-base:2026.16` | +| `pause()` / `connect()` errors | Platform too old for snapshots | Upgrade the CubeSandbox platform | + +## References + +- Runnable example: [`examples/codebuddy-integration`](https://github.com/TencentCloud/CubeSandbox/tree/master/examples/codebuddy-integration) +- Bring Your Own Image: [`docs/guide/tutorials/bring-your-own-image.md`](../tutorials/bring-your-own-image.md) +- Template from image: [`docs/guide/tutorials/template-from-image.md`](../tutorials/template-from-image.md) +- Snapshot / Clone / Rollback: [`docs/guide/snapshot-rollback-clone.md`](../snapshot-rollback-clone.md) +- Credential vault + egress control: [`docs/guide/security-proxy.md`](../security-proxy.md) +- CodeBuddy Code CLI: +- CodeBuddy Code installation: +- CodeBuddy Code environment variables: +- CodeBuddy Code directory layout (`~/.codebuddy`): diff --git a/docs/guide/integrations/index.md b/docs/guide/integrations/index.md index f40867fbc..b00182b36 100644 --- a/docs/guide/integrations/index.md +++ b/docs/guide/integrations/index.md @@ -48,3 +48,4 @@ lang: en-US | Title | Author | Date | Tags | | --- | --- | --- | --- | | [Pi Agent Integration Guide](./pi-agent.md) | chaojixinren | 2026-07-01 | integration, pi-agent, coding-agent, agent | +| [CodeBuddy Code Integration Guide](./codebuddy.md) | pei-pei45 | 2026-07-16 | integration, codebuddy, coding-agent, agent | diff --git a/docs/zh/guide/integrations/codebuddy.md b/docs/zh/guide/integrations/codebuddy.md new file mode 100644 index 000000000..68f76a2c7 --- /dev/null +++ b/docs/zh/guide/integrations/codebuddy.md @@ -0,0 +1,340 @@ +--- +title: CodeBuddy Code 集成指南 +author: pei-pei45 +date: 2026-07-16 +tags: + - integration + - codebuddy + - coding-agent + - agent +lang: zh-CN +--- + +# CodeBuddy Code 集成指南 + +[English](../../guide/integrations/codebuddy.md) + +在 CubeSandbox MicroVM 内运行 [腾讯云 CodeBuddy Code CLI](https://www.codebuddy.ai/docs/cli/README) +(面向终端的 AI 编码 Agent)。本文涵盖镜像构建、密钥注入、出网管控与基于快照的会话持久化,并配套可运行的 +[`examples/codebuddy-integration`](https://github.com/TencentCloud/CubeSandbox/tree/master/examples/codebuddy-integration) +示例项目。 + +## 集成对象与版本 + +| 组件 | 版本 | +|---|---| +| CodeBuddy Code | `@tencent-ai/codebuddy-code`(通过 `--build-arg CODEBUDDY_VERSION=x.y.z` 固定) | +| Node.js | 20(通过 NodeSource 安装) | +| CubeSandbox 基础镜像 | `ghcr.io/tencentcloud/cubesandbox-base:2026.16` | +| E2B SDK(宿主端驱动) | `e2b`(最新版) | +| CubeSandbox 平台 | `>= 0.3.0`(pause/resume)/ `>= 0.4.0`(CubeEgress 凭证保险柜) | + +## 前置条件 + +- 已部署 CubeSandbox,CubeAPI 可访问(`http://:3000`)。 +- `cubemastercli` 已在 `$PATH` 且已连通集群。 +- 构建机装有 Docker,且 registry 能被 Cube 集群拉取。 +- 一个 CodeBuddy Code 账号,或自定义上游 API Key。CodeBuddy Code 可对接: + 国际版平台(`CODEBUDDY_INTERNET_ENVIRONMENT=io`,默认)、国内版平台(`internal`)、 + iOA 企业版平台(`ioa`),或通过 `CODEBUDDY_BASE_URL` / `ANTHROPIC_BASE_URL` 指向任何 + Anthropic / OpenAI 兼容端点。 +- Python 3.10+(宿主端驱动脚本)。 + +## 为什么把 CodeBuddy 跑在沙箱里 + +CodeBuddy Code 是一款终端 Agent,会编辑文件、执行命令、安装软件包。直接在工作站上运行,会把 +Agent 的破坏半径和你的开发环境混在一起。把它放到 CubeSandbox 里能获得: + +| 关注点 | CubeSandbox 提供 | +|---|---| +| **隔离** | 每个会话独占一台 KVM MicroVM,独占 guest 内核 | +| **可复现** | 每个会话都从同一份模板快照启动 | +| **快速启动** | 冷启动 < 60ms,并行 N 个 Agent 几乎无成本 | +| **长任务** | `sandbox.pause()` 对 VM + rootfs 打快照,之后再恢复 | +| **密钥卫生** | CubeEgress 在链路上注入鉴权头,VM 内永远看不到真实 Key | +| **出网审计** | 每次访问 LLM API 都会记录在出网审计日志中 | + +## 接入步骤 + +### 1. 构建模板镜像 + +镜像在 `cubesandbox-base` 之上叠加 Node.js 20 与 CodeBuddy CLI;envd 已经监听 `:49983`。 + +```dockerfile +# examples/codebuddy-integration/Dockerfile(节选) +ARG CUBE_BASE_IMAGE=ghcr.io/tencentcloud/cubesandbox-base:2026.16 +FROM ${CUBE_BASE_IMAGE} + +ARG NODE_MAJOR=20 +ARG CODEBUDDY_VERSION=2.117.1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates curl git gnupg jq less procps python3 python3-pip ripgrep \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg \ + && gpg --show-keys /etc/apt/keyrings/nodesource.gpg 2>/dev/null \ + | grep -q "6F71F525282841EEDAF851B42F59B5F99B1BE0B4" \ + || (echo "ERROR: NodeSource GPG fingerprint mismatch" && exit 1) \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g --omit=dev --ignore-scripts \ + "@tencent-ai/codebuddy-code@${CODEBUDDY_VERSION}" \ + && codebuddy --version \ + && rm -rf /root/.npm + +# CodeBuddy 以非 root 用户运行。基础镜像已经用 uid=1000 作为 ``user`` exec 账号, +# 所以 UID/GID 由系统自动分配;pause/resume 快照按用户名保留身份。MicroVM 提供外层隔离, +# 这层降权是对 prompt injection 场景(LLM agent 被诱导执行 shell)的纵深防御。 +# +# /workspace 设成 world-writable,因为 e2b exec 信道把用户名限制在 ``root`` 和 ``user``, +# 而 ``user`` 写不进 codebuddy 拥有的目录。CodeBuddy 状态目录放在 +# /workspace/.codebuddy,pause/resume 时会跟项目文件一起被快照。 +RUN groupadd --system codebuddy \ + && useradd --system --gid codebuddy \ + --home-dir /home/codebuddy --shell /bin/bash \ + --no-create-home codebuddy \ + && install -d -o codebuddy -g codebuddy -m 0700 /home/codebuddy \ + && install -d -o codebuddy -g codebuddy -m 0777 /workspace + +ENV CODEBUDDY_CONFIG_DIR=/workspace/.codebuddy \ + DISABLE_TELEMETRY=1 \ + DISABLE_ERROR_REPORTING=1 \ + DISABLE_AUTOUPDATER=1 \ + DISABLE_FEEDBACK_COMMAND=1 \ + CODEBUDDY_INTERNET_ENVIRONMENT=io + +WORKDIR /workspace +USER codebuddy +``` + +构建并推送(在仓库根目录运行,确保相对构建上下文 `examples/codebuddy-integration` +能正确解析): + +```bash +docker build --pull --platform linux/amd64 \ + -t /codebuddy-cube:latest \ + examples/codebuddy-integration +docker push /codebuddy-cube:latest +``` + +### 2. 注册为 Cube 模板 + +```bash +cubemastercli tpl create-from-image \ + --image /codebuddy-cube:latest \ + --writable-layer-size 4G \ + --expose-port 49983 \ + --probe 49983 \ + --probe-path /health + +cubemastercli tpl watch --job-id +``` + +任务变为 `READY` 后记下 `template_id`,之后每次 `Sandbox.create()` 都传给它。中等任务用 `4G` +可写层就够;如果 Agent 需要安装较重的工具链,建议提升到 `8G+`。 + +### 3. 配置宿主端驱动 + +```bash +cd examples/codebuddy-integration +cp .env.example .env +# 填写 E2B_API_URL、CUBE_TEMPLATE_ID、CODEBUDDY_INTERNET_ENVIRONMENT 以及 provider key +pip install -r requirements.txt +``` + +| 变量 | 作用位置 | 说明 | +|---|---|---| +| `E2B_API_URL` | 本地进程 | CubeAPI 地址(`http://:3000`) | +| `E2B_API_KEY` | 本地进程 | 本地开发填任意非空字符串 | +| `CUBE_TEMPLATE_ID` | `Sandbox.create(template=...)` | 来自第 2 步 | +| `CODEBUDDY_INTERNET_ENVIRONMENT` | CodeBuddy CLI | `io`(默认,国际版)、`internal`(国内)、`ioa`(腾讯企业版) | +| `CODEBUDDY_MODEL` / `CODEBUDDY_BASE_URL` | CodeBuddy CLI | 模型 id 与可选的自定义上游端点 | +| `CODEBUDDY_API_KEY` / `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` / ... | `envs=...`(直连)或 CubeEgress 注入(vault) | provider 密钥 | +| `CODEBUDDY_LLM_HOST` | `network_policy.py` | 默认拒绝下放行的 LLM host | + +### 4. 运行时配置与 API Key 注入 + +CodeBuddy 以无交互模式启动:`-p` 让它处理完 prompt 即退出(不进入交互 TUI),配合 `-y` / +`--dangerously-skip-permissions`(任何会读写文件或执行命令的非交互运行都必须带,否则 CLI 会 +卡在无法在 exec 信道回答的权限弹窗上)。prompt 作为末尾的位置参数。两种密钥注入方式共用同一 +份模板: + +**直连方式** —— 每个命令注入一次 Key。`e2b` 的 `commands.run(envs=...)` 把环境放进 exec 信 +封,而不是写入 VM 内的持久文件,所以 Key 只在该命令执行期间存在。exec 信道只接受 +`root` 和 `user` 两个用户名;传 `user="codebuddy"` 会触发 `invalid username: 'codebuddy'`。 +镜像内 Agent 仍以非 root 身份运行(因为 Dockerfile 有 `USER codebuddy`),SDK 层面的 +`user` 参数只约束 exec 信道的身份,不影响容器内进程身份: + +```python +result = sandbox.commands.run( + "cd /workspace && codebuddy -p -y --model claude-sonnet-4-6 'do something'", + envs={"ANTHROPIC_API_KEY": key}, + user="user", + timeout=900, +) +``` + +**保险柜方式** —— 让 Key 完全不进入 VM(见第 6 步)。 + +### 5. 会话持久化(pause / resume) + +```bash +cd examples/codebuddy-integration +python resume_codebuddy.py +``` + +与 [快照 / 克隆 / 回滚](../snapshot-rollback-clone.md) 引擎在 SDK 层等价: + +- `sandbox.pause()` 对运行中的 VM(内存 + rootfs)打快照并释放算力。 +- `Sandbox.connect(sandbox_id)` 恢复时,`/workspace`、`/workspace/.codebuddy` 以及其它所有 + 文件都保留。第二轮再调用 `codebuddy -p -y -c`,让 CodeBuddy 自动续接 + `$CODEBUDDY_CONFIG_DIR/projects/` 下最近一次会话。 + +> **生命周期注意事项:** 用 `try/finally` 管理沙箱生命周期,不要用 `with Sandbox.create(...)` +> context manager。`__exit__` 会 `kill` 沙箱,把 pause 撤销。示例里显式创建沙箱,只在 +> `finally` 中调用 `sandbox.kill()`。 + +```python +sandbox = Sandbox.create(template=template_id, timeout=1800) +try: + run_turn(sandbox, prompt_1) # 写入 /workspace/plan.md + sandbox_id = sandbox.pause() or sandbox.sandbox_id + sandbox = Sandbox.connect(sandbox_id) + assert_state_survived(sandbox) # /workspace + /workspace/.codebuddy 完好 + run_turn(sandbox, prompt_2, continue_session=True) +finally: + sandbox.kill() +``` + +### 6. 网络与出网策略(凭证保险柜) + +在完成第 3 步配置后,在 `examples/codebuddy-integration/` 目录里跑: + +```bash +cd examples/codebuddy-integration +python network_policy.py +``` + +脚本演示了共享集群的推荐模式:默认拒绝出网 + 链路上注入 Key。 + +```python +# 凭证注入走原生 cubesandbox SDK(见 security-proxy.md)。 +from cubesandbox import Sandbox, Rule, Match, Action, Inject + +host = "api.anthropic.com" +rules = [ + Rule( + name="allow_anthropic_llm", + match=Match(scheme="https", sni=host, host=host), + action=Action(allow=True, audit="metadata", inject=[ + Inject(header="x-api-key", secret=ANTHROPIC_API_KEY, format="${SECRET}"), + Inject(header="anthropic-version", secret="2023-06-01", format="${SECRET}"), + ]), + ), +] + +sandbox = Sandbox.create( + template=CUBE_TEMPLATE_ID, + allow_internet_access=False, # 默认拒绝;规则中的 host 自动放行 + network={"rules": rules}, +) +``` + +效果: + +- 沙箱内 `printenv ANTHROPIC_API_KEY` 只能看到一个占位值。 +- 每次访问 LLM host 都会在链路上被加上鉴权头。 +- 其它目的地址在 L3/L4 由 CubeVS 直接丢弃(`allow_internet_access=False`),根本不会离开沙箱。 +- 每次放行/拒绝都会落到出网审计日志中。 + +非 Anthropic provider 用 `Authorization: Bearer` 头注入。如果某个 provider 不接受在链路上注入 +头,那就退回直连方式(`envs=...`)—— 但永远不要把 Key 写进沙箱内的持久文件。 + +## 使用场景与最佳实践 + +- **隔离开发。** 把编码 Agent 跑在沙箱里,让它的文件编辑与 shell 命令无法触碰宿主机。 +- **执行 Agent 生成的代码并回收结果。** 让 Agent 把产出写到 `/workspace`,然后用 + `sandbox.files` 或 `commands.run` 把产物读回来。 +- **长任务的断点续跑。** 用 `pause()` + `connect()` 给一次长重构打快照,之后再恢复;也 + 可以从同一份快照 fork 出多个变体。 +- **不改镜像就切换 LLM provider。** CodeBuddy 自身由 `CODEBUDDY_INTERNET_ENVIRONMENT` + + `CODEBUDDY_API_KEY` 决定上游;通过 `CODEBUDDY_BASE_URL` 指向 Anthropic / OpenAI / + DeepSeek / Gemini 等即可,模板无需重建。 +- **重型依赖预装进模板。** 默认拒绝策略下,运行时再去拉依赖会很慢,建议在镜像里把常用工具链 + 装好。 + +## 关键代码片段 + +### 无头调用 CodeBuddy + +```python +cmd = ( + "cd /workspace && codebuddy -p -y --model claude-sonnet-4-6 " + "'Inspect the project, run app.py, and summarize the result.'" +) +result = sandbox.commands.run(cmd, envs=codebuddy_env, timeout=900) +``` + +### 启动前版本检查 + +```python +version = sandbox.commands.run("codebuddy --version", timeout=60) +``` + +## 注意事项 + +- **Node.js 版本。** CodeBuddy 要求 Node 18.20+;基础镜像自带的是较老的 apt Node,请走 + NodeSource 安装(Dockerfile 已处理)。 +- **非交互模式需要预置 Key。** `codebuddy -p` 不会回落到浏览器登录流程;忘了设 + `CODEBUDDY_API_KEY`(或对应的 provider 环境变量)就会卡在认证弹窗上。`run_codebuddy.py` + 在启动沙箱前就会因为找不到 Key 而直接报错。 +- **权限模式。** `-y` 会跳过所有工具调用弹窗,这是非交互执行所必需的,因为 exec 信道无 + 法回答弹窗。在更高安全等级的场景里,请通过 `settings.json`(`permissions.defaultMode`、 + `permissions.allow` 等)收紧白名单,而不是去掉 `-y`。 +- **Agent 状态目录。** `/workspace/.codebuddy` 存放 CodeBuddy 的会话缓存(配置、历史、会话、 + 计划、文件历史)。在镜像里请保持空目录,避免租户之间的会话泄露:Dockerfile 会创建该目录, + 但不会写入任何凭据。 +- **直连方式的 Key 残留。** 直连(`envs=`)下 Key 只在该 exec 调用期间有效,但 CodeBuddy + 可能把 provider 凭据缓存在状态目录(`/workspace/.codebuddy/`)里,会跨 `pause()` / + `resume()` 存活。严格隔离场景请用保险柜方式(`network_policy.py`),让 Key 完全不进入 VM。 +- **CubeEgress 拦截 CA(Node)。** 保险柜方式要求沙箱信任 CubeEgress 根 CA,基础镜像把它装 + 进了系统 CA 包。CodeBuddy 是 Node.js 包,忽略系统 CA 库,因此 `network_policy.py` 还会 + 设 `NODE_EXTRA_CA_CERTS`(可用 `CODEBUDDY_NODE_EXTRA_CA_CERTS` 覆盖)—— 否则 vault + 路径会以 `unable to verify the first certificate` 失败。 +- **出网副作用。** `npm install`、拉 MCP 工具等任务需要把这些 host 加进放行规则,或预装 + 进模板。 +- **交互式 TTY 特性。** CodeBuddy 的交互 TUI 走不了 E2B 协议。请用 `-p -y` 走无头模式, + 多轮对话由宿主端脚本驱动(`-c` / `--resume` 续接,`--session-id` 钉住会话 id)。 + +## 排错 + +| 现象 | 可能原因 | 处理 | +|---|---|---| +| preflight 报 `codebuddy: command not found` | CLI 变更后未重建模板 | 重建镜像并重新注册模板 | +| 启动时弹出登录浏览器界面卡住 | `-p` 模式要求预置 API Key,不会回落到交互登录 | 启动前设置 `CODEBUDDY_API_KEY`(或对应的 provider 环境变量) | +| 权限弹窗卡住整个 run | 忘了在会读写/执行命令的运行上加 `-y` / `--dangerously-skip-permissions` | 加 `-y`,或在 `settings.json` 里收紧 permissions | +| provider 鉴权失败 | 密钥未传入(直连)或缺少 inject 规则(vault) | 传 `envs={...}` 或修正规则的 `sni`/`host` | +| `403 Forbidden - CubeEgress` | 默认拒绝且无匹配放行规则 | 把 LLM host(及所需其他 host)加入规则 | +| vault 路径下 CodeBuddy 报 `Connection error` / TLS 失败 | CodeBuddy 是 Node.js 包,忽略系统 CA 库,不信任 CubeEgress 拦截 CA | 脚本已把 `NODE_EXTRA_CA_CERTS` 指向系统 CA 包;若 CA 在别处,用 `CODEBUDDY_NODE_EXTRA_CA_CERTS` 覆盖 | +| 模板创建卡在 `PULLING` | registry 无法被 Cube 节点访问 | 推送到集群可达的 registry,或传入鉴权参数 | +| 就绪探针超时 | 镜像缺少 envd | 确认 `FROM ghcr.io/tencentcloud/cubesandbox-base:2026.16` | +| `pause()` / `connect()` 报错 | 平台版本过低不支持快照 | 升级 CubeSandbox 平台 | + +## 参考资料 + +- 可运行示例:[`examples/codebuddy-integration`](https://github.com/TencentCloud/CubeSandbox/tree/master/examples/codebuddy-integration) +- 引入自有镜像:[`docs/zh/guide/tutorials/bring-your-own-image.md`](../tutorials/bring-your-own-image.md) +- 从镜像创建模板:[`docs/zh/guide/tutorials/template-from-image.md`](../tutorials/template-from-image.md) +- 快照 / 克隆 / 回滚:[`docs/zh/guide/snapshot-rollback-clone.md`](../snapshot-rollback-clone.md) +- 凭证保险柜 + 出网管控:[`docs/zh/guide/security-proxy.md`](../security-proxy.md) +- CodeBuddy Code CLI: +- CodeBuddy Code 安装: +- CodeBuddy Code 环境变量: +- CodeBuddy Code 目录结构(`~/.codebuddy`): diff --git a/docs/zh/guide/integrations/index.md b/docs/zh/guide/integrations/index.md index c96579ce6..7be6cfcd5 100644 --- a/docs/zh/guide/integrations/index.md +++ b/docs/zh/guide/integrations/index.md @@ -48,3 +48,4 @@ lang: zh-CN | 标题 | 作者 | 日期 | 标签 | | --- | --- | --- | --- | | [Pi Agent 集成指南](./pi-agent.md) | chaojixinren | 2026-07-01 | integration, pi-agent, coding-agent, agent | +| [CodeBuddy Code 集成指南](./codebuddy.md) | pei-pei45 | 2026-07-16 | integration, codebuddy, coding-agent, agent | diff --git a/examples/codebuddy-integration/.env.example b/examples/codebuddy-integration/.env.example new file mode 100644 index 000000000..56bcf65c4 --- /dev/null +++ b/examples/codebuddy-integration/.env.example @@ -0,0 +1,51 @@ +# --- CubeSandbox connection --- + +# Required: CubeAPI address (use CubeAPI, not CubeProxy). +# CUBE_API_URL is canonical; E2B_API_URL is accepted as a legacy alias. +CUBE_API_URL="http://:3000" + +# Required: any non-empty value in local dev; a real key when auth is enabled. +# CUBE_API_KEY is canonical; E2B_API_KEY is accepted as a legacy alias. +CUBE_API_KEY="e2b_000000" + +# Required: template built from this example's Dockerfile. +CUBE_TEMPLATE_ID="" + +# Optional: only when talking to CubeProxy over HTTPS with Cube's mkcert cert. +# SSL_CERT_FILE="/root/.local/share/mkcert/rootCA.pem" + +# --- CodeBuddy agent --- + +# Required: which CodeBuddy site CodeBuddy should authenticate against. +# io — International site (default; codebuddy.ai) +# internal — China site (copilot.tencent.com) +# ioa — Tencent enterprise / iOA +CODEBUDDY_INTERNET_ENVIRONMENT="io" + +# Required: model id CodeBuddy runs against. Provider default is only set for +# Anthropic; for other providers set this explicitly (model IDs are +# provider-specific and change often). +# CODEBUDDY_MODEL="claude-sonnet-4-6" + +# Required: the active provider's API key (only the one matching CODEBUDDY_INTERNET_ENVIRONMENT +# / CODEBUDDY_BASE_URL). For the international CodeBuddy site use CODEBUDDY_API_KEY; +# for a custom upstream (Anthropic / OpenAI / DeepSeek / ...) use the matching key. +# CODEBUDDY_API_KEY="" +# ANTHROPIC_API_KEY="" +# OPENAI_API_KEY="" +# DEEPSEEK_API_KEY="" +# GEMINI_API_KEY="" + +# Optional: override the upstream endpoint (e.g. Anthropic-compatible gateways). +# CODEBUDDY_BASE_URL="https://api.anthropic.com" +# ANTHROPIC_BASE_URL="https://api.deepseek.com/anthropic" +# ANTHROPIC_MODEL="deepseek-v4-pro" + +# Optional (network_policy.py): LLM host to allow under default-deny egress. +# Defaults to the host parsed from CODEBUDDY_BASE_URL / ANTHROPIC_BASE_URL, or +# the provider default; override only for a custom endpoint. +# CODEBUDDY_LLM_HOST="api.anthropic.com" + +# Advanced CodeBuddy tunables (CODEBUDDY_WORKSPACE, CODEBUDDY_CONFIG_DIR, +# CODEBUDDY_AGENT_EXEC_TIMEOUT, CODEBUDDY_NODE_EXTRA_CA_CERTS, ...) have sane +# defaults; see env_utils.py to override. diff --git a/examples/codebuddy-integration/.gitignore b/examples/codebuddy-integration/.gitignore new file mode 100644 index 000000000..5fe0978a2 --- /dev/null +++ b/examples/codebuddy-integration/.gitignore @@ -0,0 +1,8 @@ +.env +.env.* +!.env.example +__pycache__/ +*.pyc +*.log +output/ +workspace-seed/ diff --git a/examples/codebuddy-integration/Dockerfile b/examples/codebuddy-integration/Dockerfile new file mode 100644 index 000000000..5634abc30 --- /dev/null +++ b/examples/codebuddy-integration/Dockerfile @@ -0,0 +1,152 @@ +# syntax=docker/dockerfile:1.7 +# +# Tencent CodeBuddy Code inside a CubeSandbox template. +# +# The image installs Node.js 20 (CodeBuddy's bundled toolchain expects Node 18+, +# 20 is the long-lived line that matches what CodeBuddy currently tests against) +# and the CodeBuddy CLI on top of the official CubeSandbox base image. The +# inherited cube-entrypoint keeps envd running on :49983 so Cube can use the +# standard readiness probe. +# +# Build: +# docker build --pull -t codebuddy-cube:latest examples/codebuddy-integration +# +# Register as a Cube template: +# cubemastercli tpl create-from-image \ +# --image /codebuddy-cube:latest \ +# --writable-layer-size 4G \ +# --expose-port 49983 \ +# --probe 49983 \ +# --probe-path /health + +ARG CUBE_BASE_IMAGE=ghcr.io/tencentcloud/cubesandbox-base:2026.16 +FROM ${CUBE_BASE_IMAGE} + +ARG DEBIAN_FRONTEND=noninteractive +ARG NODE_MAJOR=20 +ARG CODEBUDDY_VERSION=2.117.1 + +# CodeBuddy stores configuration, history, sessions, plans and tool output under +# ~/.codebuddy. We relocate it to /workspace/.codebuddy (rather than a +# $HOME-relative path) for two reasons: +# +# 1. The e2b exec channel constrains us to a fixed allow-list of usernames +# (``root``, ``user``); an Image-level USER directive that creates a +# ``codebuddy`` account does not propagate to the exec user. CODEBUDDY's +# default $HOME-relative path therefore needs to be world-writable for the +# SDK-allowed exec user to write into it, which weakens the privilege drop. +# 2. Keeping state under /workspace means the snapshot captures the project +# tree and the agent's session cache together, which is the natural unit +# for pause/resume — operators get "what was the agent doing" alongside +# "what files did it leave behind" without reaching into a hidden home dir. +ARG CODEBUDDY_HOME=/workspace/.codebuddy + +# NodeSource signing key (primary fingerprint, not the subkey). Update by +# running: curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ +# | gpg --show-keys --with-colons | grep ^fpr +# Verified against https://github.com/nodesource/distributions on 2026-07-16. +ARG NODESOURCE_GPG_FPR=6F71F525282841EEDAF851B42F59B5F99B1BE0B4 + +# Layer 1 — system packages + NodeSource repo. Splitting this off from the +# CodeBuddy install layer keeps the slow apt + NodeSource GPG step cacheable +# across CodeBuddy version bumps. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + gnupg \ + jq \ + less \ + procps \ + python3 \ + python3-pip \ + ripgrep \ + && mkdir -p /etc/apt/keyrings \ + && curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \ + | gpg --dearmor --yes -o /etc/apt/keyrings/nodesource.gpg \ + && gpg --show-keys /etc/apt/keyrings/nodesource.gpg 2>/dev/null \ + | grep -q "${NODESOURCE_GPG_FPR}" \ + || (echo "ERROR: NodeSource GPG fingerprint mismatch" >&2; exit 1) \ + && echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_${NODE_MAJOR}.x nodistro main" \ + > /etc/apt/sources.list.d/nodesource.list \ + && apt-get update \ + && apt-get install -y --no-install-recommends nodejs \ + && npm --version \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Layer 2 — CodeBuddy Code. Skips dev deps (50-200 MB of tooling the headless +# CLI never loads) and post-install scripts (no network access during build). +# --ignore-scripts avoids post-install hooks that may try to phone home. +RUN npm install -g --omit=dev --ignore-scripts \ + "@tencent-ai/codebuddy-code@${CODEBUDDY_VERSION}" \ + && codebuddy --version \ + && rm -rf /root/.npm + +# Create an unprivileged user that owns the workspace. CodeBuddy is an +# autonomous code-generation agent with Bash, Write, and Edit tool access — +# running it as root turns any prompt-injection or CLI bug into a root-level +# compromise inside the sandbox. The MicroVM still provides the outer +# isolation, but defense in depth matters for long-running sessions. +# +# UID/GID are auto-assigned because the base image already owns uid=1000 as +# the ``user`` exec account; pause/resume preserves identity by username, not +# by numeric id. +# +# /workspace is world-writable because the e2b exec channel constrains us to +# the SDK's allowed-users list (``root``, ``user``) and ``user`` cannot write +# to a codebuddy-owned directory. The MicroVM provides the isolation; the +# world write is a single-purpose concession to the SDK's allowed users. The +# home directory under /home/codebuddy is kept at 0700. +RUN groupadd --system codebuddy \ + && useradd --system --gid codebuddy \ + --home-dir /home/codebuddy --shell /bin/bash \ + --no-create-home codebuddy \ + && install -d -o codebuddy -g codebuddy -m 0700 /home/codebuddy \ + && install -d -o codebuddy -g codebuddy -m 0777 /workspace + +# Pin the configuration directory so the path is stable across pause/resume +# snapshots and so the empty-state below is created at build time (no leftover +# tenant state ever leaks into the base template). +ENV CODEBUDDY_CONFIG_DIR=${CODEBUDDY_HOME} \ + DISABLE_TELEMETRY=1 \ + DISABLE_ERROR_REPORTING=1 \ + DISABLE_AUTOUPDATER=1 \ + DISABLE_FEEDBACK_COMMAND=1 \ + NPM_CONFIG_UPDATE_NOTIFIER=false \ + CODEBUDDY_INTERNET_ENVIRONMENT=io + +# Pre-create the workspace and the CodeBuddy state tree so the first exec call +# does not race the directory creation. settings.json ships with a minimal +# safe-by-default profile: no telemetry, autoupdater off, prompt suggestion off +# (we run headless), the maximum bash budget from Claude Code to match operator +# expectations, and no inline plugin/hooks so a paused snapshot cannot replay a +# tenant-specific hook chain. +RUN install -d -o codebuddy -g codebuddy -m 0777 "${CODEBUDDY_HOME}" \ + && printf '%s\n' \ + '{' \ + ' "telemetry": { "enabled": false },' \ + ' "autoUpdater": false,' \ + ' "promptSuggestionEnabled": false,' \ + ' "permissions": { "defaultMode": "bypassPermissions" },' \ + ' "env": {' \ + ' "DISABLE_TELEMETRY": "1",' \ + ' "DISABLE_ERROR_REPORTING": "1",' \ + ' "DISABLE_AUTOUPDATER": "1",' \ + ' "DISABLE_FEEDBACK_COMMAND": "1",' \ + ' "CODEBUDDY_INTERNET_ENVIRONMENT": "io"' \ + ' }' \ + '}' \ + > "${CODEBUDDY_HOME}/settings.json" + +WORKDIR /workspace + +# Drop root. CodeBuddy and any subsequent shell commands run as codebuddy. +# If cube-entrypoint needs root (e.g. to bind :49983), the inherited image +# already runs the entrypoint before this USER takes effect — confirm against +# the base image's entrypoint before changing this. +USER codebuddy + +EXPOSE 49983 diff --git a/examples/codebuddy-integration/README.md b/examples/codebuddy-integration/README.md new file mode 100644 index 000000000..801366e9d --- /dev/null +++ b/examples/codebuddy-integration/README.md @@ -0,0 +1,296 @@ +# CodeBuddy + CubeSandbox Example + +[中文文档](README_zh.md) + +Run the [Tencent CodeBuddy Code CLI](https://www.codebuddy.ai/docs/cli/README) +— a terminal-native AI coding agent — inside a CubeSandbox MicroVM. The agent +edits files, runs commands, and reaches an LLM API entirely within an isolated, +reproducible sandbox. + +This example ships: + +- A `Dockerfile` that stacks Node.js 20 + the CodeBuddy CLI on top of the + CubeSandbox base image (envd already listens on `:49983`). +- `run_codebuddy.py` — a headless one-shot run inside `/workspace`. +- `resume_codebuddy.py` — pause/resume across two turns, proving `/workspace` + and CodeBuddy's state directory (`/workspace/.codebuddy`) survive the snapshot. +- `network_policy.py` — a default-deny egress policy where CubeEgress injects + the API key on the wire, so the key never enters the VM. +- `sandbox_exec.py` — a host-side CLI executor that lets you run arbitrary + Python or shell code inside a disposable MicroVM (`--code`, `--file`, + `--cmd`, `--pip`). Reuses a cached sandbox across invocations via a + UID-scoped session file under `/tmp`. +- `mcp_server.py` — exposes the same execution backend as an MCP server + (JSON-RPC over stdio) so any MCP client (Claude Desktop, Cursor, …) can + sandbox its code through CodeBuddy's toolchain. +- `hooks/` — a CodeBuddy JavaScript plugin (`cubesandbox-sandbox.js`) that + routes the in-agent `bash` tool through the host-side executor, plus a + shell installer (`install.sh`) that drops the plugin into + `~/.config/codebuddy/plugins/`. +- `env_utils.py`, `_codebuddy_common.py`, `.env.example`, `requirements.txt`, + `tests/`. + +## Directory layout + +``` +codebuddy-integration/ +├── Dockerfile # CubeSandbox template image (Node.js + CodeBuddy CLI) +├── .env.example # Copy to .env and fill in +├── .gitignore +├── requirements.txt # Host driver deps (e2b, cubesandbox, python-dotenv) +├── env_utils.py # .env loading, provider keys, CodeBuddy command builder +├── _codebuddy_common.py # Shared sandbox command helpers (run/ensure/id) +├── run_codebuddy.py # One-shot CodeBuddy task +├── resume_codebuddy.py # Pause / resume session persistence +├── network_policy.py # Default-deny egress + on-the-wire key injection +├── sandbox_exec.py # Host-side CLI: --code / --file / --cmd / --pip +├── mcp_server.py # JSON-RPC stdio MCP server with 5 sandbox tools +├── hooks/ +│ ├── install.sh # Copy plugin + sanitized config into ~/.config/codebuddy +│ └── cubesandbox-sandbox.js # tool.execute.before plugin that forwards bash to a sandbox +├── tests/ # pytest suite (fully offline, SDK mocked) +│ ├── test_sandbox_exec.py +│ ├── test_mcp_server.py +│ └── test_codebuddy_common.py +├── README.md # English docs (this file) +└── README_zh.md # Chinese docs +``` + +## Prerequisites + +- A running CubeSandbox deployment; CubeAPI reachable at `http://:3000`. +- `cubemastercli` on `$PATH`, connected to the cluster. +- Docker on the build workstation, plus a registry the Cube nodes can pull from. +- A CodeBuddy Code account (or a custom upstream API key — Anthropic, OpenAI, + DeepSeek, Google Gemini, ...). See `.env.example`. +- Python 3.10+ for the host driver scripts. + +## 1. Build the template image + +```bash +docker build --pull --platform linux/amd64 \ + -t /codebuddy-cube:latest \ + examples/codebuddy-integration +docker push /codebuddy-cube:latest +``` + +The image installs `@tencent-ai/codebuddy-code` plus `git`, `python3`, +`ripgrep`, `jq`, and cleans apt/npm caches. The CodeBuddy version is pinned +via `--build-arg CODEBUDDY_VERSION=x.y.z`. + +## 2. Register as a Cube template + +```bash +cubemastercli tpl create-from-image \ + --image /codebuddy-cube:latest \ + --writable-layer-size 4G \ + --expose-port 49983 \ + --probe 49983 \ + --probe-path /health + +cubemastercli tpl watch --job-id +``` + +Note the `template_id` once the job reaches `READY`. + +## 3. Configure the host driver + +```bash +cd examples/codebuddy-integration +cp .env.example .env +# fill in CUBE_API_URL, CUBE_TEMPLATE_ID, CODEBUDDY_INTERNET_ENVIRONMENT, and your key +pip install -r requirements.txt +``` + +| Variable | Where it flows | Notes | +|---|---|---| +| `CUBE_API_URL` | Local process | CubeAPI address (`http://:3000`) | +| `CUBE_API_KEY` | Local process | Any non-empty string in local dev | +| `CUBE_TEMPLATE_ID` | `Sandbox.create(template=...)` | From step 2 | +| `CODEBUDDY_INTERNET_ENVIRONMENT` | CodeBuddy CLI | `io` (default, international), `internal` (China), `ioa` (Tencent enterprise) | +| `CODEBUDDY_MODEL` | CodeBuddy CLI | Model id for the active provider | +| `CODEBUDDY_API_KEY` / `ANTHROPIC_API_KEY` / ... | `envs=...` (direct) or CubeEgress inject (vault) | Provider key | +| `CODEBUDDY_BASE_URL` / `ANTHROPIC_BASE_URL` | CodeBuddy CLI | Custom upstream endpoint (e.g. DeepSeek via Anthropic-compatible gateway) | +| `CODEBUDDY_LLM_HOST` | `network_policy.py` | LLM API host to allow; defaults to the parsed `*_BASE_URL` host or the provider default | + +## 4. One-shot run (direct key flavor) + +```bash +python run_codebuddy.py --prompt "Create hello.py that prints 'Hello from CubeSandbox' and run it." +``` + +CodeBuddy is invoked headlessly with `-p` (process the prompt and exit, no TUI) +plus `-y` (`--dangerously-skip-permissions`, required for any non-interactive +run that touches files or runs commands — without it the CLI blocks on a +permission prompt that cannot be answered over the exec channel). The provider +key is forwarded per-command via `sandbox.commands.run(..., envs=...)`, so it +lives only for the lifetime of that exec call — never written to a persistent +file inside the VM. + +> **Security:** this direct flavor leaves egress open, so a compromised agent +> could exfiltrate the injected key. For shared clusters use the vault flavor +> (step 6): default-deny egress + on-the-wire key injection. + +## 5. Pause / resume (session persistence) + +```bash +python resume_codebuddy.py +``` + +Turn 1 asks CodeBuddy to write `/workspace/plan.md`, then `sandbox.pause()` +snapshots the VM. The script reconnects with `Sandbox.connect(sandbox_id)`, +verifies `/workspace/plan.md` and CodeBuddy's state directory +(`/workspace/.codebuddy/projects/...`) survived, then runs turn 2 with +`-c` to continue the most recent session. The sandbox lifecycle is managed +manually with `try/finally` (not a context manager), so the pause is not +undone by an early `kill`. + +## 6. Restricted egress + key vault (recommended for shared clusters) + +```bash +python network_policy.py +``` + +- Egress is default-deny — only the LLM host (`CODEBUDDY_LLM_HOST`) is reachable. +- CubeEgress attaches the provider key as an HTTP header on the wire + (`x-api-key` for Anthropic, `Authorization: Bearer` otherwise), so + `printenv` inside the sandbox never shows the real key — it only sees a + placeholder. +- Because CodeBuddy ships as a Node.js bundle that ignores the system CA + store, the script sets `NODE_EXTRA_CA_CERTS` so CodeBuddy trusts the + CubeEgress interception CA; without it the vault path fails with + "unable to verify the first certificate". Override the bundle path via + `CODEBUDDY_NODE_EXTRA_CA_CERTS` if your image keeps the CA elsewhere. +- Any other destination returns `403 Forbidden - CubeEgress`. + +If the agent needs extra hosts (package registries, MCP servers), add matching +allow rules or preinstall those dependencies into the template. + +## 7. Host-side executor (`sandbox_exec.py`) + +For tasks that need to run untrusted code but do not need the CodeBuddy +agent itself, the `sandbox_exec.py` CLI runs code directly inside a +disposable MicroVM. The host stays clean; the sandbox is destroyed (or +its session cached) at the end of each call. + +```bash +python sandbox_exec.py --code "print(1+1)" +python sandbox_exec.py --file ./script.py +python sandbox_exec.py --cmd "ls -la /workspace" +python sandbox_exec.py --pip requests --code "import requests; print(requests.__version__)" + +# Reuse the same sandbox on the next call instead of cold-starting +python sandbox_exec.py --keep-alive --code "state = 42" +python sandbox_exec.py --cmd "echo state still alive" + +# Force a fresh sandbox +python sandbox_exec.py --reset +``` + +The cross-process cache uses a UID-scoped session file under +`/tmp/cubesandbox_codebuddy_session_` (`O_NOFOLLOW` + `0600` + +`symlink → S_ISREG` check) so a different user on a shared host cannot +hijack your `Sandbox.connect()` on the next call. + +## 8. MCP server (`mcp_server.py`) + +The same execution backend is also exposed as a newline-delimited +JSON-RPC MCP server so any MCP client (Claude Desktop, Cursor, +Windsurf, VS Code, …) can run untrusted code in the CodeBuddy +sandbox instead of locally. Five tools are exposed: + +| Tool | Purpose | +| --- | --- | +| `sandbox_run_code` | Run a Python snippet in the sandbox | +| `sandbox_run_command` | Run an arbitrary shell command in the sandbox | +| `sandbox_write_file` | Write a file into the sandbox | +| `sandbox_read_file` | Read a file back from the sandbox | +| `sandbox_reset` | Destroy the cached sandbox and start over | + +Wire it into an MCP client (Claude Desktop example shown): + +```json +{ + "mcpServers": { + "cubesandbox-codebuddy": { + "command": "python3", + "args": ["/abs/path/to/examples/codebuddy-integration/mcp_server.py"], + "env": { + "CUBE_API_URL": "http://:3000", + "CUBE_API_KEY": "", + "CUBE_TEMPLATE_ID": "" + } + } + } +} +``` + +The server lifecycle is process-global: a single sandbox is created on +first use, its TTL is refreshed on every tool call, and an `atexit` +handler kills it when the MCP process exits. `sandbox_reset` is the +only way to force a fresh one mid-session. + +## 9. Host CodeBuddy + bash-routing plugin (`hooks/`) + +For the opposite workflow — keep CodeBuddy on the host but route every +`bash` tool call through CubeSandbox — install the JavaScript plugin +in `hooks/`: + +```bash +cd examples/codebuddy-integration +pip install -r requirements.txt +cp .env.example .env +# Fill in CUBE_API_URL and CUBE_TEMPLATE_ID + +cd hooks +./install.sh +``` + +The installer copies `cubesandbox-sandbox.js` into +`~/.config/codebuddy/plugins/`, drops a sibling `package.json` declaring +`"type": "module"`, and merges only an allow-listed subset of +`CUBE_*` settings into the CodeBuddy config. LLM provider API keys are +never copied. + +Restart CodeBuddy after install. The plugin's +`tool.execute.before` hook intercepts `bash` calls, spawns the host +`python3 sandbox_exec.py --cmd ` against the cached session +sandbox, and replaces the host-shell command with the sandbox output. +Other tools still run on the host as usual — the plugin only intercepts `bash`. + +Uninstall with `hooks/install.sh --uninstall`. + +## Running the tests + +```bash +cd examples/codebuddy-integration +pip install pytest +pytest tests -v +``` + +The suite is fully offline: no CubeSandbox cluster or LLM credentials +are needed. `sandbox_exec` and `mcp_server` are exercised via +`unittest.mock` against the e2b SDK; the helpers are exercised +directly so test order cannot leak state. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `codebuddy: command not found` in preflight | Template not rebuilt after CLI change | Rebuild the image, re-register the template | +| Permission prompt hangs the run | Forgot `-y` / `--dangerously-skip-permissions` on a run that touches files or commands | The default invocation already passes `-y`; use `settings.json` permissions if you want a safer default | +| Provider auth failure | Key not forwarded (direct) or missing inject rule (vault) | Pass `envs={...}` or fix the rule's `sni`/`host` | +| `403 Forbidden - CubeEgress` | Default-deny with no matching allow rule | Add the LLM host (and any extra hosts) to the rules | +| `Connection error` / TLS failure from CodeBuddy (vault) | CodeBuddy is a Node.js bundle that ignores the system CA store, so it won't trust the CubeEgress interception CA | The script sets `NODE_EXTRA_CA_CERTS` to the system bundle; override with `CODEBUDDY_NODE_EXTRA_CA_CERTS` if your CA lives elsewhere | +| Template creation stuck in `PULLING` | Registry unreachable from Cube nodes | Push to a registry the cluster can reach; supply auth if needed | +| Readiness probe timeout | Base image without envd | Ensure `FROM ghcr.io/tencentcloud/cubesandbox-base:2026.16` | +| `pause()` / `connect()` errors | Platform too old for snapshots | Upgrade the CubeSandbox platform | +| Login browser popup blocks the run | Default mode is interactive; `-p` requires a pre-set API key | Set `CODEBUDDY_API_KEY` (or the matching provider env) — the non-interactive mode never falls back to a browser flow | + +## References + +- Integration guide: [`docs/guide/integrations/codebuddy.md`](../../docs/guide/integrations/codebuddy.md) +- Snapshot / Clone / Rollback: [`docs/guide/snapshot-rollback-clone.md`](../../docs/guide/snapshot-rollback-clone.md) +- Network / egress policy examples: [`examples/network-policy`](../network-policy) +- Credential vault + egress control: [`docs/guide/security-proxy.md`](../../docs/guide/security-proxy.md) +- CodeBuddy Code CLI: diff --git a/examples/codebuddy-integration/README_zh.md b/examples/codebuddy-integration/README_zh.md new file mode 100644 index 000000000..65d719887 --- /dev/null +++ b/examples/codebuddy-integration/README_zh.md @@ -0,0 +1,151 @@ +# CodeBuddy + CubeSandbox 示例 + +[English](README.md) + +在 CubeSandbox MicroVM 内运行 [腾讯云 CodeBuddy Code CLI](https://www.codebuddy.ai/docs/cli/README) +(面向终端的 AI 编码 Agent)。Agent 在一个隔离、可复现的沙箱内编辑文件、执行命令并访问 LLM API。 + +本示例包含: + +- 一个 `Dockerfile`:在 CubeSandbox 基础镜像上叠加 Node.js 20 与 CodeBuddy CLI(envd 已监听 `:49983`)。 +- `run_codebuddy.py`:在 `/workspace` 内的一次性无交互运行。 +- `resume_codebuddy.py`:跨两轮的 pause/resume,证明 `/workspace` 与 CodeBuddy 状态目录(`/workspace/.codebuddy`)在快照后仍存在。 +- `network_policy.py`:默认拒绝出网的策略,由 CubeEgress 在链路上注入 API Key,密钥不进入 VM。 +- `env_utils.py`、`_codebuddy_common.py`、`.env.example`、`requirements.txt`。 + +## 目录结构 + +``` +codebuddy-integration/ +├── Dockerfile # CubeSandbox 模板镜像(Node.js + CodeBuddy CLI) +├── .env.example # 复制为 .env 并填写 +├── .gitignore +├── requirements.txt # 宿主端驱动依赖(e2b、cubesandbox、python-dotenv) +├── env_utils.py # .env 加载、provider key、CodeBuddy 命令构造 +├── _codebuddy_common.py # 共享的沙箱命令辅助(run/ensure/id) +├── run_codebuddy.py # 一次性 CodeBuddy 任务 +├── resume_codebuddy.py # pause / resume 会话持久化 +├── network_policy.py # 默认拒绝出网 + 链路上注入密钥 +├── README.md # 英文文档 +└── README_zh.md # 中文文档(本文件) +``` + +## 前置条件 + +- 已部署 CubeSandbox,CubeAPI 可访问(`http://:3000`)。 +- `cubemastercli` 已在 `$PATH` 且已连通集群。 +- 构建机装有 Docker,且 registry 能被 Cube 集群拉取。 +- 一个 CodeBuddy Code 账号(或自定义上游 API Key — Anthropic、OpenAI、DeepSeek、Google Gemini 等)。见 `.env.example`。 +- Python 3.10+(宿主端驱动脚本)。 + +## 1. 构建模板镜像 + +```bash +docker build --pull --platform linux/amd64 \ + -t /codebuddy-cube:latest \ + examples/codebuddy-integration +docker push /codebuddy-cube:latest +``` + +镜像会安装 `@tencent-ai/codebuddy-code`,以及 `git`、`python3`、`ripgrep`、`jq`,并清理 +apt/npm 缓存。CodeBuddy 版本通过 `--build-arg CODEBUDDY_VERSION=x.y.z` 固定。 + +## 2. 注册为 Cube 模板 + +```bash +cubemastercli tpl create-from-image \ + --image /codebuddy-cube:latest \ + --writable-layer-size 4G \ + --expose-port 49983 \ + --probe 49983 \ + --probe-path /health + +cubemastercli tpl watch --job-id +``` + +任务变为 `READY` 后记下 `template_id`。 + +## 3. 配置宿主端驱动 + +```bash +cd examples/codebuddy-integration +cp .env.example .env +# 填写 CUBE_API_URL、CUBE_TEMPLATE_ID、CODEBUDDY_INTERNET_ENVIRONMENT 以及你的密钥 +pip install -r requirements.txt +``` + +| 变量 | 作用位置 | 说明 | +|---|---|---| +| `CUBE_API_URL` | 本地进程 | CubeAPI 地址(`http://:3000`) | +| `CUBE_API_KEY` | 本地进程 | 本地开发填任意非空字符串 | +| `CUBE_TEMPLATE_ID` | `Sandbox.create(template=...)` | 来自第 2 步 | +| `CODEBUDDY_INTERNET_ENVIRONMENT` | CodeBuddy CLI | `io`(默认,国际版)、`internal`(国内)、`ioa`(腾讯企业版) | +| `CODEBUDDY_MODEL` | CodeBuddy CLI | 对应 provider 的模型 id | +| `CODEBUDDY_API_KEY` / `ANTHROPIC_API_KEY` / ... | `envs=...`(直连)或 CubeEgress 注入(vault) | provider 密钥 | +| `CODEBUDDY_BASE_URL` / `ANTHROPIC_BASE_URL` | CodeBuddy CLI | 自定义上游端点(如通过 Anthropic 兼容网关接 DeepSeek) | +| `CODEBUDDY_LLM_HOST` | `network_policy.py` | 放行的 LLM API host,默认从 `*_BASE_URL` 解析或取 provider 默认 | + +## 4. 一次性运行(直连注入密钥) + +```bash +python run_codebuddy.py --prompt "创建 hello.py 打印 'Hello from CubeSandbox' 并运行它。" +``` + +CodeBuddy 以无交互模式启动:`-p` 让它处理完 prompt 即退出(不进入交互 TUI),配合 `-y` +(`--dangerously-skip-permissions`,任何会读写文件或执行命令的非交互运行都必须带,否则 +CLI 会卡在无法在 exec 信道回答的权限弹窗上)。密钥通过 +`sandbox.commands.run(..., envs=...)` 逐命令传入,只在该命令执行期间存在,不会写入 VM 内的 +持久文件。 + +> **安全:** 直连方式出网是放开的,Agent 被攻破可能外泄注入的密钥。共享集群请用保险柜方式 +> (第 6 步):默认拒绝出网 + 链路上注入密钥。 + +## 5. pause / resume(会话持久化) + +```bash +python resume_codebuddy.py +``` + +第一轮让 CodeBuddy 写 `/workspace/plan.md`,随后 `sandbox.pause()` 对 VM 打快照。脚本用 +`Sandbox.connect(sandbox_id)` 恢复,校验 `/workspace/plan.md` 与 CodeBuddy 状态目录 +(`/workspace/.codebuddy/projects/...`)仍在,再用 `-c` 续接最近一次会话执行第二轮。沙箱生命周期 +用 `try/finally` 手动管理(不用 context manager),避免 pause 后被过早 `kill` 掉。 + +## 6. 受限出网 + 密钥保险柜(推荐用于共享集群) + +```bash +python network_policy.py +``` + +- 出网默认拒绝,仅放行 LLM host(`CODEBUDDY_LLM_HOST`)。 +- CubeEgress 在链路上把 provider 密钥作为 HTTP 头注入(Anthropic 用 `x-api-key`,其他用 + `Authorization: Bearer`),因此沙箱内 `printenv` 看不到真实密钥,只有占位值。 +- CodeBuddy 是 Node.js 包,忽略系统 CA 库。脚本会设置 `NODE_EXTRA_CA_CERTS` 让 CodeBuddy + 信任 CubeEgress 的拦截 CA;否则 vault 路径会以 `unable to verify the first certificate` + 失败。若镜像里 CA 路径不同,可用 `CODEBUDDY_NODE_EXTRA_CA_CERTS` 覆盖。 +- 任何其他目的地都会返回 `403 Forbidden - CubeEgress`。 + +若 Agent 需要访问额外主机(包镜像源、MCP 服务器等),请增加对应的放行规则,或把这些依赖预 +装进模板。 + +## 排错 + +| 现象 | 可能原因 | 处理 | +|---|---|---| +| preflight 报 `codebuddy: command not found` | CLI 变更后未重建模板 | 重建镜像并重新注册模板 | +| 权限弹窗卡住整个 run | 忘了在会读写/执行命令的运行上加 `-y` / `--dangerously-skip-permissions` | 默认调用已带 `-y`;若需要更严格默认,请在 `settings.json` 里配置 permissions | +| provider 鉴权失败 | 密钥未传入(直连)或缺少 inject 规则(vault) | 传 `envs={...}` 或修正规则的 `sni`/`host` | +| `403 Forbidden - CubeEgress` | 默认拒绝且无匹配放行规则 | 把 LLM host(及所需其他 host)加入规则 | +| vault 路径下 CodeBuddy 报 `Connection error` / TLS 失败 | CodeBuddy 是 Node.js 包,忽略系统 CA 库,不信任 CubeEgress 拦截 CA | 脚本已把 `NODE_EXTRA_CA_CERTS` 指向系统 CA 包;若 CA 在别处,用 `CODEBUDDY_NODE_EXTRA_CA_CERTS` 覆盖 | +| 模板创建卡在 `PULLING` | registry 无法被 Cube 节点访问 | 推送到集群可达的 registry,或传入鉴权参数 | +| 就绪探针超时 | 镜像缺少 envd | 确认 `FROM ghcr.io/tencentcloud/cubesandbox-base:2026.16` | +| `pause()` / `connect()` 报错 | 平台版本过低不支持快照 | 升级 CubeSandbox 平台 | +| 启动时弹出登录浏览器界面卡住 | 默认模式为交互式;`-p` 要求预设 API Key | 设置 `CODEBUDDY_API_KEY`(或对应的 provider 环境变量)—— 非交互模式不会回落到浏览器登录 | + +## 参考 + +- 集成指南:[`docs/guide/integrations/codebuddy.md`](../../docs/zh/guide/integrations/codebuddy.md) +- 快照 / 克隆 / 回滚:[`docs/zh/guide/snapshot-rollback-clone.md`](../../docs/zh/guide/snapshot-rollback-clone.md) +- 网络 / 出网策略示例:[`examples/network-policy`](../network-policy) +- 凭证保险柜 + 出网管控:[`docs/zh/guide/security-proxy.md`](../../docs/zh/guide/security-proxy.md) +- CodeBuddy Code CLI: diff --git a/examples/codebuddy-integration/_codebuddy_common.py b/examples/codebuddy-integration/_codebuddy_common.py new file mode 100644 index 000000000..6eab62cfc --- /dev/null +++ b/examples/codebuddy-integration/_codebuddy_common.py @@ -0,0 +1,112 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared sandbox command helpers for the CodeBuddy example scripts. + +Kept SDK-agnostic (duck-typed on ``sandbox.commands.run`` and the result's +attributes) so the same helpers work with both the e2b-compatible SDK used by +``run_codebuddy.py`` / ``resume_codebuddy.py`` and the native ``cubesandbox`` +SDK used by ``network_policy.py``. +""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Callable +from typing import Any + +from e2b.sandbox.commands.command_handle import CommandExitException + + +def positive_int(value: str) -> int: + """argparse type that rejects zero and negative integers. + + Both the CLI value and the env-var fallback default flow through this + function so passing ``--exec-timeout 0`` fails the same way as setting + ``CODEBUDDY_AGENT_EXEC_TIMEOUT=0`` in the environment. + """ + try: + parsed = int(value) + except (TypeError, ValueError): + raise argparse.ArgumentTypeError(f"expected an integer, got {value!r}") from None + if parsed <= 0: + raise argparse.ArgumentTypeError( + f"expected a positive integer, got {value}" + ) + return parsed + + +def stream_writer(stream) -> Callable[[object], None]: + """Create a callback that writes chunks to a stream with error handling. + + Errors during write/flush are logged to stderr rather than raised, + preventing stream exceptions from aborting the command execution. + """ + def write(chunk: object) -> None: + try: + text = getattr(chunk, "line", chunk) + stream.write(str(text)) + stream.flush() + except OSError: + pass # Broken pipe / closed stream — command likely terminated + except Exception: + print(f"[stream writer error: {type(chunk).__name__}]", file=sys.stderr) + + return write + + +def run_command( + sandbox: Any, + command: str, + *, + cwd: str | None = None, + envs: dict[str, str] | None = None, + timeout: int | float | None = None, + stream: bool = False, + user: str = "user", +): + # The e2b / CubeSandbox exec channel rejects unknown usernames with + # "invalid username: ''", so we cannot pass ``codebuddy`` here even + # though the image's USER directive drops privileges. ``user`` (uid 1000) + # is the default non-root account the base image ships; pairing that with + # the image-level USER means any containerized tool runs unprivileged, while + # the exec channel stays within the SDK-accepted allow-list. + kwargs = {"cwd": cwd, "timeout": timeout, "user": user} + kwargs = {key: value for key, value in kwargs.items() if value is not None} + if envs: + kwargs["envs"] = envs + if stream: + kwargs["on_stdout"] = stream_writer(sys.stdout) + kwargs["on_stderr"] = stream_writer(sys.stderr) + + try: + return sandbox.commands.run(command, **kwargs) + except TypeError as exc: + # Older SDKs name the parameter ``env`` instead of ``envs``. The retry + # only fires when the error message mentions "envs" explicitly — other + # TypeErrors (e.g. bad timeout value) are re-raised so real bugs are + # not masked. + if "envs" not in str(exc): + raise + kwargs["env"] = kwargs.pop("envs") + return sandbox.commands.run(command, **kwargs) + except CommandExitException as exc: + # Some SDKs raise on non-zero exits instead of returning a result. + # Swallow it here so every caller can uniformly branch on exit_code + # without catching at every call site. + return exc + + +def ensure_success(result, action: str) -> None: + exit_code = getattr(result, "exit_code", None) + if exit_code not in (None, 0): + stdout = getattr(result, "stdout", "") + stderr = getattr(result, "stderr", "") + raise SystemExit( + f"Failed to {action} (exit {exit_code}).\nSTDOUT:\n{stdout}\nSTDERR:\n{stderr}" + ) + + +def sandbox_identifier(sandbox: Any) -> str: + return getattr(sandbox, "sandbox_id", getattr(sandbox, "id", "unknown")) diff --git a/examples/codebuddy-integration/env_utils.py b/examples/codebuddy-integration/env_utils.py new file mode 100644 index 000000000..c3955e6ed --- /dev/null +++ b/examples/codebuddy-integration/env_utils.py @@ -0,0 +1,475 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import shlex +from pathlib import Path +from urllib.parse import urlparse, urlunparse + +from dotenv import load_dotenv + +# Default CodeBuddy state directory (overridable via CODEBUDDY_CONFIG_DIR). Must +# match the value baked into the Dockerfile so pause/resume snapshots land on +# the expected path. Lives under /workspace so the SDK-allowed exec user +# (``user``) can write to it without the home-dir write hole that +# ``/home/codebuddy/.codebuddy`` would otherwise open. +DEFAULT_CODEBUDDY_HOME = "/workspace/.codebuddy" +DEFAULT_WORKSPACE = "/workspace" + +# API key / base URL for each provider. The "io" map covers the international +# site (codebuddy.ai); "internal" is the China site (copilot.tencent.com). +INTERNET_ENVIRONMENTS = ("io", "internal", "ioa") + +# Provider key env vars and the upstream host CodeBuddy Code calls when the +# matching CODEBUDDY_API_KEY is set. The provider is selected by +# CODEBUDDY_INTERNET_ENVIRONMENT (see env_utils.internet_environment()). +PROVIDER_KEY_ENV = { + "anthropic": "ANTHROPIC_API_KEY", + "openai": "OPENAI_API_KEY", + "deepseek": "DEEPSEEK_API_KEY", + "google": "GEMINI_API_KEY", + "codebuddy_io": "CODEBUDDY_API_KEY", +} + +PROVIDER_DEFAULT_HOST = { + "anthropic": "api.anthropic.com", + "openai": "api.openai.com", + "deepseek": "api.deepseek.com", + "google": "generativelanguage.googleapis.com", + "codebuddy_io": "api.codebuddy.ai", +} + +# Default model when the user only sets CODEBUDDY_MODEL to nothing; Anthropic is +# the most common provider paired with CodeBuddy Code today, so we ship a sane +# default there. Other providers require an explicit model (model IDs change +# often and there is no safe cross-provider default). +PROVIDER_DEFAULT_MODEL = { + "anthropic": "claude-sonnet-4-6", +} + +# Variables that are forwarded verbatim into the in-sandbox exec env. The list +# is intentionally narrow: only env vars that affect CodeBuddy's request shape +# or operator-controlled behavior. API keys are forwarded separately (see +# build_codebuddy_env / include_secrets=False) so a CI host with several +# providers never leaks all of them into one sandbox. +PASSTHROUGH_ENV_NAMES = ( + "ANTHROPIC_BASE_URL", + "ANTHROPIC_MODEL", + "CODEBUDDY_BASE_URL", + "CODEBUDDY_MODEL", + "CODEBUDDY_SMALL_FAST_MODEL", + "CODEBUDDY_BIG_SLOW_MODEL", + "CODEBUDDY_CODE_SUBAGENT_MODEL", + "CODEBUDDY_INTERNET_ENVIRONMENT", + "MAX_THINKING_TOKENS", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + "CODEBUDDY_CUSTOM_HEADERS", +) + + +def load_local_dotenv() -> None: + """Best-effort load of a nearby .env file without overriding real env vars.""" + for path in ( + Path(__file__).with_name(".env"), + Path.cwd() / ".env", + ): + if path.is_file(): + load_dotenv(dotenv_path=path, override=False) + return + + +def required(name: str) -> str: + value = os.environ.get(name) + if not value: + raise SystemExit(f"Missing required environment variable: {name}") + return value + + +def cube_required(cube_name: str, legacy_name: str) -> str: + """Resolve a CUBE_* config key with an E2B_* legacy fallback. + + ``cube_name`` is the canonical env-var name (``CUBE_API_URL``, ``CUBE_API_KEY``); + ``legacy_name`` is the older alias (``E2B_API_URL``, ``E2B_API_KEY``). The + function checks the canonical name first and falls back to the legacy name, + so existing deployments that only set ``E2B_*`` continue to work without changes. + """ + value = os.environ.get(cube_name) or os.environ.get(legacy_name) + if not value: + raise SystemExit( + f"Missing required environment variable: set either {cube_name} " + f"(preferred) or {legacy_name} (legacy alias) in your .env" + ) + return value + + +def optional(name: str, default: str = "") -> str: + """Return ``os.environ[name]`` if the key exists, else ``default``. + + An explicitly-empty value (``CODEBUDDY_FOO=""``) is propagated verbatim — + we only fall back when the variable is genuinely unset. This avoids the + ``os.environ.get(key) or default`` foot-gun where empty strings silently + flip to the default and mask bad .env input. + """ + if name not in os.environ: + return default + return os.environ[name] + + +def int_env(name: str, default: int) -> int: + raw = os.environ.get(name) + if raw is None or raw == "": + return default + try: + return int(raw) + except ValueError as exc: + raise SystemExit(f"{name} must be an integer, got {raw!r}") from exc + + +def _env_positive_int(name: str, default: int) -> int: + """``int_env`` + ``positive_int``: rejects zero/negative env-var values too. + + argparse evaluates ``default=`` before ``type=``, so a bare + ``default=int_env(...)`` lets a malformed env var (e.g. + ``CODEBUDDY_SANDBOX_TIMEOUT=0``) bypass the ``type=positive_int`` check. + Use this helper for timeout defaults that share the positive-integer + constraint with their CLI flag. + """ + raw = os.environ.get(name) + if raw is None or raw == "": + return default + try: + parsed = int(raw) + except ValueError as exc: + raise SystemExit(f"{name} must be an integer, got {raw!r}") from exc + if parsed <= 0: + raise SystemExit(f"{name} must be a positive integer, got {parsed}") + return parsed + + +def codebuddy_home() -> str: + return optional("CODEBUDDY_CONFIG_DIR", DEFAULT_CODEBUDDY_HOME) + + +def codebuddy_workspace() -> str: + return optional("CODEBUDDY_WORKSPACE", DEFAULT_WORKSPACE) + + +def internet_environment() -> str: + """Normalize CODEBUDDY_INTERNET_ENVIRONMENT to one of the supported values. + + ``io`` (international), ``internal`` (China), ``ioa`` (Tencent enterprise). + We lowercase + strip so accidental whitespace / case from a ``.env`` file + does not push CodeBuddy into the wrong auth flow. + """ + value = optional("CODEBUDDY_INTERNET_ENVIRONMENT", "io").strip().lower() + if value not in INTERNET_ENVIRONMENTS: + raise SystemExit( + f"CODEBUDDY_INTERNET_ENVIRONMENT must be one of " + f"{', '.join(INTERNET_ENVIRONMENTS)}, got {value!r}" + ) + return value + + +def _provider_from_host(host: str) -> str | None: + """Detect provider from hostname using exact suffix matching. + + Returns ``None`` when the hostname does not match any known official + endpoint, letting the caller decide how to proceed (typically raising an + error unless ``CODEBUDDY_PROVIDER`` is already set). + """ + host = host.rstrip(".") # strip trailing dot from FQDN notation + if host.endswith(".anthropic.com"): + return "anthropic" + if host.endswith(".openai.com"): + return "openai" + if host.endswith(".deepseek.com"): + return "deepseek" + if host.endswith(".googleapis.com"): + return "google" + if host == "api.codebuddy.ai": + return "codebuddy_io" + + # No substring fallback: "anthropic-proxy.attacker.io" must NOT match as + # "anthropic", because provider_inject() and llm_host() are security- + # critical (auth header shape and egress allow-list). Callers that + # need to handle custom gateways must set CODEBUDDY_PROVIDER explicitly. + return None + + +def provider() -> str: + """Pick a logical provider for key injection / allow-list resolution. + + The CodeBuddy upstream itself keys off CODEBUDDY_INTERNET_ENVIRONMENT plus + CODEBUDDY_API_KEY / CODEBUDDY_BASE_URL; this helper just translates that + trio into a single string so the egress allow-list (network_policy.py) can + pick the right host and the right auth-header shape. + + If ``CODEBUDDY_PROVIDER`` is set, it is used verbatim. Otherwise, for the + ``io`` internet environment, the host from ``CODEBUDDY_BASE_URL`` is matched + against known official endpoints (``*.anthropic.com``, ``api.codebuddy.ai``, + etc.) — if the host is unknown and ``CODEBUDDY_PROVIDER`` was not set, an + error is raised instead of falling back to a best-guess heuristic. Custom + gateways must therefore set ``CODEBUDDY_PROVIDER`` explicitly. + """ + explicit = os.environ.get("CODEBUDDY_PROVIDER") + if explicit: + return explicit.strip().lower() + env = internet_environment() + if env == "io": + return "codebuddy_io" + base_url = os.environ.get("CODEBUDDY_BASE_URL") or "" + host = _host_from_url(base_url) + detected = _provider_from_host(host) + if detected: + return detected + raise SystemExit( + f"Cannot determine provider from host {host!r} in CODEBUDDY_BASE_URL. " + f"Set CODEBUDDY_PROVIDER explicitly (e.g. anthropic, openai, deepseek, google) " + f"when using a non-standard upstream." + ) + + +def codebuddy_model() -> str: + """Resolve the model CodeBuddy should run against. + + Precedence: explicit ``CODEBUDDY_MODEL`` > provider-specific env + (``ANTHROPIC_MODEL``, ...) > provider default (Anthropic only). + """ + provider_name = provider() + explicit = os.environ.get("CODEBUDDY_MODEL") + if not explicit and provider_name == "anthropic": + explicit = os.environ.get("ANTHROPIC_MODEL") + if explicit: + return explicit + default = PROVIDER_DEFAULT_MODEL.get(provider_name) + if default: + return default + raise SystemExit( + f"No default model for provider {provider_name!r}. Set CODEBUDDY_MODEL in your " + ".env (model IDs are provider-specific; there is no safe cross-provider default)." + ) + + +def provider_key_name() -> str: + """Return the env-var name we expect to hold the active provider's key.""" + provider_name = provider() + candidates = provider_key_candidates(provider_name) + for name in candidates: + if os.environ.get(name): + return name + return candidates[0] + + +def require_provider_key() -> str: + """Resolve the active provider key, raising if none is set. + + Unlike pi-agent we only need one key — CodeBuddy itself only ever talks to + one upstream at a time — but we still scan multiple env-var names so + users who copy-paste their Anthropic env still work without editing. + """ + provider_name = provider() + candidates = provider_key_candidates(provider_name) + for name in candidates: + value = os.environ.get(name) + if value: + return value + raise SystemExit( + "Missing required environment variable: one of " + ", ".join(candidates) + ) + + +def provider_key_candidates(provider_name: str) -> tuple[str, ...]: + provider_name = provider_name.strip().lower() + default_name = PROVIDER_KEY_ENV.get( + provider_name, f"{provider_name.upper()}_API_KEY" + ) + # CodeBuddy also accepts CODEBUDDY_AUTH_TOKEN as a platform auth token; the + # user usually wants the API key path, but we fall back to it so the same + # .env file works for both. + candidates = [default_name, "CODEBUDDY_API_KEY", "CODEBUDDY_AUTH_TOKEN"] + if provider_name == "codebuddy_io": + # When the user keeps the international CodeBuddy site as the default + # but points CODEBUDDY_BASE_URL at a custom upstream, fall back to the + # matching provider key so they do not have to set CODEBUDDY_PROVIDER. + base_url = os.environ.get("CODEBUDDY_BASE_URL") or "" + host = _host_from_url(base_url) + detected = _provider_from_host(host) + if detected: + candidates.append(PROVIDER_KEY_ENV.get(detected, f"{detected.upper()}_API_KEY")) + return tuple(candidates) + + +def llm_host() -> str: + """Resolve the LLM API host that CodeBuddy must reach. + + Precedence: explicit ``CODEBUDDY_LLM_HOST`` > host parsed from + ``CODEBUDDY_BASE_URL`` / ``ANTHROPIC_BASE_URL`` > provider default. + """ + provider_name = provider() + explicit = os.environ.get("CODEBUDDY_LLM_HOST") + if explicit: + return _host_from_url(explicit) + for env_name in ("CODEBUDDY_BASE_URL", "ANTHROPIC_BASE_URL"): + base_url = os.environ.get(env_name) + if base_url: + host = _host_from_url(base_url) + if host: + return host + return PROVIDER_DEFAULT_HOST.get(provider_name, "") + + +def _host_from_url(value: str) -> str: + candidate = value.strip() + if not candidate: + return "" + if "://" not in candidate: + candidate = f"https://{candidate}" + return urlparse(candidate).hostname or "" + + +# Proxy env vars whose URLs may carry embedded credentials (e.g. +# ``http://user:pass@corp-proxy:8080``). CodeBuddy's LLM agent runs inside the +# VM and would otherwise see those credentials in its own environment — strip +# them before forwarding. The other LLM-host resolution paths here never see a +# URL with userinfo (we extract only the hostname), so this only matters for the +# HTTP_PROXY passthrough below. +PROXY_URL_ENV_NAMES = frozenset({"HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY"}) +_BASE_URL_ENV_NAMES = frozenset({"ANTHROPIC_BASE_URL", "CODEBUDDY_BASE_URL"}) + + +def strip_url_userinfo(value: str) -> str: + """Remove the ``user:password@`` segment from a URL. + + Accepts both full URLs (``http://u:p@h:8080``) and bare hoststrings + (``u:p@h:8080``); the latter is normalized with an ``https://`` prefix + first so ``urlparse`` can split authority from path. Strings that do not + parse cleanly are returned unchanged rather than masked, since the + downstream consumer is the Linux env inside the sandbox, which is the + same place any malformed proxy URL would already fail. + """ + candidate = value.strip() + if not candidate or "@" not in candidate: + return value + if "://" not in candidate: + candidate = f"https://{candidate}" + parsed = urlparse(candidate) + if not parsed.hostname: + return value + if parsed.username is None and parsed.password is None: + # ``@`` present but only as a non-credential delimiter — leave alone. + return value + netloc = parsed.hostname + if parsed.port is not None: + netloc = f"{netloc}:{parsed.port}" + return urlunparse(parsed._replace(netloc=netloc)) + + +def provider_inject(provider_name: str, secret: str) -> list[dict[str, str]]: + """CubeEgress credential-injection specs for a provider's auth header(s). + + Each dict maps directly to a ``cubesandbox.Inject(header=..., secret=..., + format=...)``. CubeEgress attaches these headers to matched outbound + requests, so the real key rides the wire and never enters the sandbox VM. + Anthropic uses ``x-api-key`` plus the required API-version header; every + other provider uses ``Authorization: Bearer``. + """ + if provider_name.strip().lower() == "anthropic": + return [ + {"header": "x-api-key", "secret": secret, "format": "${SECRET}"}, + {"header": "anthropic-version", "secret": "2023-06-01", "format": "${SECRET}"}, + ] + return [{"header": "Authorization", "secret": secret, "format": "Bearer ${SECRET}"}] + + +def build_codebuddy_env(include_secrets: bool = True) -> dict[str, str]: + """Build the env map passed to the CodeBuddy command inside the sandbox. + + Set ``include_secrets=False`` for the CubeEgress vault flavor: the real + provider key rides the wire via egress injection, so it must never enter + the sandbox environment. + """ + env = { + "CODEBUDDY_CONFIG_DIR": codebuddy_home(), + "CODEBUDDY_INTERNET_ENVIRONMENT": internet_environment(), + "DISABLE_TELEMETRY": optional("DISABLE_TELEMETRY", "1"), + "DISABLE_ERROR_REPORTING": optional("DISABLE_ERROR_REPORTING", "1"), + "DISABLE_AUTOUPDATER": optional("DISABLE_AUTOUPDATER", "1"), + "DISABLE_FEEDBACK_COMMAND": optional("DISABLE_FEEDBACK_COMMAND", "1"), + } + for name in PASSTHROUGH_ENV_NAMES: + value = os.environ.get(name) + if value: + # Proxy URLs may carry host credentials (http://user:pass@proxy:...) + # which would otherwise leak to the LLM agent inside the VM. Strip + # the userinfo segment but keep the host:port so the agent can still + # route through the proxy. + if name in PROXY_URL_ENV_NAMES: + value = strip_url_userinfo(value) + # Base URLs may embed credentials (e.g. https://token:key@gateway.example.com). + # Strip them for defense in depth so embedded tokens never reach the sandbox. + if name in _BASE_URL_ENV_NAMES: + value = strip_url_userinfo(value) + env[name] = value + if include_secrets: + # Forward ONLY the first (highest-priority) provider key that is actually + # set, never every candidate — a host with several provider keys (e.g. a + # CI matrix) must not leak all of them into the sandbox. The fallback + # chain in provider_key_candidates covers multi-name aliases (e.g. + # ANTHROPIC_API_KEY vs CODEBUDDY_API_KEY) for the same logical provider, + # but once one name matches we stop, so a dual-key host only forwards the + # one the operator actually set for this invocation. + for name in provider_key_candidates(provider()): + value = os.environ.get(name) + if value: + env[name] = value + break + return env + + +def codebuddy_command( + prompt: str, + *, + dangerously_skip_permissions: bool = True, + resume: str | None = None, + continue_session: bool = False, + session_id: str | None = None, + model: str | None = None, +) -> str: + """Build a headless (non-interactive) CodeBuddy invocation. + + ``-p`` makes CodeBuddy process the prompt and exit instead of launching the + interactive TUI (which would hang over the E2B exec channel). ``-y`` + (a.k.a. ``--dangerously-skip-permissions``) trusts every tool call for this + run — the alternative is a permission prompt that cannot be answered over + the non-interactive exec channel. Pass ``dangerously_skip_permissions=False`` + only if you have wired up a safe sandbox-specific tool allow-list via + settings.json. + + Session flags: + * ``-c`` (continue) re-uses the most recent session. + * ``-r `` / ``--resume `` re-uses a specific session. + * ``--session-id `` pins the session id for the new run. + + The prompt is the trailing positional argument. + """ + args = ["codebuddy", "-p"] + if dangerously_skip_permissions: + args.append("-y") + if continue_session: + args.append("-c") + if resume: + args.extend(["--resume", resume]) + if session_id: + args.extend(["--session-id", session_id]) + if model: + args.extend(["--model", model]) + args.append(prompt) + return " ".join(shlex.quote(arg) for arg in args) + + +def shell_join(*parts: str) -> str: + return " && ".join(part for part in parts if part) diff --git a/examples/codebuddy-integration/hooks/cubesandbox-sandbox.js b/examples/codebuddy-integration/hooks/cubesandbox-sandbox.js new file mode 100644 index 000000000..965218141 --- /dev/null +++ b/examples/codebuddy-integration/hooks/cubesandbox-sandbox.js @@ -0,0 +1,101 @@ +// cubesandbox-sandbox.js — CodeBuddy plugin that routes bash tool calls +// through a host-side CubeSandbox MicroVM instead of executing them on the +// host directly. +// +// Drop this file (and any sibling files in the same directory) into one of +// the CodeBuddy plugin directories to install: +// +// ~/.config/codebuddy/plugins/cubesandbox-sandbox.js (global) +// .codebuddy/plugins/cubesandbox-sandbox.js (project) +// +// Then add `CUBE_*` settings to the CodeBuddy config. Only whitelisted CUBE_* +// keys are read; LLM provider credentials are never copied here. +// +// Behaviour: +// - "tool.execute.before" for the `bash` tool: +// * spawns `python3 --cmd ` from this plugin +// file's directory +// * reads its stdout (which contains the command's stdout) and stderr +// * replaces output.args.command with a marker so the LLM sees the +// sandbox result instead of the host shell trying to run it +// * throws on non-zero exit so CodeBuddy aborts the tool call +// - Other tools pass through untouched. +// +// Security notes: +// - The plugin never embeds the API key. The spawned sandbox_exec.py uses +// the host's CubeSandbox SDK, which reads CUBE_* from the host env. +// - The plugin only forwards the command string; `sandbox_exec.py` runs +// it inside a disposable MicroVM, so a malicious prompt cannot poison +// the host filesystem beyond the session file under /tmp. +// - If the plugin fails to spawn or the sandbox crashes, we throw so +// CodeBuddy does not silently fall back to host execution. +// - runInSandbox uses execFileAsync (promisified) to avoid blocking the +// Node.js event loop during sandbox execution. + +import { execFile } from "node:child_process"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url)); +const EXECUTOR = join(PLUGIN_DIR, "..", "sandbox_exec.py"); + +/** + * Run a shell command inside a CubeSandbox via the host-side executor. + * Returns the captured stdout. Throws on non-zero exit so the LLM sees the + * failure and CodeBuddy aborts the tool call. + * + * Uses execFileAsync (via promisify) instead of spawnSync to avoid blocking + * the Node.js event loop. A hard timeout (5 minutes) is set as a safeguard; + * the sandbox-side timeout in sandbox_exec.py (default 120s, max 300s) is + * the authoritative limit for long-running commands. + */ +async function runInSandbox(command) { + let stdout, stderr, status; + + try { + ({ stdout, stderr, status } = await execFileAsync( + "python3", + [EXECUTOR, "--cmd", command], + { encoding: "utf-8", timeout: 300000 }, // 5-minute host-side timeout + )); + } catch (err) { + throw new Error( + `cubesandbox plugin: failed to spawn sandbox_exec.py: ${err.message}`, + ); + } + + if (status !== 0) { + throw new Error( + `cubesandbox plugin: sandbox command failed (exit ${status}): ${(stderr || "").trim()}`, + ); + } + return (stdout || "").trimEnd(); +} + +/** Plugin entry point — CodeBuddy invokes this once on startup. */ +export const CubeSandboxBashPlugin = async () => { + return { + "tool.execute.before": async (input, output) => { + // Only intercept the bash tool; every other tool (read, edit, ...) + // runs on the host as usual. + if (input.tool !== "bash") return; + + const command = output?.args?.command; + if (typeof command !== "string" || command.length === 0) return; + + const stdout = await runInSandbox(command); + + // Replace the command so CodeBuddy does not run it on the host shell, + // and surface the sandbox output as a synthetic stdout the LLM can + // read. Subsequent tool calls in the same session reuse the cached + // sandbox via sandbox_exec.py's session-file mechanism, so this is + // not a cold start after the first invocation. + output.args.command = `echo ${JSON.stringify( + "[cubesandbox-sandbox] executed in isolated MicroVM:\n" + stdout, + )}`; + }, + }; +}; diff --git a/examples/codebuddy-integration/hooks/install.sh b/examples/codebuddy-integration/hooks/install.sh new file mode 100644 index 000000000..a6537530f --- /dev/null +++ b/examples/codebuddy-integration/hooks/install.sh @@ -0,0 +1,165 @@ +#!/usr/bin/env bash +# Install or remove the CubeSandbox bash-routing plugin for CodeBuddy. +# +# CodeBuddy loads JavaScript plugins automatically from: +# - ~/.config/codebuddy/plugins/ (global) +# - .codebuddy/plugins/ (project-local) +# +# This installer copies cubesandbox-sandbox.js into the global plugin +# directory and writes a sanitized subset of CUBE_* settings into the +# CodeBuddy config so the spawned sandbox_exec.py can authenticate against +# CubeMaster. Provider API keys and CUBE_API_KEY are never copied into the +# CodeBuddy config — the cluster API key is read from the host environment +# at runtime. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +EXAMPLE_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +# XDG_CONFIG_HOME defaults to ~/.config per the spec. +CODEBUDDY_CONFIG_HOME="${CODEBUDDY_CONFIG_HOME:-$HOME/.config/codebuddy}" +PLUGIN_DIR="$CODEBUDDY_CONFIG_HOME/plugins" +PLUGIN_FILE="$PLUGIN_DIR/cubesandbox-sandbox.js" +PACKAGE_FILE="$PLUGIN_DIR/package.json" +CONFIG_FILE="$CODEBUDDY_CONFIG_HOME/config.json" +SOURCE_ENV="$EXAMPLE_DIR/.env.example" + +# CUBE_API_KEY is deliberately excluded from this list and from config.json. +# The cluster API key is a sensitive credential that should only be present in +# the host environment, never stored in plaintext on disk. sandbox_exec.py reads +# CUBE_API_KEY directly from os.environ at runtime, and the JavaScript plugin +# never accesses it. +ALLOWED_KEYS=( + "CUBE_API_URL" + "CUBE_TEMPLATE_ID" + "CUBE_PROXY_NODE_IP" + "CUBE_PROXY_PORT_HTTP" + "CUBE_SANDBOX_DOMAIN" + "CUBE_SANDBOX_USER" + "CUBE_SANDBOX_TIMEOUT" + "CUBE_EXEC_TIMEOUT" +) + +write_codebuddy_config() { + python3 - "$SOURCE_ENV" "$CONFIG_FILE" <<'PYEOF' +import json +import os +import stat +import sys +import tempfile +from pathlib import Path + +try: + from dotenv import dotenv_values +except ImportError as exc: + raise SystemExit( + "python-dotenv is required; install examples/codebuddy-integration/requirements.txt" + ) from exc + +ALLOWED_KEYS = { + "CUBE_API_URL", + "CUBE_TEMPLATE_ID", + "CUBE_PROXY_NODE_IP", + "CUBE_PROXY_PORT_HTTP", + "CUBE_SANDBOX_DOMAIN", + "CUBE_SANDBOX_USER", + "CUBE_SANDBOX_TIMEOUT", + "CUBE_EXEC_TIMEOUT", +} + +source = Path(sys.argv[1]) +destination = Path(sys.argv[2]) +values = dotenv_values(source) if source.is_file() else {} + +cube_settings = { + key: values[key] + for key in ALLOWED_KEYS + if isinstance(values.get(key), str) and values[key] +} + +destination.parent.mkdir(parents=True, exist_ok=True) + +existing = {} +if destination.exists() and destination.stat().st_size: + try: + existing = json.loads(destination.read_text(encoding="utf-8")) + except json.JSONDecodeError: + existing = {} +if not isinstance(existing, dict): + raise SystemExit(f"{destination} must contain a JSON object") + +# Merge: union of CUBE_* keys wins; everything else is preserved so the +# user keeps their provider / model settings untouched. +existing_cube = existing.get("cubesandbox", {}) +if not isinstance(existing_cube, dict): + existing_cube = {} +existing_cube.update(cube_settings) +existing_cube = {k: v for k, v in existing_cube.items() if v} +existing["cubesandbox"] = existing_cube + +fd, tmp_name = tempfile.mkstemp( + dir=destination.parent, + prefix=f".{destination.name}.", + suffix=".tmp", +) +temporary = Path(tmp_name) +try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as fp: + json.dump(existing, fp, indent=2) + fp.write("\n") + fp.flush() + os.fsync(fp.fileno()) + os.replace(temporary, destination) + os.chmod(destination, 0o600) +finally: + with __import__("contextlib").suppress(FileNotFoundError): + temporary.unlink() +PYEOF +} + +if [[ "${1:-}" == "--uninstall" ]]; then + rm -f "$PLUGIN_FILE" "$PACKAGE_FILE" "$PLUGIN_DIR/sandbox_exec.py" + echo "CubeSandbox CodeBuddy plugin uninstalled from $PLUGIN_DIR." + echo "(Your config.json was left intact — remove the \"cubesandbox\" key manually if desired.)" + exit 0 +fi + +if [[ $# -gt 0 ]]; then + echo "usage: $0 [--uninstall]" >&2 + exit 2 +fi + +command -v python3 >/dev/null 2>&1 || { + echo "python3 is required" >&2 + exit 1 +} + +mkdir -p "$PLUGIN_DIR" +install -m 0644 "$SCRIPT_DIR/cubesandbox-sandbox.js" "$PLUGIN_FILE" +install -m 0755 "$EXAMPLE_DIR/sandbox_exec.py" "$PLUGIN_DIR/sandbox_exec.py" +# CodeBuddy loads the plugin as an ES module +cat > "$PACKAGE_FILE" <<'JSONEOF' +{ + "name": "codebuddy-cubesandbox-plugins", + "version": "1.0.0", + "private": true, + "type": "module", + "description": "Local plugin directory for CodeBuddy's CubeSandbox bash router. Auto-loaded by CodeBuddy." +} +JSONEOF + +if [[ ! -f "$SOURCE_ENV" ]]; then + echo "Warning: $SOURCE_ENV not found — skipping config merge." >&2 + echo "Create a .env file to configure the plugin (see .env.example for keys)." >&2 +else + write_codebuddy_config +fi + +echo "CubeSandbox CodeBuddy plugin installed:" +echo " plugin file: $PLUGIN_FILE" +echo " config: $CONFIG_FILE (only CUBE_* keys added)" +echo "" +echo "Restart CodeBuddy for the plugin to take effect." +echo "Uninstall with: $0 --uninstall" diff --git a/examples/codebuddy-integration/mcp_server.py b/examples/codebuddy-integration/mcp_server.py new file mode 100644 index 000000000..2eaf8ad1a --- /dev/null +++ b/examples/codebuddy-integration/mcp_server.py @@ -0,0 +1,470 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""CubeSandbox MCP Server for CodeBuddy. + +Exposes a small set of tools that let any MCP-capable client (Claude +Desktop, Cursor, Windsurf, VS Code, etc.) execute untrusted code or shell +commands inside an isolated CubeSandbox MicroVM instead of on the host. +The same backend (`sandbox_exec.py`) handles the actual work; this file +just adapts it to the Model Context Protocol's newline-delimited JSON-RPC +transport on stdio. + +Add to your MCP client's config (Claude Desktop example shown): + + { + "mcpServers": { + "cubesandbox-codebuddy": { + "command": "python3", + "args": [ + "/abs/path/to/CubeSandbox/examples/codebuddy-integration/mcp_server.py" + ], + "env": { + "CUBE_API_URL": "http://:3000", + "CUBE_API_KEY": "", + "CUBE_TEMPLATE_ID": "" + } + } + } + } + +Security notes: +- The server executes commands inside an isolated MicroVM, limiting blast radius. +- File paths are validated syntactically (normpath only) to prevent obvious + path traversal. The sandbox's isolated namespace is the authoritative boundary. +- No authentication is required when accessed via local MCP client config. +- Ensure MCP client configurations are properly secured in production. +""" + +from __future__ import annotations + +import json +import logging +import os +import shlex +import sys +import threading +import traceback +from typing import Any + +from dotenv import load_dotenv +from e2b_code_interpreter import Sandbox + +logger = logging.getLogger(__name__) + +load_dotenv() + +# --- Configuration ----------------------------------------------------------- + +# CUBE_API_URL / CUBE_API_KEY are the canonical names (documented in .env.example). +# E2B_API_URL / E2B_API_KEY are accepted as legacy aliases. +E2B_API_URL = os.getenv("CUBE_API_URL") or os.getenv("E2B_API_URL", "http://127.0.0.1:3000") +E2B_API_KEY = os.getenv("CUBE_API_KEY") or os.getenv("E2B_API_KEY", "e2b_000000") +TEMPLATE_ID = os.getenv("CUBE_TEMPLATE_ID", "") + +# Security limits +MAX_TIMEOUT = 300 # 5 minutes maximum +MAX_CODE_LENGTH = 100_000 # 100KB +MAX_CONTENT_LENGTH = 1_000_000 # 1MB +MAX_MESSAGE_LENGTH = 1_000_000 # 1MB per JSON-RPC line on stdio +MAX_COMMAND_LENGTH = 65536 # 64KB + +# Allowed path prefixes for file operations (sandbox-side). +# In a typical setup, the sandbox VM only has access to /workspace. +_ALLOWED_PATH_PREFIXES = ("/workspace", "/tmp", "/home/user") + +_sandbox: Any = None +_sandbox_lock = threading.Lock() + + +def _get_sandbox(timeout: int = 600): + """Lazy-create a sandbox and reuse it across tool calls until the process exits.""" + global _sandbox + with _sandbox_lock: + if _sandbox is None: + if not TEMPLATE_ID: + raise RuntimeError("CUBE_TEMPLATE_ID is not set") + _sandbox = Sandbox.create(TEMPLATE_ID, timeout=timeout) + else: + try: + _sandbox.set_timeout(timeout) + except Exception: + try: + _sandbox.kill() + except Exception: + # Orphaned sandbox — set_timeout and kill both failed. + # Log for operator visibility; the sandbox will remain alive + # on the cluster until its TTL expires. + logger.warning( + "Failed to set timeout and kill sandbox; " + "sandbox may be orphaned (id=%s)", getattr(_sandbox, "id", "unknown") + ) + _sandbox = Sandbox.create(TEMPLATE_ID, timeout=timeout) + return _sandbox + + +def _cleanup_sandbox() -> None: + """Destroy the cached sandbox when the MCP process exits.""" + global _sandbox + sandbox, _sandbox = _sandbox, None + if sandbox is not None: + try: + sandbox.kill() + except Exception as exc: + print(f"Failed to clean up sandbox: {exc}", file=sys.stderr) + + +def _validate_path(path: str) -> str | None: + """Validate a path is syntactically within allowed prefixes. + + Returns None if valid, error message otherwise. + + Note: This validation is purely syntactic (uses os.path.normpath, not + os.path.realpath). It checks that the normalized path starts with an + allowed prefix. The actual file operations run inside the sandbox's + isolated namespace, where the sandbox's own filesystem policies are + the authoritative security boundary. realpath is not used because it + would resolve against the host's symlinks, which may differ from the + sandbox's private namespace. + """ + if not isinstance(path, str): + return "path must be a string" + if not path: + return "path cannot be empty" + # Normalize path syntactically and check prefix + try: + normalized = os.path.normpath(path) + except (ValueError, OSError): + return "invalid path" + for prefix in _ALLOWED_PATH_PREFIXES: + # Use strict prefix check with os.sep to avoid /workspace-stuff matching /workspace + if normalized.startswith(prefix + os.sep) or normalized == prefix: + return None + return f"path must be within {', '.join(_ALLOWED_PATH_PREFIXES)}" + + +def _validate_timeout(timeout: int | None) -> int: + """Validate and bound timeout value.""" + if timeout is None: + return MAX_TIMEOUT + if not isinstance(timeout, int): + return MAX_TIMEOUT + return min(max(1, timeout), MAX_TIMEOUT) + + +def _validate_string_length(value: str, max_length: int, field_name: str) -> str | None: + """Validate string length. Returns None if valid, error message otherwise.""" + if not isinstance(value, str): + return f"{field_name} must be a string" + if len(value) > max_length: + return f"{field_name} exceeds maximum length of {max_length} bytes" + return None + + +def run_command(cmd: str, timeout: int = 120) -> dict[str, Any]: + """Run a shell command inside the sandbox, capturing exit code / stdout / stderr.""" + from e2b.sandbox.commands.command_handle import CommandExitException + try: + result = _get_sandbox().commands.run(cmd, timeout=timeout) + return { + "exit_code": result.exit_code, + "stdout": result.stdout.strip() if result.stdout else "", + "stderr": result.stderr.strip() if result.stderr else "", + } + except CommandExitException as exc: + return { + "exit_code": getattr(exc, "exit_code", 1), + "stdout": "", + "stderr": str(exc)[:2048], # Truncate to prevent info leak + } + except Exception as exc: + return { + "exit_code": 1, + "stdout": "", + "stderr": f"execution error: {type(exc).__name__}", + } + + +def _format_command_result(result: dict[str, Any]) -> str: + """Render a command result for an MCP tool response. + + The exit code is always included so the LLM can branch on success / + failure without having to parse free-form text. stdout / stderr are + rendered only when non-empty. + """ + sections = [f"exit_code: {result['exit_code']}"] + if result["stdout"]: + sections.append(f"stdout:\n{result['stdout']}") + if result["stderr"]: + sections.append(f"stderr:\n{result['stderr']}") + if len(sections) == 1: + sections.append("(no output)") + return "\n".join(sections) + + +# --- Tool definitions -------------------------------------------------------- + +TOOLS: list[dict[str, Any]] = [ + { + "name": "sandbox_run_code", + "description": ( + "Execute Python code in an isolated CubeSandbox MicroVM. Use this for " + "running untrusted code, testing generated scripts, or installing " + "packages safely." + ), + "inputSchema": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Python code to execute in the sandbox", + }, + "timeout": { + "type": "integer", + "description": "Execution timeout in seconds (default 120, max 300)", + "default": 120, + }, + }, + "required": ["code"], + }, + }, + { + "name": "sandbox_run_command", + "description": ( + "Execute a shell command in an isolated CubeSandbox MicroVM. Use this " + "to safely test shell commands, explore file systems, or run build " + "tools without affecting the host." + ), + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "Shell command to execute in the sandbox", + }, + "timeout": { + "type": "integer", + "description": "Execution timeout in seconds (default 120, max 300)", + "default": 120, + }, + }, + "required": ["command"], + }, + }, + { + "name": "sandbox_write_file", + "description": "Write content to a file inside the sandbox.", + "inputSchema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Absolute path inside the sandbox (e.g. /workspace/script.py)", + }, + "content": {"type": "string", "description": "File content to write"}, + }, + "required": ["path", "content"], + }, + }, + { + "name": "sandbox_read_file", + "description": "Read a file's content from inside the sandbox.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "Absolute path inside the sandbox"}, + }, + "required": ["path"], + }, + }, + { + "name": "sandbox_reset", + "description": ( + "Destroy the current sandbox and create a fresh one. Use between " + "unrelated tasks to get a clean environment." + ), + "inputSchema": {"type": "object", "properties": {}}, + }, +] + + +# --- JSON-RPC handler -------------------------------------------------------- + +def handle_request(request: dict[str, Any]) -> dict[str, Any] | None: + method = request.get("method", "") + req_id = request.get("id") + + if method == "initialize": + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "protocolVersion": "2024-11-05", + "capabilities": {"tools": {}}, + "serverInfo": { + "name": "cubesandbox-codebuddy-mcp", + "version": "1.0.0", + }, + }, + } + + if method == "tools/list": + return {"jsonrpc": "2.0", "id": req_id, "result": {"tools": TOOLS}} + + if method == "tools/call": + params = request.get("params") + if not isinstance(params, dict): + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": "Invalid arguments: missing params"}], + "isError": True, + }, + } + tool_name = params.get("name", "") + arguments = params.get("arguments", {}) + text = "" + is_error = False + try: + if tool_name == "sandbox_run_code": + code = arguments.get("code", "") + timeout = _validate_timeout(arguments.get("timeout")) + err = _validate_string_length(code, MAX_CODE_LENGTH, "code") + if err: + raise ValueError(err) + if not code.strip(): + raise ValueError("code cannot be empty") + result = run_command(f"python3 -c {shlex.quote(code)}", timeout=timeout) + text = _format_command_result(result) + is_error = result["exit_code"] != 0 + + elif tool_name == "sandbox_run_command": + cmd = arguments.get("command", "") + timeout = _validate_timeout(arguments.get("timeout")) + err = _validate_string_length(cmd, MAX_COMMAND_LENGTH, "command") + if err: + raise ValueError(err) + if not cmd.strip(): + raise ValueError("command cannot be empty") + result = run_command(cmd, timeout=timeout) + text = _format_command_result(result) + is_error = result["exit_code"] != 0 + + elif tool_name == "sandbox_write_file": + path = arguments.get("path", "") + content = arguments.get("content", "") + err = _validate_path(path) + if err: + raise ValueError(err) + err = _validate_string_length(content, MAX_CONTENT_LENGTH, "content") + if err: + raise ValueError(err) + _get_sandbox().files.write(path, content) + text = f"Written {len(content)} bytes to {path}" + + elif tool_name == "sandbox_read_file": + path = arguments.get("path", "") + err = _validate_path(path) + if err: + raise ValueError(err) + content = _get_sandbox().files.read(path) + if isinstance(content, bytes): + content_bytes = content + else: + content_bytes = content.encode("utf-8") + if len(content_bytes) > MAX_CONTENT_LENGTH: + raise ValueError( + f"file content exceeds maximum size of {MAX_CONTENT_LENGTH} bytes" + ) + text = content if isinstance(content, str) else content.decode( + "utf-8", errors="replace" + ) + + elif tool_name == "sandbox_reset": + _cleanup_sandbox() + text = "Sandbox destroyed. A new one will be created on next use." + + else: + text = f"Unknown tool: {tool_name}" + is_error = True + + except (ValueError, TypeError, KeyError) as exc: + text = f"Invalid arguments: {exc}" + is_error = True + except (OSError, RuntimeError) as exc: + # Expected errors from sandbox operations (network, file I/O, timeout). + traceback.print_exc(file=sys.stderr) + text = f"Error: {exc}" + is_error = True + except (KeyboardInterrupt, SystemExit): + raise # propagate without wrapping — daemon stop is not an error + except BaseException as exc: + # Fatal: MemoryError, RecursionError, etc. — log and propagate so + # the daemon can restart cleanly rather than silently continuing. + traceback.print_exc(file=sys.stderr) + raise + + return { + "jsonrpc": "2.0", + "id": req_id, + "result": { + "content": [{"type": "text", "text": text}], + "isError": is_error, + }, + } + + if method == "notifications/initialized": + return None + + return { + "jsonrpc": "2.0", + "id": req_id, + "error": {"code": -32601, "message": f"Method not found: {method}"}, + } + + +# --- Main loop --------------------------------------------------------------- + +def _read_mcp_message() -> dict[str, Any] | None: + """Read one newline-delimited JSON-RPC message from MCP stdio. + + Raises EOFError when stdin is exhausted (client disconnected). Malformed + lines return None so the loop can skip them without hanging. Each line is + bounded by MAX_MESSAGE_LENGTH to prevent a malicious client from exhausting + the server's memory with a multi-gigabyte payload. + """ + line = sys.stdin.readline() + if not line: + raise EOFError() + if len(line) > MAX_MESSAGE_LENGTH: + print(f"[mcp_server] dropped oversized message ({len(line)} bytes > {MAX_MESSAGE_LENGTH} limit)", file=sys.stderr) + return None + try: + return json.loads(line) + except json.JSONDecodeError: + print(f"[mcp_server] dropped malformed JSON message: {line[:100]!r}", file=sys.stderr) + return None + + +def main() -> None: + """Run the MCP server on stdio (newline-delimited JSON-RPC).""" + try: + while True: + try: + request = _read_mcp_message() + except EOFError: + break + if request is None: + continue + response = handle_request(request) + if response is not None: + sys.stdout.write(json.dumps(response) + "\n") + sys.stdout.flush() + finally: + _cleanup_sandbox() + + +if __name__ == "__main__": + main() diff --git a/examples/codebuddy-integration/network_policy.py b/examples/codebuddy-integration/network_policy.py new file mode 100644 index 000000000..18608fb5a --- /dev/null +++ b/examples/codebuddy-integration/network_policy.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Restrict CodeBuddy Code's egress to the LLM API and inject the key on the wire. + +This is the recommended production ("credential vault") pattern: + +* Default-deny egress — the sandbox is created with ``allow_internet_access=False`` + and an ``allow_out`` list containing only the LLM API host, so every other + destination is dropped before it can leave the sandbox. +* The provider auth header is attached by CubeEgress via ``inject`` rules + (native ``cubesandbox`` SDK; see docs/guide/security-proxy.md), so the real + key rides the wire and never enters the sandbox VM. The agent inside only + sees a placeholder value. + +Run: + python network_policy.py +""" + +from __future__ import annotations + +import argparse +import os +import shlex +import sys + +from cubesandbox import Sandbox, Rule, Match, Action, Inject + +from _codebuddy_common import ensure_success, positive_int, run_command, sandbox_identifier +from env_utils import ( + _env_positive_int, + build_codebuddy_env, + codebuddy_command, + codebuddy_model, + codebuddy_workspace, + cube_required, + internet_environment, + llm_host, + load_local_dotenv, + provider, + provider_inject, + provider_key_name, + require_provider_key, + required, + shell_join, +) + +PLACEHOLDER_KEY = "cube-egress-managed-placeholder" + +# CodeBuddy ships as a Node.js bundle that bundles its own CA store and ignores +# the system trust store. On the vault path CubeEgress terminates TLS to inject +# the credential, so Node must trust the CubeEgress root CA or every LLM call +# fails with "unable to verify the first certificate". Point NODE_EXTRA_CA_CERTS +# at a bundle that includes it; the CubeSandbox base image installs the CA into +# the system bundle below. +DEFAULT_NODE_CA_BUNDLE = "/etc/ssl/certs/ca-certificates.crt" + +DEFAULT_PROMPT = ( + "Reply with a single short sentence confirming you can reach the LLM API, " + "then write that sentence to {workspace}/egress_check.md." +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run CodeBuddy under a default-deny egress policy with on-the-wire key injection." + ) + parser.add_argument( + "--template", + default=os.environ.get("CUBE_TEMPLATE_ID"), + help="CubeSandbox template ID. Defaults to CUBE_TEMPLATE_ID.", + ) + parser.add_argument( + "--host", + default=None, + help="LLM API host to allow. Defaults to CODEBUDDY_LLM_HOST or the provider default.", + ) + parser.add_argument( + "--workspace", + default=codebuddy_workspace(), + help="Working directory inside the sandbox. Defaults to CODEBUDDY_WORKSPACE.", + ) + parser.add_argument( + "--prompt", + default=None, + help="Prompt passed to CodeBuddy. Defaults to a small egress reachability check.", + ) + parser.add_argument( + "--model", + default=None, + help="Model id for the active provider. Defaults to CODEBUDDY_MODEL.", + ) + parser.add_argument( + "--sandbox-timeout", + type=positive_int, + default=_env_positive_int("CODEBUDDY_SANDBOX_TIMEOUT", 1800), + help="Sandbox lifetime in seconds. Defaults to CODEBUDDY_SANDBOX_TIMEOUT or 1800.", + ) + parser.add_argument( + "--exec-timeout", + type=positive_int, + default=_env_positive_int("CODEBUDDY_AGENT_EXEC_TIMEOUT", 900), + help="CodeBuddy command timeout in seconds. Defaults to CODEBUDDY_AGENT_EXEC_TIMEOUT or 900.", + ) + parser.add_argument( + "--skip-agent", + action="store_true", + help="Only show the egress checks; skip the actual CodeBuddy run.", + ) + args = parser.parse_args() + if args.prompt is None: + args.prompt = DEFAULT_PROMPT.format(workspace=args.workspace) + return args + + +def build_rules(provider_name: str, host: str, secret: str) -> list[Rule]: + # Canonical CubeEgress rule (see docs/guide/security-proxy.md): allow the LLM + # host and attach the provider auth header(s) on the wire via ``inject`` so + # the real key never enters the sandbox VM. Anything matching no rule under + # default-deny is rejected by CubeEgress with 403. + return [ + Rule( + name=f"allow_{provider_name}_llm", + match=Match(scheme="https", sni=host, host=host), + action=Action( + allow=True, + audit="metadata", + inject=[Inject(**spec) for spec in provider_inject(provider_name, secret)], + ), + ) + ] + + +def create_sandbox(template_id: str, rules: list[Rule], timeout: int) -> Sandbox: + # allow_internet_access=False makes egress default-deny at L3/L4; the LLM host + # named in the rules is auto-allowed and its requests are injected at L7 by + # CubeEgress. Never silently drop this flag, or full egress is re-enabled. + return Sandbox.create( + template=template_id, + allow_internet_access=False, + network={"rules": rules}, + timeout=timeout, + ) + + +def show_key_not_in_vm(sandbox: Sandbox, key_name: str) -> None: + command = f"printenv {shlex.quote(key_name)} || echo ''" + result = run_command(sandbox, command, timeout=30) + ensure_success(result, "read provider key inside sandbox") + value = getattr(result, "stdout", "").strip() + print(f"In-VM {key_name}: {value!r} (real secret stays in CubeEgress)") + + +def show_non_llm_blocked(sandbox: Sandbox) -> None: + command = ( + "curl -s -o /dev/null -w '%{http_code}' --max-time 8 https://example.com " + "|| echo blocked" + ) + result = run_command(sandbox, command, timeout=30) + status = getattr(result, "stdout", "").strip() + print( + f"Non-LLM host (example.com) response: {status or 'blocked'} " + "(expected 403/blocked under default-deny)" + ) + + +def run_agent( + sandbox: Sandbox, + args: argparse.Namespace, + envs: dict[str, str], + model: str, +): + command = shell_join( + f"cd {shlex.quote(args.workspace)}", + codebuddy_command( + args.prompt, + dangerously_skip_permissions=True, + model=model, + ), + ) + return run_command( + sandbox, + command, + cwd=args.workspace, + envs=envs, + timeout=args.exec_timeout, + stream=True, + ) + + +def main() -> int: + load_local_dotenv() + args = parse_args() + + template_id = args.template or required("CUBE_TEMPLATE_ID") + cube_required("CUBE_API_URL", "E2B_API_URL") + cube_required("CUBE_API_KEY", "E2B_API_KEY") + + provider_name = provider() + secret = require_provider_key() + host = args.host or llm_host() + if not host: + raise SystemExit( + "Could not resolve the LLM host. Set CODEBUDDY_LLM_HOST in your .env or pass --host." + ) + + rules = build_rules(provider_name, host, secret) + + sandbox_env = build_codebuddy_env(include_secrets=False) + key_name = provider_key_name() + # Pick the same env-var CodeBuddy itself would use to read the key, so the + # CLI does not have to be reconfigured. The placeholder satisfies the SDK's + # "non-empty" check; CubeEgress replaces the header on the wire. + sandbox_env[key_name] = PLACEHOLDER_KEY + # Let the Node-based CodeBuddy CLI trust the CubeEgress interception CA + # (see note on DEFAULT_NODE_CA_BUNDLE); without this the vault path fails + # TLS. Override via CODEBUDDY_NODE_EXTRA_CA_CERTS if the CA lives elsewhere. + sandbox_env["NODE_EXTRA_CA_CERTS"] = os.environ.get( + "CODEBUDDY_NODE_EXTRA_CA_CERTS", DEFAULT_NODE_CA_BUNDLE + ) + + print(f"Internet environment: {internet_environment()}") + print(f"Provider: {provider_name}") + print(f"Allowed LLM host (default-deny for everything else): {host}") + print(f"Creating sandbox from template: {template_id}") + + sandbox = create_sandbox(template_id, rules, args.sandbox_timeout) + sandbox_id = sandbox_identifier(sandbox) + result = None + try: + print(f"Sandbox ready: {sandbox_id}\n") + + show_key_not_in_vm(sandbox, key_name) + show_non_llm_blocked(sandbox) + + if args.skip_agent: + print("\n--skip-agent set: not invoking CodeBuddy.") + return 0 + + model = args.model or codebuddy_model() + print("\nRunning CodeBuddy through the injected egress path...\n") + result = run_agent(sandbox, args, sandbox_env, model) + exit_code = getattr(result, "exit_code", None) + print(f"\nCodeBuddy exit code: {exit_code}") + return 0 if exit_code is None else int(exit_code) + finally: + try: + sandbox.kill() + print(f"\nSandbox {sandbox_id} killed.") + except Exception as exc: # noqa: BLE001 - cleanup must not mask real errors + print( + f"Warning: failed to kill sandbox {sandbox_id}: {exc}", + file=sys.stderr, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/codebuddy-integration/requirements.txt b/examples/codebuddy-integration/requirements.txt new file mode 100644 index 000000000..1fa1be70c --- /dev/null +++ b/examples/codebuddy-integration/requirements.txt @@ -0,0 +1,5 @@ +e2b>=2.4.1 +e2b-code-interpreter>=1.0.0 +cubesandbox>=0.3.0 +python-dotenv>=1.0.0 +pytest>=8.0.0 diff --git a/examples/codebuddy-integration/resume_codebuddy.py b/examples/codebuddy-integration/resume_codebuddy.py new file mode 100644 index 000000000..a05259a98 --- /dev/null +++ b/examples/codebuddy-integration/resume_codebuddy.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Demonstrate CodeBuddy Code session persistence across a CubeSandbox pause/resume. + +Turn 1 asks CodeBuddy to write ``/workspace/plan.md``, then the sandbox is paused. +Turn 2 reconnects to the same sandbox, verifies both ``/workspace`` and the +CodeBuddy state directory survived the snapshot, and asks CodeBuddy to continue +the work via ``-c`` (continue most recent session). + +Lifecycle note: this script deliberately avoids ``with Sandbox.create(...)``. +A context manager kills the sandbox on ``__exit__``, which would defeat the +pause. The lifecycle is managed manually with try/finally so the sandbox stays +alive between turns and is only killed at the very end. +""" + +from __future__ import annotations + +import argparse +import os +import shlex +import sys + +from e2b import Sandbox + +from _codebuddy_common import ensure_success, positive_int, run_command, sandbox_identifier +from env_utils import ( + _env_positive_int, + build_codebuddy_env, + codebuddy_command, + codebuddy_home, + codebuddy_model, + codebuddy_workspace, + cube_required, + load_local_dotenv, + require_provider_key, + required, + shell_join, +) + +TURN_1_PROMPT = ( + "Create {workspace}/plan.md containing a numbered 3-step plan for building a " + "small Python CLI that prints the current time. Only write the plan file." +) +TURN_2_PROMPT = ( + "Read {workspace}/plan.md and implement step 1 by creating " + "{workspace}/progress.md that records which step you completed and why. " + "Do not delete plan.md." +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Demonstrate CodeBuddy session persistence across a CubeSandbox pause/resume." + ) + parser.add_argument( + "--template", + default=os.environ.get("CUBE_TEMPLATE_ID"), + help="CubeSandbox template ID. Defaults to CUBE_TEMPLATE_ID.", + ) + parser.add_argument( + "--workspace", + default=codebuddy_workspace(), + help="Working directory inside the sandbox. Defaults to CODEBUDDY_WORKSPACE.", + ) + parser.add_argument( + "--model", + default=None, + help="Model id for the active provider. Defaults to CODEBUDDY_MODEL.", + ) + parser.add_argument( + "--codebuddy-home", + default=codebuddy_home(), + help="CodeBuddy state directory checked for survival after resume.", + ) + parser.add_argument( + "--sandbox-timeout", + type=positive_int, + default=_env_positive_int("CODEBUDDY_SANDBOX_TIMEOUT", 1800), + help="Sandbox lifetime in seconds. Defaults to CODEBUDDY_SANDBOX_TIMEOUT or 1800.", + ) + parser.add_argument( + "--exec-timeout", + type=positive_int, + default=_env_positive_int("CODEBUDDY_AGENT_EXEC_TIMEOUT", 900), + help="CodeBuddy command timeout in seconds. Defaults to CODEBUDDY_AGENT_EXEC_TIMEOUT or 900.", + ) + return parser.parse_args() + + +def run_turn( + sandbox: Sandbox, + workspace: str, + prompt: str, + exec_timeout: int, + envs: dict[str, str], + model: str, + *, + continue_session: bool = False, +): + command = shell_join( + f"cd {shlex.quote(workspace)}", + codebuddy_command( + prompt, + dangerously_skip_permissions=True, + model=model, + continue_session=continue_session, + ), + ) + return run_command( + sandbox, + command, + cwd=workspace, + envs=envs, + timeout=exec_timeout, + stream=True, + ) + + +def assert_state_survived(sandbox: Sandbox, workspace: str, state_dir: str) -> None: + quoted_workspace = shlex.quote(workspace) + quoted_state = shlex.quote(state_dir) + command = shell_join( + f"test -f {quoted_workspace}/plan.md", + f"test -d {quoted_state}", + # CodeBuddy session data lives under projects///. + # Confirm at least one project directory exists, which is what ``-c`` + # and ``-r `` consult when resuming. + f"test -n \"$(ls -1 {quoted_state}/projects 2>/dev/null)\"", + "printf '\\n--- plan.md (survived pause/resume) ---\\n'", + f"cat {quoted_workspace}/plan.md", + ) + result = run_command(sandbox, command, timeout=60) + ensure_success(result, "verify /workspace and CodeBuddy state survived pause/resume") + if getattr(result, "stdout", ""): + print(result.stdout) + + +def show_final_workspace(sandbox: Sandbox, workspace: str) -> None: + quoted_workspace = shlex.quote(workspace) + command = shell_join( + f"ls -la {quoted_workspace}", + f"test ! -f {quoted_workspace}/progress.md || " + f"(printf '\\n--- progress.md ---\\n' && cat {quoted_workspace}/progress.md)", + ) + result = run_command(sandbox, command, timeout=60) + ensure_success(result, "inspect final workspace") + if getattr(result, "stdout", ""): + print(result.stdout) + + +def main() -> int: + load_local_dotenv() + args = parse_args() + + template_id = args.template or required("CUBE_TEMPLATE_ID") + cube_required("CUBE_API_URL", "E2B_API_URL") + cube_required("CUBE_API_KEY", "E2B_API_KEY") + require_provider_key() + + codebuddy_env = build_codebuddy_env() + model = args.model or codebuddy_model() + turn_1_prompt = TURN_1_PROMPT.format(workspace=args.workspace) + turn_2_prompt = TURN_2_PROMPT.format(workspace=args.workspace) + + print(f"Creating sandbox from template: {template_id}") + # SECURITY: like run_codebuddy.py this demo keeps egress open and injects the + # key per command. The pause() snapshot also captures the in-VM env and any + # credentials CodeBuddy caches under /workspace/.codebuddy, widening + # exposure — for shared clusters prefer the default-deny + vault pattern in + # network_policy.py. + sandbox = Sandbox.create(template=template_id, timeout=args.sandbox_timeout) + sandbox_id = sandbox_identifier(sandbox) + + try: + print(f"Sandbox ready: {sandbox_id}") + + version_result = run_command(sandbox, "codebuddy --version", timeout=60) + ensure_success(version_result, "check CodeBuddy version") + print(f"CodeBuddy version: {getattr(version_result, 'stdout', '').strip()}") + + print("\n=== Turn 1: create plan.md ===\n") + result_1 = run_turn( + sandbox, args.workspace, turn_1_prompt, args.exec_timeout, codebuddy_env, model + ) + ensure_success(result_1, "run CodeBuddy turn 1") + + print(f"\nPausing sandbox {sandbox_id} (snapshotting VM + rootfs)...") + paused_id = sandbox.pause() + # The sandbox_id is stable across pause. Some SDK versions return the + # resume handle as a string; others return a bool (success). Only adopt + # a string handle, otherwise keep the original id for connect(). + if isinstance(paused_id, str) and paused_id: + sandbox_id = paused_id + print(f"Paused. Resume handle: {sandbox_id}") + + print(f"\nReconnecting to {sandbox_id}...") + sandbox = Sandbox.connect(sandbox_id=sandbox_id) + print("Reconnected after resume.") + + print("\n=== Verifying persistence after resume ===\n") + assert_state_survived(sandbox, args.workspace, args.codebuddy_home) + + print("\n=== Turn 2: continue the work ===\n") + # ``-c`` picks the most recent session CodeBuddy recorded under + # $CODEBUDDY_CONFIG_DIR/projects/, which survived the snapshot. + result_2 = run_turn( + sandbox, + args.workspace, + turn_2_prompt, + args.exec_timeout, + codebuddy_env, + model, + continue_session=True, + ) + ensure_success(result_2, "run CodeBuddy turn 2") + + show_final_workspace(sandbox, args.workspace) + + exit_code = getattr(result_2, "exit_code", 0) + return 0 if exit_code is None else int(exit_code) + finally: + if sandbox is not None: + try: + sandbox.kill() + print(f"\nSandbox {sandbox_id} killed.") + except Exception as exc: # noqa: BLE001 - cleanup must not mask real errors + print( + f"Warning: failed to kill sandbox {sandbox_id}: {exc}", + file=sys.stderr, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/codebuddy-integration/run_codebuddy.py b/examples/codebuddy-integration/run_codebuddy.py new file mode 100644 index 000000000..64d9596e1 --- /dev/null +++ b/examples/codebuddy-integration/run_codebuddy.py @@ -0,0 +1,211 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Run a one-shot Tencent CodeBuddy Code task inside a CubeSandbox. + +The key is forwarded per-command via ``sandbox.commands.run(..., envs=...)``, +so it lives only for the lifetime of that exec call — never written to a +persistent file inside the VM. +""" + +from __future__ import annotations + +import argparse +import os +import shlex +import sys +from typing import Any + +from e2b import Sandbox + +from _codebuddy_common import ensure_success, positive_int, run_command, sandbox_identifier +from env_utils import ( + _env_positive_int, + build_codebuddy_env, + codebuddy_command, + codebuddy_model, + codebuddy_workspace, + cube_required, + load_local_dotenv, + require_provider_key, + required, + shell_join, +) + +DEFAULT_PROMPT_TEMPLATE = ( + "Inspect the project in {workspace}, run python3 app.py, and write a " + "concise summary of the result to {workspace}/result.md." +) + + +def default_prompt(workspace: str) -> str: + return DEFAULT_PROMPT_TEMPLATE.format(workspace=workspace) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run a one-shot CodeBuddy Code task inside CubeSandbox." + ) + parser.add_argument( + "--template", + default=os.environ.get("CUBE_TEMPLATE_ID"), + help="CubeSandbox template ID. Defaults to CUBE_TEMPLATE_ID.", + ) + parser.add_argument( + "--prompt", + default=None, + help="Prompt passed to CodeBuddy. Defaults to a small workspace smoke task.", + ) + parser.add_argument( + "--workspace", + default=codebuddy_workspace(), + help="Working directory inside the sandbox. Defaults to CODEBUDDY_WORKSPACE.", + ) + parser.add_argument( + "--model", + default=None, + help="Model id for the active provider. Defaults to CODEBUDDY_MODEL.", + ) + parser.add_argument( + "--approve", + action=argparse.BooleanOptionalAction, + default=True, + help=( + "Skip tool-call permission prompts (-y / --dangerously-skip-permissions). " + "Required for any non-interactive run that touches files or commands. " + "Defaults to enabled; pass --no-approve to run with CodeBuddy's permission " + "prompts on (the exec channel cannot answer them, so this will hang — only " + "use --no-approve if you've tightened the tool allow-list via settings.json)." + ), + ) + parser.add_argument( + "--sandbox-timeout", + type=positive_int, + # Resolve the env-var through positive_int too so + # CODEBUDDY_SANDBOX_TIMEOUT=0 fails the same way as ``--sandbox-timeout 0`` + # (a bare ``int_env(...)`` default would skip that check and let a + # zero-value env var reach the SDK, which then creates a sandbox with + # no lifetime). + default=_env_positive_int("CODEBUDDY_SANDBOX_TIMEOUT", 1800), + help="Sandbox lifetime in seconds. Defaults to CODEBUDDY_SANDBOX_TIMEOUT or 1800.", + ) + parser.add_argument( + "--exec-timeout", + type=positive_int, + default=_env_positive_int("CODEBUDDY_AGENT_EXEC_TIMEOUT", 900), + help="CodeBuddy command timeout in seconds. Defaults to CODEBUDDY_AGENT_EXEC_TIMEOUT or 900.", + ) + parser.add_argument( + "--no-seed", + action="store_true", + help="Skip writing the demo files into the sandbox workspace.", + ) + args = parser.parse_args() + if args.prompt is None: + args.prompt = default_prompt(args.workspace) + return args + + +def seed_project(sandbox: Sandbox, workspace: str, timeout: int) -> None: + quoted_workspace = shlex.quote(workspace) + command = f"""mkdir -p {quoted_workspace} +cat > {quoted_workspace}/README.md <<'EOF' +# CubeSandbox CodeBuddy Smoke Project + +This tiny project exists so the CodeBuddy coding agent has a deterministic task to run. +EOF +cat > {quoted_workspace}/app.py <<'EOF' +def main() -> None: + print("hello from CubeSandbox + CodeBuddy") + + +if __name__ == "__main__": + main() +EOF +""" + result = run_command(sandbox, command, timeout=timeout) + ensure_success(result, "seed workspace") + + +def print_result_summary(result: Any) -> None: + exit_code = getattr(result, "exit_code", None) + stderr = getattr(result, "stderr", "") + + print(f"\nCodeBuddy exit code: {exit_code}") + if stderr: + print("\nCaptured stderr:", file=sys.stderr) + print(stderr, file=sys.stderr) + + +def show_workspace_result(sandbox: Sandbox, workspace: str, timeout: int) -> None: + quoted_workspace = shlex.quote(workspace) + command = shell_join( + f"ls -la {quoted_workspace}", + f"test ! -f {quoted_workspace}/result.md || " + f"(printf '\\n--- result.md ---\\n' && cat {quoted_workspace}/result.md)", + ) + result = run_command(sandbox, command, timeout=timeout) + ensure_success(result, "inspect workspace") + if getattr(result, "stdout", ""): + print(result.stdout) + + +def main() -> int: + load_local_dotenv() + args = parse_args() + + template_id = args.template or required("CUBE_TEMPLATE_ID") + cube_required("CUBE_API_URL", "E2B_API_URL") + cube_required("CUBE_API_KEY", "E2B_API_KEY") + require_provider_key() + + codebuddy_env = build_codebuddy_env() + model = args.model or codebuddy_model() + command = shell_join( + f"cd {shlex.quote(args.workspace)}", + codebuddy_command( + args.prompt, + dangerously_skip_permissions=args.approve, + model=model, + ), + ) + + print(f"Creating sandbox from template: {template_id}") + result = None + # SECURITY: this direct-key demo keeps egress open (allow_internet_access + # defaults to True) for simplicity, and injects the provider key per command + # via envs=. A compromised agent with open egress could exfiltrate that key. + # For shared/production use prefer network_policy.py, which pairs default-deny + # egress with the CubeEgress credential vault (the key never enters the VM). + with Sandbox.create(template=template_id, timeout=args.sandbox_timeout) as sandbox: + sandbox_id = sandbox_identifier(sandbox) + print(f"Sandbox ready: {sandbox_id}") + + version_result = run_command(sandbox, "codebuddy --version", timeout=60) + ensure_success(version_result, "check CodeBuddy version") + print(f"CodeBuddy version: {getattr(version_result, 'stdout', '').strip()}") + + if not args.no_seed: + seed_project(sandbox, args.workspace, timeout=60) + print(f"Seeded demo project in {args.workspace}") + + print("\nRunning CodeBuddy task...\n") + result = run_command( + sandbox, + command, + cwd=args.workspace, + envs=codebuddy_env, + timeout=args.exec_timeout, + stream=True, + ) + + print_result_summary(result) + show_workspace_result(sandbox, args.workspace, timeout=60) + + exit_code = getattr(result, "exit_code", 1) + return 0 if exit_code is None else int(exit_code) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/codebuddy-integration/sandbox_exec.py b/examples/codebuddy-integration/sandbox_exec.py new file mode 100644 index 000000000..b2a769348 --- /dev/null +++ b/examples/codebuddy-integration/sandbox_exec.py @@ -0,0 +1,416 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Sandbox execution backend for CodeBuddy. + +CodeBuddy runs on the HOST (or inside an outer sandbox) and calls this +script whenever it needs to execute untrusted code or shell commands inside +an isolated CubeSandbox MicroVM. The pattern mirrors the same idea behind +Claude Code's Bash hook — keep the LLM agent outside the danger zone, +route every executable action through a fresh, disposable VM. + +Usage from a Python orchestrator or a shell: + + python sandbox_exec.py --code "print(1+1)" + python sandbox_exec.py --file /path/to/script.py + python sandbox_exec.py --cmd "ls -la /workspace" + python sandbox_exec.py --pip requests --code "import requests; print(requests.__version__)" + python sandbox_exec.py --keep-alive --code "state = 42" # reuse on next call + python sandbox_exec.py --reset --session session-id # force a fresh one +""" + +from __future__ import annotations + +import argparse +import logging +import os +import re +import shlex +import stat +import sys +import tempfile +import threading +import time +from pathlib import Path + +from dotenv import load_dotenv +from e2b.sandbox.commands.command_handle import CommandExitException +from e2b_code_interpreter import Sandbox + +logger = logging.getLogger(__name__) + +load_dotenv() + +# --- Configuration ----------------------------------------------------------- + +# CUBE_API_URL / CUBE_API_KEY are the canonical names (documented in .env.example). +# E2B_API_URL / E2B_API_KEY are accepted as legacy aliases so existing deployments +# that only set the E2B_ names continue to work without changes. +E2B_API_URL = os.getenv("CUBE_API_URL") or os.getenv("E2B_API_URL", "http://127.0.0.1:3000") +E2B_API_KEY = os.getenv("CUBE_API_KEY") or os.getenv("E2B_API_KEY", "e2b_000000") +TEMPLATE_ID = os.getenv("CUBE_TEMPLATE_ID", "") + +# Maximum command length to prevent resource exhaustion. +MAX_COMMAND_LENGTH = 65536 + +# Maximum code length to prevent resource exhaustion. +MAX_CODE_LENGTH = 100_000 + +# Maximum file size (bytes) for --file to prevent host OOM from reading huge files. +MAX_FILE_LENGTH = 10 * 1024 * 1024 # 10 MB + +# Allowed directories for file operations. Defaults to cwd so the script +# works out-of-the-box in a project directory, but note that running from +# / or /etc would open those entire trees. In production set +# ALLOWED_READ_DIRS explicitly (colon-separated list) so the directory +# boundary is intentional and auditable. The --file guard is the only +# filesystem boundary between the host and the sandbox; there is no +# chroot or mount namespace here. +_ALLOWED_READ_DIRS = [Path.cwd()] +if os.getenv("ALLOWED_READ_DIRS"): + for d in os.getenv("ALLOWED_READ_DIRS", "").split(":"): + if d: + _ALLOWED_READ_DIRS.append(Path(d).resolve()) + +# PEP 508 package name validator — compiled once at module load. +_PACKAGE_NAME_RE = re.compile(r"^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$|^[A-Za-z0-9]$") + +# The session file lives under gettempdir() so `mktemp`/`/tmp` rotations can +# reclaim it, but is scoped to the invoking UID and locked down with 0600 so +# other users on a shared host cannot *read* it. The O_NOFOLLOW + S_ISREG +# double-check guards against symlink races that would otherwise let an attacker +# point us at an arbitrary file before the write happens. +# +# Caveat — TOCTOU in the retry loop: between the FileExistsError unlink() and +# the retry open(), a local attacker who can predict or observe SESSION_FILE +# may re-create a symlink at the path. O_NOFOLLOW makes open() fail with +# ELOOP rather than following the link, but a persistent attacker can keep +# re-creating the symlink and cause repeated failures. The retry loop +# mitigates but does not eliminate this window. On a shared cluster where +# untrusted users share a host's /tmp, use per-invocation sandboxes without +# --keep-alive to avoid the session-file path entirely. +SESSION_FILE = Path(tempfile.gettempdir()) / f"cubesandbox_codebuddy_session_{os.getuid()}" + +# Thread lock for sandbox access (prevents race conditions in multi-threaded usage). +_sandbox_lock = threading.Lock() + +# In-process cache so consecutive --keep-alive calls inside the same Python +# interpreter don't pay cold-start cost. Cross-process reuse goes through +# SESSION_FILE. +_sandbox: "Sandbox | None" = None + + +def _write_session(sandbox_id: str) -> None: + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + + # ELOOP means the path resolved to a symlink. This can happen in a + # TOCTOU window: between the FileExistsError unlink() and the retry + # open(), a hostile or coincidental symlink may appear at the path. + # A short retry gives the legitimate file a chance to win while + # preserving O_NOFOLLOW's guarantee that we never follow a symlink. + for attempt in range(3): + try: + fd = os.open(SESSION_FILE, flags, 0o600) + except FileExistsError: + # O_EXCL fails if file exists — unlink and retry. + SESSION_FILE.unlink(missing_ok=True) + continue + except OSError as e: + if hasattr(e, "errno") and e.errno == 40: # ELOOP - symlink race + time.sleep(0.05 * (attempt + 1)) # brief backoff + continue + raise + break + else: + raise OSError(f"could not create session file after retries: {SESSION_FILE}") + + try: + # Immediately verify it's a regular file before writing + st = os.fstat(fd) + if not stat.S_ISREG(st.st_mode): + raise OSError(f"session file is not a regular file: {SESSION_FILE}") + os.ftruncate(fd, 0) + os.write(fd, sandbox_id.encode("utf-8")) + os.fsync(fd) + finally: + os.close(fd) + + +def _read_session() -> str | None: + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + fd = os.open(SESSION_FILE, flags) + except OSError: + return None + try: + st = os.fstat(fd) + if not stat.S_ISREG(st.st_mode): + return None + with os.fdopen(fd, "r", encoding="utf-8") as fp: + value = fp.read().strip() + return value or None + except (OSError, UnicodeDecodeError): + return None + + +def _get_sandbox(timeout: int = 300) -> "Sandbox": + """Get or create a reusable sandbox, reconnecting via session file when possible.""" + global _sandbox + with _sandbox_lock: + if _sandbox is not None: + try: + _sandbox.set_timeout(timeout) # refresh TTL + return _sandbox + except Exception: + try: + _sandbox.kill() + except Exception: + # Orphaned sandbox — set_timeout and kill both failed. + # Log for operator visibility; the sandbox will remain alive + # on the cluster until its TTL expires. + logger.warning( + "Failed to set timeout and kill sandbox; " + "sandbox may be orphaned (id=%s)", + getattr(_sandbox, "sandbox_id", "unknown") + ) + _sandbox = None + + # Try cross-process reuse first. + sandbox_id = _read_session() + if sandbox_id: + try: + _sandbox = Sandbox.connect(sandbox_id) + _sandbox.set_timeout(timeout) + return _sandbox + except Exception: + SESSION_FILE.unlink(missing_ok=True) + + if not TEMPLATE_ID: + raise SystemExit( + "CUBE_TEMPLATE_ID is not set. Set it in your .env or pass it via the environment." + ) + _sandbox = Sandbox.create(TEMPLATE_ID, timeout=timeout) + _write_session(_sandbox.sandbox_id) + return _sandbox + + +def _run(sandbox: "Sandbox", cmd: str, timeout: int = 120): + """Run a command in the sandbox, normalizing non-zero exits into a result object. + + The e2b / cubesandbox SDK raises CommandExitException on non-zero exits; + swallowing it here lets callers handle success / failure uniformly instead + of catching at every call site. + """ + try: + return sandbox.commands.run(cmd, timeout=timeout) + except CommandExitException as exc: + return exc + + +# --- Public API -------------------------------------------------------------- + +def exec_code(code: str, pip_packages: list[str] | None = None, timeout: int = 120) -> str: + """Execute Python code in the sandbox and return stdout (or stderr on failure).""" + if not isinstance(code, str): + return "[error] code must be a string" + if len(code) > MAX_CODE_LENGTH: + return f"[error] code exceeds maximum length of {MAX_CODE_LENGTH} bytes" + if not code.strip(): + return "[error] code cannot be empty" + + # Validate pip package names to prevent injection via malicious package names. + # Accept only simple names (letters, digits, hyphens, underscores, periods) + # following PEP 508 package name specification. + if pip_packages: + for pkg in pip_packages: + if not isinstance(pkg, str) or not _PACKAGE_NAME_RE.match(pkg): + return f"[error] invalid pip package name: {pkg!r}" + if len(pkg) > 128: # PEP 508 recommends max 214 chars, be conservative + return f"[error] pip package name too long: {pkg!r}" + + sandbox = _get_sandbox(timeout + 60) + if pip_packages: + r = _run( + sandbox, + "pip install " + " ".join(shlex.quote(p) for p in pip_packages), + timeout=60, + ) + if r.exit_code != 0: + return f"[pip error] command failed (exit {r.exit_code})" + result = _run(sandbox, f"python3 -c {shlex.quote(code)}", timeout=timeout) + if result.exit_code == 0: + return result.stdout or "" + return f"[error] exit code {result.exit_code}" + + +def exec_file(filepath: str, timeout: int = 120) -> str: + """Copy a local file into the sandbox and execute it. + + Security: filepath is validated to be within allowed directories before reading. + Symlinks and absolute paths outside allowed directories are rejected. + """ + # Resolve and validate the filepath + try: + resolved = Path(filepath).resolve() + except (ValueError, OSError): + return "[error] invalid filepath" + + # Reject symlinks at the leaf — os.open with O_NOFOLLOW will also reject + # symlinks on intermediate components, but checking the leaf first keeps the + # error message clear and avoids hitting the OS penalty for deep paths. + try: + if Path(filepath).is_symlink(): + return "[error] symlinks not allowed" + except (ValueError, OSError): + pass + + # Check if path is within any allowed directory (use separator guard to prevent + # /workspace matching /workspace_evil) + if not any( + str(resolved).startswith(str(d) + os.sep) or str(resolved) == str(d) + for d in _ALLOWED_READ_DIRS + ): + return "[error] filepath not in allowed directories" + + sandbox = _get_sandbox(timeout + 60) + fd = None + try: + fd = os.open(filepath, os.O_RDONLY | os.O_NOFOLLOW) + st = os.fstat(fd) + if st.st_size > MAX_FILE_LENGTH: + return f"[error] file exceeds maximum size of {MAX_FILE_LENGTH} bytes" + try: + raw = os.read(fd, st.st_size) + except OSError: + return "[error] cannot read file" + try: + content = raw.decode("utf-8") + except UnicodeDecodeError: + return "[error] file is not valid UTF-8" + finally: + if fd is not None: + os.close(fd) + + sandbox.files.write("/tmp/codebuddy_script.py", content) + result = _run(sandbox, "python3 /tmp/codebuddy_script.py", timeout=timeout) + if result.exit_code == 0: + return result.stdout or "" + return f"[error] exit code {result.exit_code}" + + +def exec_cmd(command: str, timeout: int = 120) -> str: + """Execute an arbitrary shell command in the sandbox. + + Note: Commands are executed inside an isolated MicroVM, so the blast + radius of a malicious command is limited to that sandbox. However, be + aware that: + - The sandbox may have access to API keys injected via environment. + - Resource exhaustion attacks (infinite loops, memory allocation) are possible. + """ + if not isinstance(command, str): + return "[error] command must be a string" + if len(command) > MAX_COMMAND_LENGTH: + return f"[error] command exceeds maximum length of {MAX_COMMAND_LENGTH} bytes" + if not command.strip(): + return "[error] empty command" + + sandbox = _get_sandbox(timeout + 60) + result = _run(sandbox, command, timeout=timeout) + if result.exit_code == 0: + return result.stdout or "" + return f"[error] exit code {result.exit_code}" + + +def r_stderr(result) -> str: + """Extract stderr from a result object, sanitizing internal details.""" + stderr = getattr(result, "stderr", None) + if stderr: + # Truncate to prevent large error output from leaking internal details + return stderr[:2048].strip() + exit_code = getattr(result, "exit_code", None) + if exit_code is not None: + return f"exit code {exit_code}" + return "unknown error" + + +def cleanup() -> None: + """Destroy the cached sandbox and clear the session file.""" + global _sandbox + with _sandbox_lock: + if _sandbox is not None: + try: + _sandbox.kill() + except Exception: + pass + _sandbox = None + try: + SESSION_FILE.unlink(missing_ok=True) + except OSError: + pass + + +# --- CLI --------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Execute code or shell commands in an isolated CubeSandbox MicroVM." + ) + parser.add_argument("--code", help="Python code to execute") + parser.add_argument("--file", help="Local Python file to copy into the sandbox and execute") + parser.add_argument("--cmd", help="Shell command to execute") + parser.add_argument("--pip", nargs="+", help="Pip packages to install before --code") + parser.add_argument("--timeout", type=int, default=120, help="Execution timeout in seconds") + parser.add_argument( + "--keep-alive", + action="store_true", + help="Keep the sandbox alive after this invocation so the next call " + "can reconnect via the session file instead of cold-starting. " + "On shared hosts where untrusted users share /tmp, prefer " + "per-invocation mode to avoid the session-file TOCTOU window.", + ) + parser.add_argument( + "--reset", + action="store_true", + help="Destroy the cached sandbox before running, then exit.", + ) + parser.add_argument( + "--session", + default=None, + help="(Reserved) per-session identifier. Currently unused — session reuse " + "is process-global. Documented so future per-session sandboxes " + "do not break callers.", + ) + args = parser.parse_args() + + if args.reset: + cleanup() + print("Sandbox destroyed. A new one will be created on next use.") + return + + if not any((args.code, args.file, args.cmd)): + parser.print_help() + sys.exit(2) + + if not TEMPLATE_ID: + print("Error: CUBE_TEMPLATE_ID not set in .env", file=sys.stderr) + sys.exit(1) + + try: + if args.code: + print(exec_code(args.code, args.pip, args.timeout)) + elif args.file: + print(exec_file(args.file, args.timeout)) + elif args.cmd: + print(exec_cmd(args.cmd, args.timeout)) + finally: + if not args.keep_alive: + cleanup() + + +if __name__ == "__main__": + main() diff --git a/examples/codebuddy-integration/tests/test_codebuddy_common.py b/examples/codebuddy-integration/tests/test_codebuddy_common.py new file mode 100644 index 000000000..6ce4b8c84 --- /dev/null +++ b/examples/codebuddy-integration/tests/test_codebuddy_common.py @@ -0,0 +1,163 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for _codebuddy_common.py. + +These tests are fully offline: no CubeSandbox cluster or LLM credentials are +needed. The helpers are tested directly without mocking SDK calls. +""" + +from __future__ import annotations + +import argparse +import sys +from unittest.mock import MagicMock + +import pytest + +import _codebuddy_common as cb_common # noqa: E402 + + +class TestPositiveInt: + def test_parses_positive_integer(self): + assert cb_common.positive_int("42") == 42 + + def test_rejects_zero(self): + with pytest.raises(argparse.ArgumentTypeError): + cb_common.positive_int("0") + + def test_rejects_negative(self): + with pytest.raises(argparse.ArgumentTypeError): + cb_common.positive_int("-1") + + def test_rejects_non_integer(self): + with pytest.raises(argparse.ArgumentTypeError): + cb_common.positive_int("abc") + + +class TestRunCommand: + def test_returns_result_on_success(self): + mock_sandbox = MagicMock() + mock_result = MagicMock() + mock_result.exit_code = 0 + mock_result.stdout = "hello\n" + mock_result.stderr = "" + mock_sandbox.commands.run.return_value = mock_result + + result = cb_common.run_command(mock_sandbox, "echo hello") + assert result.exit_code == 0 + assert result.stdout == "hello\n" + + def test_returns_result_on_non_zero_exit(self): + mock_sandbox = MagicMock() + mock_result = MagicMock() + mock_result.exit_code = 1 + mock_result.stdout = "" + mock_result.stderr = "error" + mock_sandbox.commands.run.return_value = mock_result + + result = cb_common.run_command(mock_sandbox, "false") + assert result.exit_code == 1 + + def test_passes_timeout_and_cwd(self): + mock_sandbox = MagicMock() + mock_sandbox.commands.run.return_value = MagicMock(exit_code=0, stdout="", stderr="") + + cb_common.run_command(mock_sandbox, "ls", cwd="/tmp", timeout=30) + mock_sandbox.commands.run.assert_called_once() + call_kwargs = mock_sandbox.commands.run.call_args.kwargs + assert call_kwargs["cwd"] == "/tmp" + assert call_kwargs["timeout"] == 30 + + def test_stream_mode_attaches_on_stdout_and_stderr(self): + mock_sandbox = MagicMock() + mock_sandbox.commands.run.return_value = MagicMock(exit_code=0, stdout="", stderr="") + + cb_common.run_command(mock_sandbox, "ls", stream=True) + call_kwargs = mock_sandbox.commands.run.call_args.kwargs + assert "on_stdout" in call_kwargs + assert "on_stderr" in call_kwargs + + def test_default_user_is_user(self): + mock_sandbox = MagicMock() + mock_sandbox.commands.run.return_value = MagicMock(exit_code=0, stdout="", stderr="") + + cb_common.run_command(mock_sandbox, "ls") + call_kwargs = mock_sandbox.commands.run.call_args.kwargs + assert call_kwargs["user"] == "user" + + def test_non_envs_typeerror_propagates(self): + mock_sandbox = MagicMock() + mock_sandbox.commands.run.side_effect = TypeError("unexpected kwarg") + + with pytest.raises(TypeError, match="unexpected kwarg"): + cb_common.run_command(mock_sandbox, "ls") + + def test_falls_back_to_env_kwarg_for_legacy_sdk(self): + mock_sandbox = MagicMock() + mock_result = MagicMock(exit_code=0, stdout="ok", stderr="") + mock_sandbox.commands.run.side_effect = [ + TypeError("run() got an unexpected keyword argument 'envs'"), + mock_result, + ] + + result = cb_common.run_command(mock_sandbox, "ls", envs={"FOO": "bar"}) + assert result.exit_code == 0 + + +class TestEnsureSuccess: + def test_zero_exit_does_not_raise(self): + mock_result = MagicMock(exit_code=0, stdout="ok", stderr="") + cb_common.ensure_success(mock_result, "test action") # must not raise + + def test_none_exit_does_not_raise(self): + mock_result = MagicMock(exit_code=None, stdout="ok", stderr="") + cb_common.ensure_success(mock_result, "test action") # must not raise + + def test_non_zero_exit_raises(self): + mock_result = MagicMock(exit_code=1, stdout="", stderr="error msg") + with pytest.raises(SystemExit) as exc_info: + cb_common.ensure_success(mock_result, "do something") + assert "Failed to do something" in str(exc_info.value) + assert "error msg" in str(exc_info.value) + + +class TestSandboxIdentifier: + def test_prefers_sandbox_id(self): + mock_sb = MagicMock() + mock_sb.sandbox_id = "sb-123" + mock_sb.id = "id-456" + assert cb_common.sandbox_identifier(mock_sb) == "sb-123" + + def test_falls_back_to_id(self): + mock_sb = MagicMock(spec=["id"]) + mock_sb.id = "id-789" + assert cb_common.sandbox_identifier(mock_sb) == "id-789" + + def test_returns_unknown_when_neither_attr(self): + # Use spec=[] to prevent MagicMock from auto-creating attributes + mock_sb = MagicMock(spec=[]) + assert cb_common.sandbox_identifier(mock_sb) == "unknown" + + +class TestStreamWriter: + def test_writes_plain_string(self, monkeypatch): + output = [] + monkeypatch.setattr(sys.stdout, "write", lambda x: output.append(x)) + monkeypatch.setattr(sys.stdout, "flush", lambda: None) + + writer = cb_common.stream_writer(sys.stdout) + writer("hello") + assert "hello" in output + + def test_handles_chunk_with_line_attr(self, monkeypatch): + output = [] + monkeypatch.setattr(sys.stdout, "write", lambda x: output.append(x)) + monkeypatch.setattr(sys.stdout, "flush", lambda: None) + + writer = cb_common.stream_writer(sys.stdout) + chunk = MagicMock() + chunk.line = "line content\n" + writer(chunk) + # The output list contains the string with trailing newline + assert any("line content" in s for s in output) diff --git a/examples/codebuddy-integration/tests/test_env_utils.py b/examples/codebuddy-integration/tests/test_env_utils.py new file mode 100644 index 000000000..6081cad75 --- /dev/null +++ b/examples/codebuddy-integration/tests/test_env_utils.py @@ -0,0 +1,756 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Offline unit tests for examples/codebuddy-integration/env_utils.py. + +No CubeSandbox cluster or LLM credentials needed — every function tested here +is a pure resolution helper around environment variables. Run: + + cd examples/codebuddy-integration + python3 -m unittest test_env_utils.py -v +""" + +from __future__ import annotations + +import argparse +import os +import sys +import types +import unittest +from unittest import mock + +# Ensure the parent directory is on sys.path for direct execution via +# ``python3 -m unittest`` as well as for pytest's automatic path discovery. +_parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +if _parent not in sys.path: + sys.path.append(_parent) + +import env_utils # noqa: E402 + + +class InternetEnvironmentTests(unittest.TestCase): + def setUp(self) -> None: + self._saved = {k: os.environ.get(k) for k in ( + "CODEBUDDY_INTERNET_ENVIRONMENT", + "CODEBUDDY_BASE_URL", + "ANTHROPIC_BASE_URL", + "CODEBUDDY_LLM_HOST", + "CODEBUDDY_PROVIDER", + "CODEBUDDY_MODEL", + "ANTHROPIC_MODEL", + "CODEBUDDY_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "DEEPSEEK_API_KEY", + "CODEBUDDY_AUTH_TOKEN", + "CODEBUDDY_CONFIG_DIR", + "CODEBUDDY_WORKSPACE", + "DISABLE_TELEMETRY", + "DISABLE_ERROR_REPORTING", + "DISABLE_AUTOUPDATER", + "DISABLE_FEEDBACK_COMMAND", + )} + + def tearDown(self) -> None: + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _clear(self) -> None: + for k in self._saved: + os.environ.pop(k, None) + + def test_default_internet_environment(self) -> None: + self._clear() + self.assertEqual(env_utils.internet_environment(), "io") + + def test_internet_environment_normalizes_case(self) -> None: + self._clear() + os.environ["CODEBUDDY_INTERNET_ENVIRONMENT"] = " INTERNAL " + self.assertEqual(env_utils.internet_environment(), "internal") + + def test_internet_environment_rejects_unknown(self) -> None: + self._clear() + os.environ["CODEBUDDY_INTERNET_ENVIRONMENT"] = "mars" + with self.assertRaises(SystemExit): + env_utils.internet_environment() + + +class ProviderTests(unittest.TestCase): + def setUp(self) -> None: + self._saved = {k: os.environ.get(k) for k in ( + "CODEBUDDY_INTERNET_ENVIRONMENT", + "CODEBUDDY_BASE_URL", + "ANTHROPIC_BASE_URL", + "CODEBUDDY_PROVIDER", + )} + + def tearDown(self) -> None: + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _clear(self) -> None: + for k in self._saved: + os.environ.pop(k, None) + + def test_explicit_provider_overrides_everything(self) -> None: + self._clear() + os.environ["CODEBUDDY_PROVIDER"] = "OpenAI" + self.assertEqual(env_utils.provider(), "openai") + + def test_io_default_returns_codebuddy_io(self) -> None: + self._clear() + self.assertEqual(env_utils.provider(), "codebuddy_io") + + def test_internal_with_anthropic_url(self) -> None: + self._clear() + os.environ["CODEBUDDY_INTERNET_ENVIRONMENT"] = "internal" + os.environ["CODEBUDDY_BASE_URL"] = "https://api.anthropic.com" + self.assertEqual(env_utils.provider(), "anthropic") + + def test_internal_with_openai_url(self) -> None: + self._clear() + os.environ["CODEBUDDY_INTERNET_ENVIRONMENT"] = "internal" + os.environ["CODEBUDDY_BASE_URL"] = "https://api.openai.com/v1" + self.assertEqual(env_utils.provider(), "openai") + + def test_internal_with_unknown_url_raises_requires_explicit_provider(self) -> None: + self._clear() + os.environ["CODEBUDDY_INTERNET_ENVIRONMENT"] = "internal" + os.environ["CODEBUDDY_BASE_URL"] = "https://example.com" + with self.assertRaises(SystemExit) as ctx: + env_utils.provider() + self.assertIn("Cannot determine provider", str(ctx.exception)) + + +class LLMHostTests(unittest.TestCase): + def setUp(self) -> None: + self._saved = {k: os.environ.get(k) for k in ( + "CODEBUDDY_INTERNET_ENVIRONMENT", + "CODEBUDDY_BASE_URL", + "ANTHROPIC_BASE_URL", + "CODEBUDDY_LLM_HOST", + )} + + def tearDown(self) -> None: + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _clear(self) -> None: + for k in self._saved: + os.environ.pop(k, None) + + def test_explicit_host_wins(self) -> None: + self._clear() + os.environ["CODEBUDDY_LLM_HOST"] = "llm.example.com" + os.environ["CODEBUDDY_BASE_URL"] = "https://api.anthropic.com" + self.assertEqual(env_utils.llm_host(), "llm.example.com") + + def test_anthropic_base_url_used_for_default(self) -> None: + self._clear() + os.environ["CODEBUDDY_INTERNET_ENVIRONMENT"] = "internal" + os.environ["CODEBUDDY_PROVIDER"] = "anthropic" + os.environ["ANTHROPIC_BASE_URL"] = "https://api.anthropic.com/v1" + self.assertEqual(env_utils.llm_host(), "api.anthropic.com") + + def test_provider_default_when_nothing_set(self) -> None: + self._clear() + os.environ["CODEBUDDY_INTERNET_ENVIRONMENT"] = "internal" + os.environ["CODEBUDDY_BASE_URL"] = "https://api.anthropic.com" + self.assertEqual(env_utils.llm_host(), "api.anthropic.com") + + +class KeyInjectionTests(unittest.TestCase): + def test_anthropic_injects_x_api_key(self) -> None: + specs = env_utils.provider_inject("anthropic", "sk-test") + self.assertEqual(len(specs), 2) + self.assertEqual(specs[0], { + "header": "x-api-key", + "secret": "sk-test", + "format": "${SECRET}", + }) + self.assertEqual(specs[1]["header"], "anthropic-version") + + def test_non_anthropic_injects_bearer(self) -> None: + specs = env_utils.provider_inject("openai", "sk-test") + self.assertEqual(len(specs), 1) + self.assertEqual(specs[0], { + "header": "Authorization", + "secret": "sk-test", + "format": "Bearer ${SECRET}", + }) + + def test_provider_case_insensitive(self) -> None: + specs = env_utils.provider_inject("Anthropic", "sk") + self.assertEqual(specs[0]["header"], "x-api-key") + + +class KeyCandidatesTests(unittest.TestCase): + def setUp(self) -> None: + self._saved = {k: os.environ.get(k) for k in ( + "CODEBUDDY_BASE_URL", "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", "DEEPSEEK_API_KEY", "GEMINI_API_KEY", + "CODEBUDDY_API_KEY", "CODEBUDDY_AUTH_TOKEN", + )} + + def tearDown(self) -> None: + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _clear(self) -> None: + for k in self._saved: + os.environ.pop(k, None) + + def test_anthropic_candidates_start_with_anthropic_key(self) -> None: + self._clear() + candidates = env_utils.provider_key_candidates("anthropic") + self.assertEqual(candidates[0], "ANTHROPIC_API_KEY") + + def test_codebuddy_io_falls_back_to_anthropic_when_base_url_matches(self) -> None: + self._clear() + os.environ["CODEBUDDY_BASE_URL"] = "https://api.anthropic.com" + candidates = env_utils.provider_key_candidates("codebuddy_io") + self.assertIn("ANTHROPIC_API_KEY", candidates) + self.assertIn("CODEBUDDY_API_KEY", candidates) + + def test_codebuddy_io_does_not_fall_back_when_base_url_is_neutral(self) -> None: + self._clear() + os.environ["CODEBUDDY_BASE_URL"] = "https://example.com" + candidates = env_utils.provider_key_candidates("codebuddy_io") + self.assertNotIn("ANTHROPIC_API_KEY", candidates) + + +class RequireProviderKeyTests(unittest.TestCase): + def setUp(self) -> None: + self._saved = {k: os.environ.get(k) for k in ( + "CODEBUDDY_INTERNET_ENVIRONMENT", + "CODEBUDDY_BASE_URL", + "CODEBUDDY_API_KEY", "CODEBUDDY_AUTH_TOKEN", + "ANTHROPIC_API_KEY", "OPENAI_API_KEY", + )} + + def tearDown(self) -> None: + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _clear(self) -> None: + for k in self._saved: + os.environ.pop(k, None) + + def test_raises_when_no_keys_set(self) -> None: + self._clear() + with self.assertRaises(SystemExit): + env_utils.require_provider_key() + + def test_returns_anthropic_key_when_set(self) -> None: + self._clear() + os.environ["CODEBUDDY_INTERNET_ENVIRONMENT"] = "internal" + os.environ["CODEBUDDY_BASE_URL"] = "https://api.anthropic.com" + os.environ["ANTHROPIC_API_KEY"] = "sk-test" + self.assertEqual(env_utils.require_provider_key(), "sk-test") + + def test_returns_codebuddy_api_key_when_set(self) -> None: + self._clear() + os.environ["CODEBUDDY_API_KEY"] = "cb-test" + self.assertEqual(env_utils.require_provider_key(), "cb-test") + + +class BuildEnvTests(unittest.TestCase): + def setUp(self) -> None: + self._saved = {k: os.environ.get(k) for k in ( + "CODEBUDDY_INTERNET_ENVIRONMENT", + "CODEBUDDY_PROVIDER", + "CODEBUDDY_CONFIG_DIR", + "CODEBUDDY_BASE_URL", + "ANTHROPIC_BASE_URL", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "DEEPSEEK_API_KEY", + "GEMINI_API_KEY", + "CODEBUDDY_API_KEY", + "CODEBUDDY_AUTH_TOKEN", + "DISABLE_TELEMETRY", + "DISABLE_ERROR_REPORTING", + "DISABLE_AUTOUPDATER", + "DISABLE_FEEDBACK_COMMAND", + "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", + "CODEBUDDY_MODEL", "MAX_THINKING_TOKENS", + "CODEBUDDY_CUSTOM_HEADERS", + )} + + def tearDown(self) -> None: + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _clear(self) -> None: + for k in self._saved: + os.environ.pop(k, None) + + def test_with_secrets_includes_active_provider_key_only(self) -> None: + self._clear() + os.environ["CODEBUDDY_INTERNET_ENVIRONMENT"] = "internal" + os.environ["CODEBUDDY_BASE_URL"] = "https://api.anthropic.com" + os.environ["ANTHROPIC_API_KEY"] = "sk-ant" + os.environ["OPENAI_API_KEY"] = "sk-oai" + env = env_utils.build_codebuddy_env(include_secrets=True) + self.assertEqual(env["ANTHROPIC_API_KEY"], "sk-ant") + self.assertNotIn("OPENAI_API_KEY", env) + self.assertEqual(env["CODEBUDDY_INTERNET_ENVIRONMENT"], "internal") + self.assertEqual(env["DISABLE_TELEMETRY"], "1") + + def test_without_secrets_omits_keys(self) -> None: + self._clear() + os.environ["ANTHROPIC_API_KEY"] = "sk-ant" + env = env_utils.build_codebuddy_env(include_secrets=False) + self.assertNotIn("ANTHROPIC_API_KEY", env) + + def test_passthrough_env_forwarded(self) -> None: + self._clear() + os.environ["HTTP_PROXY"] = "http://proxy:8080" + os.environ["HTTPS_PROXY"] = "http://proxy:8080" + os.environ["CODEBUDDY_MODEL"] = "claude-sonnet-4-6" + env = env_utils.build_codebuddy_env() + self.assertEqual(env["HTTP_PROXY"], "http://proxy:8080") + self.assertEqual(env["HTTPS_PROXY"], "http://proxy:8080") + self.assertEqual(env["CODEBUDDY_MODEL"], "claude-sonnet-4-6") + + def test_proxy_userinfo_stripped_before_forwarding(self) -> None: + # Host proxy credentials must not leak into the sandbox where the LLM + # agent could read them out via env or printenv. + self._clear() + os.environ["HTTP_PROXY"] = "http://user:pass@corp-proxy:8080" + os.environ["HTTPS_PROXY"] = "https://alice:s3cret@proxy.example.com:3128" + env = env_utils.build_codebuddy_env() + self.assertEqual(env["HTTP_PROXY"], "http://corp-proxy:8080") + self.assertEqual(env["HTTPS_PROXY"], "https://proxy.example.com:3128") + self.assertNotIn("user:pass", env["HTTP_PROXY"]) + self.assertNotIn("alice:s3cret", env["HTTPS_PROXY"]) + + def test_proxy_without_userinfo_passes_through(self) -> None: + self._clear() + os.environ["HTTPS_PROXY"] = "http://proxy:8080" + env = env_utils.build_codebuddy_env() + self.assertEqual(env["HTTPS_PROXY"], "http://proxy:8080") + + def test_only_first_matching_key_forwarded(self) -> None: + # When CODEBUDDY_INTERNET_ENVIRONMENT=io (provider=codebuddy_io) with a + # custom BASE_URL containing "anthropic", provider_key_candidates() extends + # the list with ANTHROPIC_API_KEY. If both CODEBUDDY_API_KEY and + # ANTHROPIC_API_KEY are set on the host, the first match + # (CODEBUDDY_API_KEY, higher priority for codebuddy_io) enters the + # sandbox — confirming the loop breaks after the first hit. + self._clear() + os.environ["CODEBUDDY_INTERNET_ENVIRONMENT"] = "io" + os.environ["CODEBUDDY_BASE_URL"] = "https://api.anthropic.com" + os.environ["CODEBUDDY_API_KEY"] = "cb-primary" + os.environ["ANTHROPIC_API_KEY"] = "sk-ant-secondary" + env = env_utils.build_codebuddy_env(include_secrets=True) + self.assertEqual(env["CODEBUDDY_API_KEY"], "cb-primary") + self.assertNotIn("ANTHROPIC_API_KEY", env) + + +class StripUrlUserinfoTests(unittest.TestCase): + def test_strips_user_and_password(self) -> None: + self.assertEqual( + env_utils.strip_url_userinfo("http://u:p@h:8080"), + "http://h:8080", + ) + + def test_strips_user_only(self) -> None: + self.assertEqual( + env_utils.strip_url_userinfo("http://u@h:8080"), + "http://h:8080", + ) + + def test_keeps_port(self) -> None: + self.assertEqual( + env_utils.strip_url_userinfo("https://u:p@proxy.example.com:3128/path"), + "https://proxy.example.com:3128/path", + ) + + def test_handles_bare_host_with_userinfo(self) -> None: + # Some proxies are exported as just "user:p@host:port" with no scheme. + self.assertEqual( + env_utils.strip_url_userinfo("u:p@h:8080"), + "https://h:8080", + ) + + def test_no_userinfo_returns_unchanged(self) -> None: + self.assertEqual( + env_utils.strip_url_userinfo("http://proxy:8080"), + "http://proxy:8080", + ) + + def test_empty_returns_unchanged(self) -> None: + self.assertEqual(env_utils.strip_url_userinfo(""), "") + self.assertEqual(env_utils.strip_url_userinfo(" "), " ") + + def test_at_sign_in_path_only_returns_unchanged(self) -> None: + # An '@' that is not in the authority section should not be mangled. + self.assertEqual( + env_utils.strip_url_userinfo("http://proxy:8080/path@version"), + "http://proxy:8080/path@version", + ) + +class CommandBuilderTests(unittest.TestCase): + def test_minimal_command(self) -> None: + cmd = env_utils.codebuddy_command("hello") + self.assertEqual(cmd, "codebuddy -p -y hello") + + def test_continue_flag(self) -> None: + cmd = env_utils.codebuddy_command("hello", continue_session=True) + self.assertIn("-c", cmd) + + def test_resume_flag(self) -> None: + cmd = env_utils.codebuddy_command("hello", resume="abc-123") + self.assertIn("--resume abc-123", cmd) + + def test_session_id_flag(self) -> None: + cmd = env_utils.codebuddy_command("hello", session_id="uuid-1") + self.assertIn("--session-id uuid-1", cmd) + + def test_model_flag(self) -> None: + cmd = env_utils.codebuddy_command("hello", model="claude-sonnet-4-6") + self.assertIn("--model claude-sonnet-4-6", cmd) + + def test_dangerously_skip_permissions_off(self) -> None: + cmd = env_utils.codebuddy_command("hello", dangerously_skip_permissions=False) + self.assertNotIn("-y", cmd) + + def test_prompt_is_shell_quoted(self) -> None: + cmd = env_utils.codebuddy_command("hello world; rm -rf /") + # shlex.quote wraps in single quotes for safety + self.assertIn("'hello world; rm -rf /'", cmd) + + +class ShellJoinTests(unittest.TestCase): + def test_joins_non_empty_parts_with_and(self) -> None: + self.assertEqual( + env_utils.shell_join("a", "b", "c"), + "a && b && c", + ) + + def test_skips_empty_parts(self) -> None: + self.assertEqual( + env_utils.shell_join("a", "", "c"), + "a && c", + ) + + +class HomeAndWorkspaceTests(unittest.TestCase): + def setUp(self) -> None: + self._saved = {k: os.environ.get(k) for k in ( + "CODEBUDDY_CONFIG_DIR", "CODEBUDDY_WORKSPACE", + )} + + def tearDown(self) -> None: + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _clear(self) -> None: + for k in self._saved: + os.environ.pop(k, None) + + def test_default_home(self) -> None: + self._clear() + self.assertEqual(env_utils.codebuddy_home(), "/workspace/.codebuddy") + + def test_custom_home(self) -> None: + self._clear() + os.environ["CODEBUDDY_CONFIG_DIR"] = "/var/lib/codebuddy" + self.assertEqual(env_utils.codebuddy_home(), "/var/lib/codebuddy") + + def test_default_workspace(self) -> None: + self._clear() + self.assertEqual(env_utils.codebuddy_workspace(), "/workspace") + + +class HostFromUrlTests(unittest.TestCase): + def test_extracts_host(self) -> None: + self.assertEqual( + env_utils._host_from_url("https://api.anthropic.com/v1"), + "api.anthropic.com", + ) + + def test_handles_bare_hostname(self) -> None: + self.assertEqual( + env_utils._host_from_url("api.anthropic.com"), + "api.anthropic.com", + ) + + def test_empty_returns_empty(self) -> None: + self.assertEqual(env_utils._host_from_url(""), "") + self.assertEqual(env_utils._host_from_url(" "), "") + + +class OptionalAndIntEnvTests(unittest.TestCase): + def test_optional_returns_default_when_unset(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(env_utils.optional("X", "fallback"), "fallback") + + def test_optional_returns_empty_when_unset_and_no_default(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(env_utils.optional("X"), "") + + def test_required_raises_when_unset(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True): + with self.assertRaises(SystemExit): + env_utils.required("X") + + def test_int_env_returns_default_when_unset(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(env_utils.int_env("X", 42), 42) + + def test_int_env_parses_value(self) -> None: + with mock.patch.dict(os.environ, {"X": "123"}): + self.assertEqual(env_utils.int_env("X", 0), 123) + + def test_int_env_rejects_non_integer(self) -> None: + with mock.patch.dict(os.environ, {"X": "abc"}): + with self.assertRaises(SystemExit): + env_utils.int_env("X", 0) + + +class CodebuddyModelTests(unittest.TestCase): + def setUp(self) -> None: + self._saved = {k: os.environ.get(k) for k in ( + "CODEBUDDY_INTERNET_ENVIRONMENT", + "CODEBUDDY_PROVIDER", + "CODEBUDDY_MODEL", + "ANTHROPIC_MODEL", + )} + + def tearDown(self) -> None: + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _clear(self) -> None: + for k in self._saved: + os.environ.pop(k, None) + + def test_explicit_model_wins(self) -> None: + self._clear() + os.environ["CODEBUDDY_MODEL"] = "claude-opus-4-5" + self.assertEqual(env_utils.codebuddy_model(), "claude-opus-4-5") + + def test_anthropic_model_fallback(self) -> None: + # When CODEBUDDY_MODEL is unset and provider is anthropic, fall back + # to ANTHROPIC_MODEL. + self._clear() + os.environ["CODEBUDDY_PROVIDER"] = "anthropic" + os.environ["ANTHROPIC_MODEL"] = "claude-sonnet-4-7" + self.assertEqual(env_utils.codebuddy_model(), "claude-sonnet-4-7") + + def test_no_default_for_non_anthropic_raises(self) -> None: + # OpenAI and other non-Anthropic providers have no safe cross-provider + # default, so omitting both CODEBUDDY_MODEL and ANTHROPIC_MODEL raises. + self._clear() + os.environ["CODEBUDDY_PROVIDER"] = "openai" + with self.assertRaises(SystemExit): + env_utils.codebuddy_model() + + def test_anthropic_default_when_nothing_set(self) -> None: + self._clear() + os.environ["CODEBUDDY_PROVIDER"] = "anthropic" + # no CODEBUDDY_MODEL, no ANTHROPIC_MODEL → must use the shipped default + self.assertEqual(env_utils.codebuddy_model(), "claude-sonnet-4-6") + + +class ProviderKeyNameTests(unittest.TestCase): + def setUp(self) -> None: + self._saved = {k: os.environ.get(k) for k in ( + "CODEBUDDY_INTERNET_ENVIRONMENT", + "CODEBUDDY_PROVIDER", + "CODEBUDDY_BASE_URL", + "CODEBUDDY_API_KEY", + "ANTHROPIC_API_KEY", + "OPENAI_API_KEY", + "DEEPSEEK_API_KEY", + )} + + def tearDown(self) -> None: + for k, v in self._saved.items(): + if v is None: + os.environ.pop(k, None) + else: + os.environ[k] = v + + def _clear(self) -> None: + for k in self._saved: + os.environ.pop(k, None) + + def test_returns_first_set_key(self) -> None: + self._clear() + os.environ["CODEBUDDY_PROVIDER"] = "anthropic" + os.environ["ANTHROPIC_API_KEY"] = "sk-ant-test" + os.environ["CODEBUDDY_API_KEY"] = "cb-test" + self.assertEqual(env_utils.provider_key_name(), "ANTHROPIC_API_KEY") + + def test_falls_back_to_codebuddy_key(self) -> None: + self._clear() + os.environ["CODEBUDDY_PROVIDER"] = "anthropic" + os.environ["CODEBUDDY_API_KEY"] = "cb-fallback" + # ANTHROPIC_API_KEY is not set → first candidate is ANTHROPIC_API_KEY + # (always added by provider_key_candidates), which returns empty string + # from os.environ.get; the second candidate CODEBUDDY_API_KEY matches. + self.assertEqual(env_utils.provider_key_name(), "CODEBUDDY_API_KEY") + + def test_unknown_provider_returns_default(self) -> None: + self._clear() + os.environ["CODEBUDDY_PROVIDER"] = "anthropic" + # no keys set → returns the canonical first candidate name + self.assertEqual(env_utils.provider_key_name(), "ANTHROPIC_API_KEY") + + +class LoadLocalDotenvTests(unittest.TestCase): + def test_load_local_dotenv_does_not_raise(self) -> None: + # Smoke test: the function should not raise even when no .env exists. + env_utils.load_local_dotenv() + + +class PositiveIntTests(unittest.TestCase): + def test_parses_positive_integer(self) -> None: + from _codebuddy_common import positive_int + self.assertEqual(positive_int("42"), 42) + + def test_rejects_zero(self) -> None: + from _codebuddy_common import positive_int + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("0") + + def test_rejects_negative(self) -> None: + from _codebuddy_common import positive_int + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("-5") + + def test_rejects_non_integer(self) -> None: + from _codebuddy_common import positive_int + with self.assertRaises(argparse.ArgumentTypeError): + positive_int("abc") + + +class EnvPositiveIntTests(unittest.TestCase): + """Verify env-var fallback also rejects zero / negative / non-integer values. + + argparse evaluates ``default=`` before ``type=``, so the bare + ``default=int_env(...)`` pattern that used to be there would silently let + ``CODEBUDDY_SANDBOX_TIMEOUT=0`` reach the SDK. These tests pin down + ``_env_positive_int`` as the right helper. + """ + + def test_unset_returns_default(self) -> None: + with mock.patch.dict(os.environ, {}, clear=True): + self.assertEqual(env_utils._env_positive_int("X", 42), 42) + + def test_empty_returns_default(self) -> None: + with mock.patch.dict(os.environ, {"X": ""}): + self.assertEqual(env_utils._env_positive_int("X", 42), 42) + + def test_positive_passes_through(self) -> None: + with mock.patch.dict(os.environ, {"X": "120"}): + self.assertEqual(env_utils._env_positive_int("X", 1), 120) + + def test_zero_raises(self) -> None: + with mock.patch.dict(os.environ, {"X": "0"}): + with self.assertRaises(SystemExit): + env_utils._env_positive_int("X", 1) + + def test_negative_raises(self) -> None: + with mock.patch.dict(os.environ, {"X": "-5"}): + with self.assertRaises(SystemExit): + env_utils._env_positive_int("X", 1) + + def test_non_integer_raises(self) -> None: + with mock.patch.dict(os.environ, {"X": "abc"}): + with self.assertRaises(SystemExit): + env_utils._env_positive_int("X", 1) + + +class CommonHelpersTests(unittest.TestCase): + """Tests for _codebuddy_common helpers that are not exercised by env_utils.""" + + def test_ensure_success_zero_exit(self) -> None: + from _codebuddy_common import ensure_success + result = unittest.mock.MagicMock(exit_code=0) + # must not raise + ensure_success(result, "do something") + + def test_ensure_success_none_exit(self) -> None: + from _codebuddy_common import ensure_success + result = unittest.mock.MagicMock(exit_code=None, stdout="ok", stderr="") + ensure_success(result, "do something") # must not raise + + def test_ensure_success_non_zero_exit(self) -> None: + from _codebuddy_common import ensure_success + result = unittest.mock.MagicMock( + exit_code=1, stdout="out", stderr="error" + ) + with self.assertRaises(SystemExit) as ctx: + ensure_success(result, "do something") + # SystemExit raised by ensure_success carries the formatted message as + # args[0], not an integer exit code. + self.assertIn("Failed to do something (exit 1)", str(ctx.exception)) + + def test_sandbox_identifier_prefers_sandbox_id(self) -> None: + from _codebuddy_common import sandbox_identifier + # Use a plain object so the attribute lookup is a real getattr, not a + # auto-generated MagicMock attribute (which would always succeed and + # silently mask the priority path). + sandbox = types.SimpleNamespace(sandbox_id="sb-abc", id="legacy-id") + self.assertEqual(sandbox_identifier(sandbox), "sb-abc") + + def test_sandbox_identifier_falls_back_to_id(self) -> None: + from _codebuddy_common import sandbox_identifier + sandbox = types.SimpleNamespace(id="legacy-id") + self.assertEqual(sandbox_identifier(sandbox), "legacy-id") + + def test_sandbox_identifier_returns_unknown_when_neither_attr(self) -> None: + from _codebuddy_common import sandbox_identifier + sandbox = types.SimpleNamespace() + self.assertEqual(sandbox_identifier(sandbox), "unknown") + + def test_stream_writer_extracts_line(self) -> None: + from _codebuddy_common import stream_writer + import io + buf = io.StringIO() + writer = stream_writer(buf) + writer("hello") + self.assertEqual(buf.getvalue(), "hello") + + def test_stream_writer_handles_chunk_with_line_attr(self) -> None: + from _codebuddy_common import stream_writer + import io + buf = io.StringIO() + writer = stream_writer(buf) + chunk = unittest.mock.MagicMock() + chunk.line = "chunked output" + writer(chunk) + self.assertEqual(buf.getvalue(), "chunked output") + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/examples/codebuddy-integration/tests/test_mcp_server.py b/examples/codebuddy-integration/tests/test_mcp_server.py new file mode 100644 index 000000000..86f8ed6f4 --- /dev/null +++ b/examples/codebuddy-integration/tests/test_mcp_server.py @@ -0,0 +1,402 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for mcp_server.py. + +These tests are fully offline: no CubeSandbox cluster or LLM credentials are +needed. The Sandbox SDK is mocked via unittest.mock so test order cannot leak +state. +""" + +from __future__ import annotations + +import json +import os +import sys +from unittest.mock import MagicMock, patch + +import pytest + +# Clear ambient env before importing the module under test. +def _clear_env(): + for key in list(os.environ): + if key.startswith("CUBE_") or key.startswith("E2B_"): + os.environ.pop(key, None) + + +_clear_env() + +import mcp_server # noqa: E402 (import after env scrub) + + +class TestHandleRequest: + def test_initialize(self): + request = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}} + response = mcp_server.handle_request(request) + assert response["jsonrpc"] == "2.0" + assert response["id"] == 1 + assert response["result"]["protocolVersion"] == "2024-11-05" + assert response["result"]["serverInfo"]["name"] == "cubesandbox-codebuddy-mcp" + + def test_tools_list(self): + request = {"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {}} + response = mcp_server.handle_request(request) + assert response["result"]["tools"] + tool_names = [t["name"] for t in response["result"]["tools"]] + assert "sandbox_run_code" in tool_names + assert "sandbox_run_command" in tool_names + assert "sandbox_write_file" in tool_names + assert "sandbox_read_file" in tool_names + assert "sandbox_reset" in tool_names + + def test_tools_call_unknown_tool(self): + request = { + "jsonrpc": "2.0", + "id": 3, + "method": "tools/call", + "params": {"name": "unknown_tool", "arguments": {}}, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is True + assert "Unknown tool" in response["result"]["content"][0]["text"] + + def test_notifications_initialized_returns_none(self): + request = {"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}} + assert mcp_server.handle_request(request) is None + + def test_unknown_method_returns_error(self): + request = {"jsonrpc": "2.0", "id": 5, "method": "unknown.method", "params": {}} + response = mcp_server.handle_request(request) + assert response["error"]["code"] == -32601 + + def test_tools_call_missing_params_is_error(self): + request = {"jsonrpc": "2.0", "id": 5, "method": "tools/call"} + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is True + assert "Invalid arguments" in response["result"]["content"][0]["text"] + + def test_tools_call_empty_params_is_error(self): + request = {"jsonrpc": "2.0", "id": 5, "method": "tools/call", "params": {}} + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is True + + def test_run_code_pipes_through_python(self): + mock_result = {"exit_code": 0, "stdout": "42\n", "stderr": ""} + with patch.object(mcp_server, "run_command", return_value=mock_result): + request = { + "jsonrpc": "2.0", + "id": 6, + "method": "tools/call", + "params": {"name": "sandbox_run_code", "arguments": {"code": "print(42)"}}, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is False + assert "exit_code: 0" in response["result"]["content"][0]["text"] + + def test_command_failure_preserves_exit_code_and_both_streams(self): + mock_result = {"exit_code": 127, "stdout": "", "stderr": "command not found"} + with patch.object(mcp_server, "run_command", return_value=mock_result): + request = { + "jsonrpc": "2.0", + "id": 7, + "method": "tools/call", + "params": {"name": "sandbox_run_command", "arguments": {"command": "ls /bad"}}, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is True + text = response["result"]["content"][0]["text"] + assert "exit_code: 127" in text + assert "command not found" in text + + def test_write_file_reports_byte_count(self): + mock_files = MagicMock() + mock_sandbox = MagicMock() + mock_sandbox.files = mock_files + + with patch.object(mcp_server, "_get_sandbox", return_value=mock_sandbox): + request = { + "jsonrpc": "2.0", + "id": 8, + "method": "tools/call", + "params": { + "name": "sandbox_write_file", + "arguments": {"path": "/workspace/test.txt", "content": "hello world"}, + }, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is False + assert "11 bytes" in response["result"]["content"][0]["text"] + + def test_write_file_rejects_invalid_path(self): + request = { + "jsonrpc": "2.0", + "id": 9, + "method": "tools/call", + "params": { + "name": "sandbox_write_file", + "arguments": {"path": "/etc/passwd", "content": "malicious"}, + }, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is True + assert "Invalid arguments" in response["result"]["content"][0]["text"] + + def test_write_file_rejects_oversized_content(self): + large_content = "x" * (mcp_server.MAX_CONTENT_LENGTH + 1) + request = { + "jsonrpc": "2.0", + "id": 10, + "method": "tools/call", + "params": { + "name": "sandbox_write_file", + "arguments": {"path": "/workspace/large.txt", "content": large_content}, + }, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is True + assert "exceeds maximum length" in response["result"]["content"][0]["text"] + + def test_read_file_success(self): + mock_sandbox = MagicMock() + mock_sandbox.files.read.return_value = "file content" + + with patch.object(mcp_server, "_get_sandbox", return_value=mock_sandbox): + request = { + "jsonrpc": "2.0", + "id": 11, + "method": "tools/call", + "params": {"name": "sandbox_read_file", "arguments": {"path": "/workspace/test.py"}}, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is False + assert "file content" in response["result"]["content"][0]["text"] + + def test_read_file_rejects_invalid_path(self): + request = { + "jsonrpc": "2.0", + "id": 12, + "method": "tools/call", + "params": {"name": "sandbox_read_file", "arguments": {"path": "/root/.ssh/id_rsa"}}, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is True + assert "Invalid arguments" in response["result"]["content"][0]["text"] + + def test_run_code_rejects_empty_code(self): + request = { + "jsonrpc": "2.0", + "id": 13, + "method": "tools/call", + "params": {"name": "sandbox_run_code", "arguments": {"code": " "}}, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is True + assert "empty" in response["result"]["content"][0]["text"] + + def test_run_code_rejects_oversized_code(self): + large_code = "x" * (mcp_server.MAX_CODE_LENGTH + 1) + request = { + "jsonrpc": "2.0", + "id": 14, + "method": "tools/call", + "params": {"name": "sandbox_run_code", "arguments": {"code": large_code}}, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is True + assert "exceeds maximum length" in response["result"]["content"][0]["text"] + + def test_run_command_rejects_empty_command(self): + request = { + "jsonrpc": "2.0", + "id": 15, + "method": "tools/call", + "params": {"name": "sandbox_run_command", "arguments": {"command": ""}}, + } + response = mcp_server.handle_request(request) + assert response["result"]["isError"] is True + + def test_reset_cleans_up_sandbox(self): + mock_sandbox = MagicMock() + mock_sandbox.kill = MagicMock() + mcp_server._sandbox = mock_sandbox + + request = { + "jsonrpc": "2.0", + "id": 16, + "method": "tools/call", + "params": {"name": "sandbox_reset", "arguments": {}}, + } + response = mcp_server.handle_request(request) + mock_sandbox.kill.assert_called_once() + assert mcp_server._sandbox is None + + +class TestReadMcpMessage: + def test_valid_message(self, monkeypatch): + import io + + fake_stdin = io.StringIO('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n') + monkeypatch.setattr(sys, "stdin", fake_stdin) + + msg = mcp_server._read_mcp_message() + assert msg["method"] == "initialize" + + def test_invalid_json_returns_none(self, monkeypatch): + import io + + fake_stdin = io.StringIO("not json\n") + monkeypatch.setattr(sys, "stdin", fake_stdin) + + msg = mcp_server._read_mcp_message() + assert msg is None + + def test_blank_line_returns_none(self, monkeypatch): + import io + + fake_stdin = io.StringIO("\n") + monkeypatch.setattr(sys, "stdin", fake_stdin) + + msg = mcp_server._read_mcp_message() + assert msg is None + + def test_eof_raises_eoferror(self, monkeypatch): + import io + + fake_stdin = io.StringIO("") + monkeypatch.setattr(sys, "stdin", fake_stdin) + + with pytest.raises(EOFError): + mcp_server._read_mcp_message() + + def test_oversized_message_returns_none(self, monkeypatch): + import io + + oversized = "x" * (mcp_server.MAX_MESSAGE_LENGTH + 1) + "\n" + fake_stdin = io.StringIO(oversized) + monkeypatch.setattr(sys, "stdin", fake_stdin) + + msg = mcp_server._read_mcp_message() + assert msg is None + + +class TestValidation: + def test_validate_path_allows_workspace(self): + result = mcp_server._validate_path("/workspace/test.py") + assert result is None + + def test_validate_path_allows_tmp(self): + result = mcp_server._validate_path("/tmp/test.py") + assert result is None + + def test_validate_path_rejects_etc(self): + result = mcp_server._validate_path("/etc/passwd") + assert result is not None + assert "must be within" in result + + def test_validate_path_rejects_root(self): + result = mcp_server._validate_path("/root/.ssh") + assert result is not None + + def test_validate_path_rejects_empty(self): + result = mcp_server._validate_path("") + assert result is not None + + def test_validate_path_rejects_non_string(self): + result = mcp_server._validate_path(123) + assert result is not None + + def test_validate_timeout_bounded(self): + assert mcp_server._validate_timeout(600) == 300 # Capped at MAX_TIMEOUT + assert mcp_server._validate_timeout(100) == 100 # Within range + assert mcp_server._validate_timeout(0) == 1 # Minimum 1 + assert mcp_server._validate_timeout(None) == 300 # Default + + def test_validate_string_length_rejects_oversized(self): + result = mcp_server._validate_string_length("x" * 1000, 500, "test") + assert result is not None + assert "exceeds maximum" in result + + def test_validate_string_length_allows_valid(self): + result = mcp_server._validate_string_length("hello", 100, "test") + assert result is None + + +class TestGetSandbox: + def test_refreshes_ttl_on_subsequent_call(self): + cached = MagicMock() + cached.set_timeout = MagicMock() + mcp_server._sandbox = cached + + sb = mcp_server._get_sandbox() + cached.set_timeout.assert_called_once() + assert sb is cached + + def test_cleanup_kills_and_clears_cached_sandbox(self): + mock_sb = MagicMock() + mock_sb.kill = MagicMock() + mcp_server._sandbox = mock_sb + + mcp_server._cleanup_sandbox() + + mock_sb.kill.assert_called_once() + assert mcp_server._sandbox is None + + def test_cleanup_is_idempotent(self): + mcp_server._sandbox = None + mcp_server._cleanup_sandbox() # Must not raise + + +def test_main_exits_cleanly_on_eof(monkeypatch): + import io + + fake_stdin = io.StringIO("") + monkeypatch.setattr(sys, "stdin", fake_stdin) + monkeypatch.setattr(sys, "stdout", io.StringIO()) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + mcp_server._sandbox = None + mcp_server.main() # must not raise + + +def test_main_uses_newline_delimited_json_and_cleans_up(monkeypatch): + import io + + # Send one initialize request then EOF + fake_stdin = io.StringIO('{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}\n') + output = io.StringIO() + monkeypatch.setattr(sys, "stdin", fake_stdin) + monkeypatch.setattr(sys, "stdout", output) + monkeypatch.setattr(sys, "stderr", io.StringIO()) + + mock_sb = MagicMock() + mcp_server._sandbox = mock_sb + + mcp_server.main() + + lines = [l for l in output.getvalue().strip().split("\n") if l] + assert len(lines) == 1 + resp = json.loads(lines[0]) + assert resp["result"]["serverInfo"]["name"] == "cubesandbox-codebuddy-mcp" + + +def test_format_command_result_empty_both_streams(): + result = {"exit_code": 0, "stdout": "", "stderr": ""} + formatted = mcp_server._format_command_result(result) + assert "exit_code: 0" in formatted + assert "(no output)" in formatted + + +def test_format_command_result_stderr_only(): + result = {"exit_code": 1, "stdout": "", "stderr": "error"} + formatted = mcp_server._format_command_result(result) + assert "exit_code: 1" in formatted + assert "stderr:" in formatted + assert "stdout:" not in formatted + + +def test_format_command_result_stdout_only(): + result = {"exit_code": 0, "stdout": "hello", "stderr": ""} + formatted = mcp_server._format_command_result(result) + assert "exit_code: 0" in formatted + assert "stdout:" in formatted + assert "stderr:" not in formatted diff --git a/examples/codebuddy-integration/tests/test_sandbox_exec.py b/examples/codebuddy-integration/tests/test_sandbox_exec.py new file mode 100644 index 000000000..a50f09997 --- /dev/null +++ b/examples/codebuddy-integration/tests/test_sandbox_exec.py @@ -0,0 +1,407 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Tests for sandbox_exec.py. + +These tests are fully offline: no CubeSandbox cluster or LLM credentials are +needed. The Sandbox SDK is mocked via unittest.mock so test order cannot leak +state. +""" + +from __future__ import annotations + +import os +import stat +import sys +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# Import the module under test; apply env scrubbing at import time so no +# ambient CUBE_* / E2B_* state leaks between test runs. +def _clear_env(): + for key in list(os.environ): + if key.startswith("CUBE_") or key.startswith("E2B_"): + os.environ.pop(key, None) + + +_clear_env() + +import sandbox_exec # noqa: E402 (import after env scrub) + +# Import Sandbox directly for mocking at class level +from e2b_code_interpreter import Sandbox + + +class TestExecApi: + def test_exec_code_returns_stdout_on_success(self): + mock_result = MagicMock() + mock_result.exit_code = 0 + mock_result.stdout = "42\n" + mock_sandbox = MagicMock() + mock_sandbox.commands.run.return_value = mock_result + + with patch.object(sandbox_exec, "_get_sandbox", return_value=mock_sandbox): + output = sandbox_exec.exec_code("print(1+1)") + assert output == "42\n" + + def test_exec_code_returns_stderr_on_failure(self): + mock_result = MagicMock() + mock_result.exit_code = 1 + mock_result.stderr = "SyntaxError\n" + mock_sandbox = MagicMock() + mock_sandbox.commands.run.return_value = mock_result + + with patch.object(sandbox_exec, "_get_sandbox", return_value=mock_sandbox): + output = sandbox_exec.exec_code("raise Exception()") + assert "[error]" in output + assert "exit code 1" in output # Sanitized error message + + def test_exec_code_installs_pip_first(self): + results = [] + + def run_side_effect(cmd, timeout=None): + results.append(cmd) + mock_result = MagicMock() + mock_result.exit_code = 0 + mock_result.stdout = "ok\n" + return mock_result + + mock_sandbox = MagicMock() + mock_sandbox.commands.run.side_effect = run_side_effect + + with patch.object(sandbox_exec, "_get_sandbox", return_value=mock_sandbox): + sandbox_exec.exec_code("print(1)", pip_packages=["requests"]) + + assert len(results) == 2 + assert "pip install" in results[0] + assert "requests" in results[0] + + def test_exec_code_rejects_invalid_pip_package_name(self): + # Package names with shell metacharacters should be rejected + with patch.object(sandbox_exec, "_get_sandbox"): + output = sandbox_exec.exec_code("print(1)", pip_packages=["pkg; rm -rf /"]) + assert "[error]" in output + assert "invalid pip package name" in output + + def test_exec_code_rejects_non_string_pip_package(self): + with patch.object(sandbox_exec, "_get_sandbox"): + output = sandbox_exec.exec_code("print(1)", pip_packages=[None]) # type: ignore + assert "[error]" in output + assert "invalid pip package name" in output + + def test_exec_code_reports_pip_error(self): + mock_result = MagicMock() + mock_result.exit_code = 1 + mock_result.stderr = "pip error details" + + mock_sandbox = MagicMock() + mock_sandbox.commands.run.return_value = mock_result + + with patch.object(sandbox_exec, "_get_sandbox", return_value=mock_sandbox): + output = sandbox_exec.exec_code("print(1)", pip_packages=["nonexistent-pkg-xyz"]) + + assert "[pip error]" in output + assert "exit 1" in output # Sanitized + + def test_exec_file_works_with_allowed_path(self): + mock_result = MagicMock() + mock_result.exit_code = 0 + mock_result.stdout = "hello\n" + + mock_sandbox = MagicMock() + mock_sandbox.commands.run.return_value = mock_result + + # Create a temp file and add its parent to allowed dirs + with tempfile.TemporaryDirectory() as tmpdir: + test_file = Path(tmpdir) / "test.py" + test_file.write_text("print('hello')") + + # Override allowed dirs for this test + original_dirs = sandbox_exec._ALLOWED_READ_DIRS.copy() + sandbox_exec._ALLOWED_READ_DIRS.append(Path(tmpdir)) + + try: + with patch.object(sandbox_exec, "_get_sandbox", return_value=mock_sandbox): + output = sandbox_exec.exec_file(str(test_file)) + assert output == "hello\n" + mock_sandbox.files.write.assert_called_once() + finally: + sandbox_exec._ALLOWED_READ_DIRS[:] = original_dirs + + def test_exec_file_rejects_path_outside_allowed_dirs(self): + with patch.object(sandbox_exec, "_get_sandbox"): + # /etc is definitely not in allowed dirs + output = sandbox_exec.exec_file("/etc/passwd") + assert "[error]" in output + assert "not in allowed directories" in output + + def test_exec_file_rejects_symlink_outside_dirs(self): + # The symlink check happens after path prefix check, so a symlink + # outside allowed dirs gets rejected with "not in allowed directories" + with patch.object(sandbox_exec, "_get_sandbox"): + output = sandbox_exec.exec_file("/etc/hostname") + assert "[error]" in output + + def test_exec_file_handles_symlink_inside_allowed_dirs(self): + # Test the symlink rejection by directly checking the validation logic + with tempfile.TemporaryDirectory() as tmpdir: + target = Path(tmpdir) / "target.py" + target.write_text("print('hello')") + link = Path(tmpdir) / "link.py" + link.symlink_to(target) + + # The symlink is inside allowed dirs, but should still be rejected + # because symlinks are not allowed regardless of directory + original_dirs = sandbox_exec._ALLOWED_READ_DIRS.copy() + sandbox_exec._ALLOWED_READ_DIRS.append(Path(tmpdir)) + + try: + # Mock _get_sandbox to avoid actual sandbox creation + mock_sb = MagicMock() + mock_result = MagicMock() + mock_result.exit_code = 0 + mock_result.stdout = "ok" + mock_sb.commands.run.return_value = mock_result + + with patch.object(sandbox_exec, "_get_sandbox", return_value=mock_sb): + output = sandbox_exec.exec_file(str(link)) + finally: + sandbox_exec._ALLOWED_READ_DIRS[:] = original_dirs + + assert "[error]" in output + assert "symlink" in output + + def test_exec_file_handles_unicode_decode_error(self): + with tempfile.TemporaryDirectory() as tmpdir: + path = Path(tmpdir) / "binary.bin" + path.write_bytes(b"\x80\x81\x82") # Invalid UTF-8 + + # Add to allowed dirs + original_dirs = sandbox_exec._ALLOWED_READ_DIRS.copy() + sandbox_exec._ALLOWED_READ_DIRS.append(Path(tmpdir)) + + try: + with patch.object(sandbox_exec, "_get_sandbox"): + output = sandbox_exec.exec_file(str(path)) + finally: + sandbox_exec._ALLOWED_READ_DIRS[:] = original_dirs + + assert "[error]" in output + assert "UTF-8" in output + + def test_exec_cmd_returns_stdout_on_success(self): + mock_result = MagicMock() + mock_result.exit_code = 0 + mock_result.stdout = "total 0\n" + + mock_sandbox = MagicMock() + mock_sandbox.commands.run.return_value = mock_result + + with patch.object(sandbox_exec, "_get_sandbox", return_value=mock_sandbox): + output = sandbox_exec.exec_cmd("ls -la /workspace") + + assert output == "total 0\n" + + def test_exec_cmd_returns_stderr_on_failure(self): + mock_result = MagicMock() + mock_result.exit_code = 1 + mock_result.stderr = "not found\n" + + mock_sandbox = MagicMock() + mock_sandbox.commands.run.return_value = mock_result + + with patch.object(sandbox_exec, "_get_sandbox", return_value=mock_sandbox): + output = sandbox_exec.exec_cmd("ls /nonexistent") + + assert "[error]" in output + assert "exit code 1" in output # Sanitized + + def test_exec_cmd_rejects_empty_command(self): + with patch.object(sandbox_exec, "_get_sandbox"): + output = sandbox_exec.exec_cmd("") + assert "[error]" in output + assert "empty" in output + + def test_exec_cmd_rejects_oversized_command(self): + long_cmd = "x" * (sandbox_exec.MAX_COMMAND_LENGTH + 1) + with patch.object(sandbox_exec, "_get_sandbox"): + output = sandbox_exec.exec_cmd(long_cmd) + assert "[error]" in output + assert "exceeds" in output + + def test_exec_cmd_rejects_non_string(self): + output = sandbox_exec.exec_cmd(123) # type: ignore + assert "[error]" in output + + +class TestGetSandbox: + def test_raises_when_template_id_unset(self): + original_id = sandbox_exec.TEMPLATE_ID + sandbox_exec.TEMPLATE_ID = "" + try: + with pytest.raises(SystemExit): + sandbox_exec._get_sandbox() + finally: + sandbox_exec.TEMPLATE_ID = original_id + + def test_reconnects_from_session_file(self): + mock_sandbox = MagicMock() + mock_sandbox.sandbox_id = "reconn-456" + mock_sandbox.set_timeout = MagicMock() + + with patch.object(sandbox_exec, "_read_session", return_value="reconn-456"): + with patch.object( + Sandbox, "connect", return_value=mock_sandbox + ): + sandbox_exec._sandbox = None + sb = sandbox_exec._get_sandbox() + + assert sb.sandbox_id == "reconn-456" + + def test_reuses_in_process_cache(self): + cached = MagicMock() + cached.set_timeout = MagicMock() + sandbox_exec._sandbox = cached + + sb = sandbox_exec._get_sandbox() + assert sb is cached + cached.set_timeout.assert_called_once() + + def test_session_helpers_reject_symlink(self): + with tempfile.TemporaryDirectory() as tmpdir: + symlink = Path(tmpdir) / "symlink" + target = Path(tmpdir) / "target" + target.write_text("sbid") + symlink.symlink_to(target) + + original_session_file = sandbox_exec.SESSION_FILE + sandbox_exec.SESSION_FILE = symlink + try: + result = sandbox_exec._read_session() + assert result is None + finally: + sandbox_exec.SESSION_FILE = original_session_file + + def test_write_session_creates_valid_session_file(self): + with tempfile.TemporaryDirectory() as tmpdir: + session_file = Path(tmpdir) / "session" + + sandbox_exec.SESSION_FILE = session_file + sandbox_exec._write_session("test-sandbox-id") + + # Verify the file contains the correct sandbox ID + assert session_file.read_text() == "test-sandbox-id" + # Verify permissions are 0600 + assert session_file.stat().st_mode & 0o777 == 0o600 + + +class TestRStderr: + def test_returns_stderr_when_present(self): + mock_result = MagicMock() + mock_result.stderr = "error message" + + result = sandbox_exec.r_stderr(mock_result) + assert result == "error message" + + def test_returns_exit_code_when_no_stderr(self): + mock_result = MagicMock() + mock_result.stderr = None + mock_result.exit_code = 42 + + result = sandbox_exec.r_stderr(mock_result) + assert result == "exit code 42" + + def test_returns_unknown_error_when_no_attrs(self): + mock_result = MagicMock(spec=[]) + + result = sandbox_exec.r_stderr(mock_result) + assert result == "unknown error" + + def test_truncates_long_stderr(self): + mock_result = MagicMock() + mock_result.stderr = "x" * 3000 + + result = sandbox_exec.r_stderr(mock_result) + assert len(result) <= 2048 + + +class TestCleanup: + def test_kills_sandbox_and_clears_session(self): + mock_sb = MagicMock() + mock_sb.kill = MagicMock() + sandbox_exec._sandbox = mock_sb + + with tempfile.TemporaryDirectory() as tmpdir: + session_file = Path(tmpdir) / f"cubesandbox_codebuddy_session_{os.getuid()}" + session_file.write_text("sb-to-kill") + original_session_file = sandbox_exec.SESSION_FILE + sandbox_exec.SESSION_FILE = session_file + try: + sandbox_exec.cleanup() + + mock_sb.kill.assert_called_once() + assert sandbox_exec._sandbox is None + assert not session_file.exists() + finally: + sandbox_exec.SESSION_FILE = original_session_file + + def test_no_error_when_no_sandbox(self): + sandbox_exec._sandbox = None + sandbox_exec.cleanup() # must not raise + + def test_no_error_when_kill_fails(self): + mock_sb = MagicMock() + mock_sb.kill.side_effect = Exception("kill failed") + sandbox_exec._sandbox = mock_sb + sandbox_exec.cleanup() # must not raise + assert sandbox_exec._sandbox is None + + def test_clears_session_even_without_sandbox(self): + sandbox_exec._sandbox = None + + with tempfile.TemporaryDirectory() as tmpdir: + session_file = Path(tmpdir) / f"cubesandbox_codebuddy_session_{os.getuid()}" + session_file.write_text("orphan-session") + original_session_file = sandbox_exec.SESSION_FILE + sandbox_exec.SESSION_FILE = session_file + try: + sandbox_exec.cleanup() + assert not session_file.exists() + finally: + sandbox_exec.SESSION_FILE = original_session_file + + def test_cleanup_is_idempotent(self): + sandbox_exec._sandbox = None + sandbox_exec.cleanup() # must not raise + sandbox_exec.cleanup() # must not raise + + +class TestPathValidation: + def test_allows_explicitly_configured_dirs(self): + with tempfile.TemporaryDirectory() as tmpdir: + test_file = Path(tmpdir) / "test.py" + test_file.write_text("print('hello')") + + mock_sandbox = MagicMock() + mock_result = MagicMock() + mock_result.exit_code = 0 + mock_result.stdout = "hello\n" + mock_sandbox.commands.run.return_value = mock_result + + original_dirs = sandbox_exec._ALLOWED_READ_DIRS.copy() + sandbox_exec._ALLOWED_READ_DIRS.append(Path(tmpdir)) + + try: + with patch.object(sandbox_exec, "_get_sandbox", return_value=mock_sandbox): + output = sandbox_exec.exec_file(str(test_file)) + assert output == "hello\n" + finally: + sandbox_exec._ALLOWED_READ_DIRS[:] = original_dirs + + def test_rejects_absolute_path_not_in_allowed_dirs(self): + with patch.object(sandbox_exec, "_get_sandbox"): + output = sandbox_exec.exec_file("/var/log/messages") + assert "[error]" in output