diff --git a/docs/guide/integrations/index.md b/docs/guide/integrations/index.md index f40867fbc..eeb196043 100644 --- a/docs/guide/integrations/index.md +++ b/docs/guide/integrations/index.md @@ -47,4 +47,5 @@ lang: en-US | Title | Author | Date | Tags | | --- | --- | --- | --- | +| [OpenCode Integration Guide](./opencode.md) | Sam-Hui-dot | 2026-07-05 | integration, opencode, coding-agent, agent | | [Pi Agent Integration Guide](./pi-agent.md) | chaojixinren | 2026-07-01 | integration, pi-agent, coding-agent, agent | diff --git a/docs/guide/integrations/opencode.md b/docs/guide/integrations/opencode.md new file mode 100644 index 000000000..7826a1cd1 --- /dev/null +++ b/docs/guide/integrations/opencode.md @@ -0,0 +1,245 @@ +--- +title: OpenCode Integration Guide +author: Sam-Hui-dot +date: 2026-07-05 +tags: + - integration + - opencode + - coding-agent + - agent +lang: en-US +--- + +# OpenCode Integration Guide + +[中文文档](../../zh/guide/integrations/opencode.md) + +Run [OpenCode](https://opencode.ai/), an open-source terminal coding agent, +inside CubeSandbox MicroVMs. This guide pairs with the runnable +[`examples/opencode-integration`](https://github.com/TencentCloud/CubeSandbox/tree/master/examples/opencode-integration) +project. + +## Integration Target and Version + +| Component | Version | +|---|---| +| OpenCode CLI | `opencode-ai` pinned by `OPENCODE_VERSION` build arg | +| Node.js | 22, installed through NodeSource | +| CubeSandbox base image | `ghcr.io/tencentcloud/cubesandbox-base:2026.16` | +| Host SDKs | `e2b>=2.4.1`, `cubesandbox>=0.3.0` | +| CubeSandbox platform | `>=0.3.0` for pause/resume; CubeEgress required for header injection | + +OpenCode's documented non-interactive mode is `opencode run [message..]`. The +example uses: + +```bash +opencode run --model --dir /workspace --auto --format json "" +``` + +## Why Run OpenCode Inside CubeSandbox + +OpenCode can edit files, execute commands, install packages, and call LLM APIs. +Running it in CubeSandbox gives each coding task a disposable MicroVM boundary: + +| Concern | CubeSandbox pattern | +|---|---| +| Isolation | One MicroVM per task or session | +| Reproducibility | Boot from a pinned template image | +| Fast reuse | Pause/resume preserves `/workspace` and OpenCode state | +| Network control | Default-deny egress with explicit LLM host allow rules | +| Secret handling | CubeEgress can inject API keys on the wire | +| Reviewability | Host scripts verify generated files and test results | + +## Prerequisites + +- A running CubeSandbox deployment; CubeAPI reachable at `http://:3000`. +- `cubemastercli` on `$PATH`. +- Docker plus a registry reachable from Cube nodes. +- Python 3.10+ for host-side driver scripts. +- An LLM provider API key supported by OpenCode. + +## Integration Steps + +### 1. Build the template image + +```bash +cd examples/opencode-integration +docker build --platform linux/amd64 \ + -t /opencode-cube:latest . +docker push /opencode-cube:latest +``` + +The image installs Node.js, OpenCode, `git`, `ripgrep`, Python, and small +inspection tools on top of the CubeSandbox base image. + +### 2. Register the Cube template + +```bash +cubemastercli tpl create-from-image \ + --image /opencode-cube:latest \ + --writable-layer-size 4G \ + --expose-port 49983 \ + --probe 49983 \ + --probe-path /health +``` + +The inherited CubeSandbox base entrypoint keeps envd on port `49983` and serves +the standard `/health` endpoint. + +### 3. Configure the host driver + +```bash +cp .env.example .env +pip install -r requirements.txt +``` + +Set: + +```bash +E2B_API_URL=http://:3000 +E2B_API_KEY=e2b_000000 +CUBE_TEMPLATE_ID= +OPENCODE_MODEL=openai/gpt-4.1-mini +OPENAI_API_KEY= +``` + +For custom endpoints, set `OPENCODE_BASE_URL`. If it is not set, the scripts +also accept provider-specific variables such as `OPENAI_BASE_URL`. The scripts +write an `opencode.json` in the sandbox workspace with +`provider..options.baseURL`. + +When validating against the repository's `dev-env/` VM, set +`CUBE_DEV_SIDECAR=1` so the host-side SDK routes sandbox traffic through +`examples/e2b-dev-sidecar`. + +`network_policy.py` uses the native `cubesandbox` SDK. When running that script +against `dev-env/`, also set: + +```bash +CUBE_API_URL=http://127.0.0.1:13000 +CUBE_PROXY_NODE_IP=127.0.0.1 +CUBE_PROXY_PORT_HTTP=11080 +``` + +### 4. Run the one-shot coding task + +```bash +python3 run_opencode.py --dry-run +python3 run_opencode.py +``` + +The script seeds a small Python project, runs OpenCode, then verifies: + +- `calculator.add` was implemented. +- `python3 -m unittest discover -v` passes. +- `result.md` contains the marker `OPENCODE_CUBE_OK`. + +### 5. Demonstrate session persistence + +```bash +python3 resume_opencode.py +``` + +The first turn writes `plan.md`, pauses the sandbox, reconnects to the same +sandbox, verifies `/workspace` and OpenCode's state directory survived, then +continues the task and checks `OPENCODE_RESUME_OK`. + +### 6. Restrict egress and inject credentials + +```bash +python3 network_policy.py +``` + +The strict path uses the native `cubesandbox` SDK: + +- `allow_internet_access=False` makes egress default-deny. +- A CubeEgress rule allows only the configured LLM host. +- `Inject` attaches the real provider credential as an HTTP header. +- The sandbox process receives only a placeholder key. + +Set `OPENCODE_LLM_HOST` when the LLM API host differs from the provider +default, especially when using `OPENCODE_BASE_URL` or a provider-specific +`_BASE_URL`: + +```bash +OPENCODE_LLM_HOST=api.openai.com python3 network_policy.py +``` + +Use `--skip-agent` to verify policy behavior without spending LLM tokens: + +```bash +python3 network_policy.py --skip-agent +``` + +## Key Code Snippets + +Headless OpenCode command construction: + +```python +opencode_command( + prompt, + workspace="/workspace", + model="openai/gpt-4.1-mini", + title="cube-opencode-demo", +) +``` + +Restricted egress rule: + +```python +Rule( + name="allow_openai_llm", + match=Match(scheme="https", sni="api.openai.com", host="api.openai.com"), + action=Action( + allow=True, + audit="metadata", + inject=[Inject( + header="Authorization", + secret=secret, + format="Bearer ${SECRET}", + )], + ), +) +``` + +## Caveats + +- OpenCode still needs a real LLM provider key for live runs. +- `--auto` lets OpenCode perform edits and commands without interactive approval; + use it only inside the isolated sandbox boundary. +- Direct env injection in `run_opencode.py` is convenient for local validation + and known secret values are redacted from failure output, but shared clusters + should prefer `network_policy.py`. +- Custom provider support depends on OpenCode's provider configuration and the + target endpoint's compatibility with the selected model. +- The example Dockerfile uses the NodeSource setup script for readability. + Production images should pin and verify Node.js package sources or mirror the + artifacts internally. + +## Validation + +From `examples/opencode-integration`: + +```bash +python3 -m py_compile env_utils.py _opencode_common.py run_opencode.py resume_opencode.py network_policy.py +python3 -m unittest discover . -p 'test_*.py' +bash -n build-template.sh +docker build --platform linux/amd64 -t opencode-cube:verify . +docker run --rm opencode-cube:verify opencode --version +``` + +Live CubeSandbox validation: + +```bash +python3 run_opencode.py +python3 resume_opencode.py +python3 network_policy.py --skip-agent +python3 network_policy.py +``` + +## References + +- [OpenCode CLI documentation](https://opencode.ai/docs/cli/) +- [OpenCode provider documentation](https://opencode.ai/docs/providers/) +- [CubeSandbox OpenCode example](https://github.com/TencentCloud/CubeSandbox/tree/master/examples/opencode-integration) +- [CubeSandbox Security Proxy](../security-proxy.md) diff --git a/docs/zh/guide/integrations/index.md b/docs/zh/guide/integrations/index.md index c96579ce6..7f3893cbb 100644 --- a/docs/zh/guide/integrations/index.md +++ b/docs/zh/guide/integrations/index.md @@ -47,4 +47,5 @@ lang: zh-CN | 标题 | 作者 | 日期 | 标签 | | --- | --- | --- | --- | +| [OpenCode 集成指南](./opencode.md) | Sam-Hui-dot | 2026-07-05 | integration, opencode, coding-agent, agent | | [Pi Agent 集成指南](./pi-agent.md) | chaojixinren | 2026-07-01 | integration, pi-agent, coding-agent, agent | diff --git a/docs/zh/guide/integrations/opencode.md b/docs/zh/guide/integrations/opencode.md new file mode 100644 index 000000000..0f2f54169 --- /dev/null +++ b/docs/zh/guide/integrations/opencode.md @@ -0,0 +1,233 @@ +--- +title: OpenCode 集成指南 +author: Sam-Hui-dot +date: 2026-07-05 +tags: + - integration + - opencode + - coding-agent + - agent +lang: zh-CN +--- + +# OpenCode 集成指南 + +[English](../../../guide/integrations/opencode.md) + +本指南介绍如何在 CubeSandbox MicroVM 中运行 +[OpenCode](https://opencode.ai/) 这类开源终端编码 Agent。可运行示例位于 +[`examples/opencode-integration`](https://github.com/TencentCloud/CubeSandbox/tree/master/examples/opencode-integration)。 + +## 集成目标与版本 + +| 组件 | 版本 | +|---|---| +| OpenCode CLI | 通过 `OPENCODE_VERSION` build arg 固定的 `opencode-ai` | +| Node.js | 22,通过 NodeSource 安装 | +| CubeSandbox 基础镜像 | `ghcr.io/tencentcloud/cubesandbox-base:2026.16` | +| 宿主端 SDK | `e2b>=2.4.1`、`cubesandbox>=0.3.0` | +| CubeSandbox 平台 | pause/resume 需要 `>=0.3.0`;header 注入需要 CubeEgress | + +OpenCode 官方的非交互模式是 `opencode run [message..]`。本示例使用: + +```bash +opencode run --model --dir /workspace --auto --format json "" +``` + +## 为什么放进 CubeSandbox + +OpenCode 可以编辑文件、执行命令、安装依赖并访问 LLM API。放入 CubeSandbox 后, +每次编码任务都拥有独立 MicroVM 边界: + +| 关注点 | CubeSandbox 模式 | +|---|---| +| 隔离性 | 每个任务或会话一个 MicroVM | +| 可复现 | 从固定模板镜像启动 | +| 快速复用 | pause/resume 保留 `/workspace` 和 OpenCode 状态 | +| 网络控制 | 默认拒绝出网,只放行 LLM host | +| 密钥处理 | CubeEgress 在链路上注入 API key | +| 可审查 | 宿主端脚本验证生成文件和测试结果 | + +## 前置条件 + +- 已部署 CubeSandbox,CubeAPI 可通过 `http://:3000` 访问。 +- `cubemastercli` 已在 `$PATH` 中。 +- Docker 和 Cube 节点可拉取的镜像仓库。 +- 宿主端 Python 3.10+。 +- 一个 OpenCode 支持的 LLM provider API key。 + +## 集成步骤 + +### 1. 构建模板镜像 + +```bash +cd examples/opencode-integration +docker build --platform linux/amd64 \ + -t /opencode-cube:latest . +docker push /opencode-cube:latest +``` + +镜像基于 CubeSandbox base,安装 Node.js、OpenCode、`git`、`ripgrep`、Python +以及少量排查工具。 + +### 2. 注册 Cube 模板 + +```bash +cubemastercli tpl create-from-image \ + --image /opencode-cube:latest \ + --writable-layer-size 4G \ + --expose-port 49983 \ + --probe 49983 \ + --probe-path /health +``` + +基础镜像入口会保持 envd 监听 `49983`,并提供标准 `/health` endpoint。 + +### 3. 配置宿主端脚本 + +```bash +cp .env.example .env +pip install -r requirements.txt +``` + +填写: + +```bash +E2B_API_URL=http://:3000 +E2B_API_KEY=e2b_000000 +CUBE_TEMPLATE_ID= +OPENCODE_MODEL=openai/gpt-4.1-mini +OPENAI_API_KEY= +``` + +自定义端点可设置 `OPENCODE_BASE_URL`;如果未设置,脚本也接受 provider-specific +变量,例如 `OPENAI_BASE_URL`。脚本会在沙箱工作目录写入 `opencode.json`, +配置 `provider..options.baseURL`。 + +如果使用本仓库的 `dev-env/` VM 验证,设置 `CUBE_DEV_SIDECAR=1`,让宿主端 SDK +通过 `examples/e2b-dev-sidecar` 转发 sandbox 流量。 + +`network_policy.py` 使用原生 `cubesandbox` SDK。用 `dev-env/` 跑这个脚本时,还需要设置: + +```bash +CUBE_API_URL=http://127.0.0.1:13000 +CUBE_PROXY_NODE_IP=127.0.0.1 +CUBE_PROXY_PORT_HTTP=11080 +``` + +### 4. 运行一次性编码任务 + +```bash +python3 run_opencode.py --dry-run +python3 run_opencode.py +``` + +脚本会放入一个小型 Python 项目,运行 OpenCode,并验证: + +- `calculator.add` 已实现。 +- `python3 -m unittest discover -v` 通过。 +- `result.md` 包含 `OPENCODE_CUBE_OK`。 + +### 5. 演示会话保持 + +```bash +python3 resume_opencode.py +``` + +第一轮写入 `plan.md`,暂停沙箱,再连接同一个 sandbox;脚本验证 `/workspace` +和 OpenCode 状态目录仍存在,然后继续任务并检查 `OPENCODE_RESUME_OK`。 + +### 6. 限制出网并注入凭证 + +```bash +python3 network_policy.py +``` + +严格模式使用原生 `cubesandbox` SDK: + +- `allow_internet_access=False` 将出网设为默认拒绝。 +- CubeEgress rule 只允许配置的 LLM host。 +- `Inject` 在 HTTP header 中注入真实 provider 凭证。 +- 沙箱进程只能看到占位 key。 + +当 LLM API host 与 provider 默认值不一致时(尤其使用 `OPENCODE_BASE_URL` +或 provider-specific `_BASE_URL` 时),请设置 `OPENCODE_LLM_HOST`: + +```bash +OPENCODE_LLM_HOST=api.openai.com python3 network_policy.py +``` + +可用 `--skip-agent` 只验证策略,不消耗 LLM token: + +```bash +python3 network_policy.py --skip-agent +``` + +## 关键代码片段 + +构造非交互 OpenCode 命令: + +```python +opencode_command( + prompt, + workspace="/workspace", + model="openai/gpt-4.1-mini", + title="cube-opencode-demo", +) +``` + +受限出网规则: + +```python +Rule( + name="allow_openai_llm", + match=Match(scheme="https", sni="api.openai.com", host="api.openai.com"), + action=Action( + allow=True, + audit="metadata", + inject=[Inject( + header="Authorization", + secret=secret, + format="Bearer ${SECRET}", + )], + ), +) +``` + +## 注意事项 + +- live 运行仍需要真实 LLM provider key。 +- `--auto` 会让 OpenCode 无需交互确认即可编辑和执行命令,应放在隔离沙箱内使用。 +- `run_opencode.py` 的直接 env 注入适合本地验证,并会在失败输出中脱敏已知 + secret;共享集群建议使用 `network_policy.py`。 +- 自定义 provider 取决于 OpenCode provider 配置以及目标端点对所选模型的兼容性。 +- 示例 Dockerfile 为了可读性使用 NodeSource setup script;生产镜像建议 pin + 并校验 Node.js 包来源,或使用内部镜像源。 + +## 验证 + +在 `examples/opencode-integration` 下执行: + +```bash +python3 -m py_compile env_utils.py _opencode_common.py run_opencode.py resume_opencode.py network_policy.py +python3 -m unittest discover . -p 'test_*.py' +bash -n build-template.sh +docker build --platform linux/amd64 -t opencode-cube:verify . +docker run --rm opencode-cube:verify opencode --version +``` + +live CubeSandbox 验证: + +```bash +python3 run_opencode.py +python3 resume_opencode.py +python3 network_policy.py --skip-agent +python3 network_policy.py +``` + +## 参考 + +- [OpenCode CLI 文档](https://opencode.ai/docs/cli/) +- [OpenCode provider 文档](https://opencode.ai/docs/providers/) +- [CubeSandbox OpenCode 示例](https://github.com/TencentCloud/CubeSandbox/tree/master/examples/opencode-integration) +- [CubeSandbox Security Proxy](../../../guide/security-proxy.md) diff --git a/examples/opencode-integration/.dockerignore b/examples/opencode-integration/.dockerignore new file mode 100644 index 000000000..d864cae7c --- /dev/null +++ b/examples/opencode-integration/.dockerignore @@ -0,0 +1,3 @@ +* +!Dockerfile +!.dockerignore diff --git a/examples/opencode-integration/.env.example b/examples/opencode-integration/.env.example new file mode 100644 index 000000000..e1a8aeb6b --- /dev/null +++ b/examples/opencode-integration/.env.example @@ -0,0 +1,57 @@ +# --- CubeSandbox connection --- + +# Required: CubeAPI address (use CubeAPI, not CubeProxy). +E2B_API_URL="http://:3000" + +# Required: any non-empty value in local dev; a real key when auth is enabled. +E2B_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" + +# --- OpenCode agent --- + +# Required: OpenCode model in provider/model form. +# Examples: +# openai/gpt-4.1-mini +# anthropic/claude-sonnet-4-6 +# deepseek/deepseek-chat +# openrouter/anthropic/claude-sonnet-4 +OPENCODE_MODEL="openai/gpt-4.1-mini" + +# Required: API key for the provider prefix in OPENCODE_MODEL. +OPENAI_API_KEY="" +# Other common providers: +# ANTHROPIC_API_KEY="" +# DEEPSEEK_API_KEY="" +# OPENROUTER_API_KEY="" + +# Optional: override the provider base URL. OPENCODE_BASE_URL wins; if it is +# unset, the scripts also accept _BASE_URL, for example +# OPENAI_BASE_URL. The example writes opencode.json with +# provider..options.baseURL when either value is set. +# OPENCODE_BASE_URL="https://api.openai.com/v1" +# OPENAI_BASE_URL="https://api.openai.com/v1" + +# Optional (network_policy.py): LLM host allowed under default-deny egress. +# Defaults to OPENCODE_BASE_URL host or the provider's known default host. +# OPENCODE_LLM_HOST="api.openai.com" + +# Optional: tune execution. +# OPENCODE_WORKSPACE="/workspace" +# OPENCODE_SANDBOX_TIMEOUT="1800" +# OPENCODE_EXEC_TIMEOUT="900" + +# Optional: only for this repository's dev-env VM. It patches the E2B SDK to +# route sandbox traffic through examples/e2b-dev-sidecar. +# CUBE_DEV_SIDECAR="1" + +# Optional: only for network_policy.py against this repository's dev-env VM. +# The native cubesandbox SDK needs the CubeAPI URL plus the local CubeProxy +# HTTP forward when it opens envd command streams. +# CUBE_API_URL="http://127.0.0.1:13000" +# CUBE_PROXY_NODE_IP="127.0.0.1" +# CUBE_PROXY_PORT_HTTP="11080" diff --git a/examples/opencode-integration/Dockerfile b/examples/opencode-integration/Dockerfile new file mode 100644 index 000000000..5ed90d739 --- /dev/null +++ b/examples/opencode-integration/Dockerfile @@ -0,0 +1,44 @@ +# syntax=docker/dockerfile:1.7 +# +# OpenCode inside a CubeSandbox template. +# +# The inherited cube-entrypoint keeps envd running on :49983 and serves +# /health, so Cube can use the standard readiness probe. + +ARG CUBE_BASE_IMAGE=ghcr.io/tencentcloud/cubesandbox-base:2026.16 +FROM ${CUBE_BASE_IMAGE} + +ARG DEBIAN_FRONTEND=noninteractive +ARG NODE_MAJOR=22 +ARG OPENCODE_VERSION=1.17.13 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + bash \ + ca-certificates \ + curl \ + git \ + gnupg \ + jq \ + less \ + procps \ + python3 \ + python3-pip \ + ripgrep \ + && curl -fsSL "https://deb.nodesource.com/setup_${NODE_MAJOR}.x" | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + +RUN npm install -g "opencode-ai@${OPENCODE_VERSION}" \ + && opencode --version \ + && npm cache clean --force \ + && rm -rf /root/.npm + +ENV OPENCODE_DISABLE_AUTO_UPDATE=1 \ + NPM_CONFIG_UPDATE_NOTIFIER=false + +RUN mkdir -p /workspace /root/.local/share/opencode /root/.config/opencode + +WORKDIR /workspace + +EXPOSE 49983 diff --git a/examples/opencode-integration/README.md b/examples/opencode-integration/README.md new file mode 100644 index 000000000..4f5b99594 --- /dev/null +++ b/examples/opencode-integration/README.md @@ -0,0 +1,181 @@ +# OpenCode + CubeSandbox Example + +[中文](README_zh.md) + +Run [OpenCode](https://opencode.ai/) inside a CubeSandbox MicroVM. The host +driver creates an isolated sandbox, asks OpenCode to edit a tiny Python project, +checks the generated artifacts, and demonstrates pause/resume plus restricted +LLM egress. + +## What is included + +```text +opencode-integration/ +|-- Dockerfile # CubeSandbox template image with Node.js + OpenCode +|-- .env.example # Copy to .env and fill in local values +|-- build-template.sh # Prints docker/cubemastercli commands +|-- env_utils.py # Provider, model, env, and command helpers +|-- _opencode_common.py # Sandbox command helpers +|-- run_opencode.py # One-shot coding-agent demo +|-- resume_opencode.py # pause/resume session persistence demo +|-- network_policy.py # Default-deny egress + CubeEgress injection demo +|-- test_env_utils.py # Unit tests for config handling +|-- test_commands.py # Unit tests for command construction +`-- requirements.txt # Host-side Python dependencies +``` + +## Prerequisites + +- A running CubeSandbox deployment with CubeAPI reachable at `http://:3000`. +- `cubemastercli` connected to the cluster. +- Docker and a registry reachable by Cube nodes. +- Python 3.10+ on the host. +- An LLM provider key supported by OpenCode. + +## 1. Build the image + +```bash +cd examples/opencode-integration +docker build --platform linux/amd64 -t /opencode-cube:latest . +docker push /opencode-cube:latest +``` + +The image installs Node.js 22 and `opencode-ai` on top of +`ghcr.io/tencentcloud/cubesandbox-base:2026.16`. + +## 2. Register a CubeSandbox template + +```bash +cubemastercli tpl create-from-image \ + --image /opencode-cube:latest \ + --writable-layer-size 4G \ + --expose-port 49983 \ + --probe 49983 \ + --probe-path /health +``` + +Wait for the template job to become `READY`, then copy the returned +`template_id` into `.env`. + +## 3. Configure the host driver + +```bash +cp .env.example .env +pip install -r requirements.txt +``` + +Required values: + +| Variable | Description | +|---|---| +| `E2B_API_URL` | CubeAPI URL, for example `http://:3000` | +| `E2B_API_KEY` | Any non-empty value for local dev, or the real key when auth is enabled | +| `CUBE_TEMPLATE_ID` | Template ID created in step 2 | +| `OPENCODE_MODEL` | OpenCode model in `provider/model` form | +| `_API_KEY` | API key matching the provider prefix | + +Use `OPENCODE_BASE_URL` for OpenAI-compatible custom endpoints. If it is not +set, the scripts also accept provider-specific variables such as +`OPENAI_BASE_URL`. The scripts write an `opencode.json` into the sandbox +workspace with `provider..options.baseURL` when either value +is set. + +When running against this repository's `dev-env/` VM, also set: + +```bash +CUBE_DEV_SIDECAR=1 +``` + +The sidecar patches the E2B SDK so sandbox traffic is routed through the +dev-env CubeProxy port forwards. + +`network_policy.py` uses the native `cubesandbox` SDK. When running that script +against `dev-env/`, also set the native SDK's API and proxy variables: + +```bash +CUBE_API_URL=http://127.0.0.1:13000 +CUBE_PROXY_NODE_IP=127.0.0.1 +CUBE_PROXY_PORT_HTTP=11080 +``` + +## 4. Run the one-shot coding task + +```bash +python3 run_opencode.py --dry-run +python3 run_opencode.py +``` + +The demo seeds `/workspace` with a tiny Python project, asks OpenCode to +implement `calculator.add`, runs `python3 -m unittest discover -v`, and verifies +that `result.md` contains `OPENCODE_CUBE_OK`. + +## 5. Verify pause/resume persistence + +```bash +python3 resume_opencode.py +``` + +The first turn asks OpenCode to write `plan.md`, pauses the sandbox, reconnects +to the same sandbox ID, verifies `/workspace` and OpenCode state survived, then +continues the task and checks for `OPENCODE_RESUME_OK`. + +## 6. Run with restricted egress + +```bash +python3 network_policy.py +``` + +This path uses the native `cubesandbox` SDK to create the sandbox with +default-deny egress and an allow rule for only the configured LLM host. The +provider key is injected by CubeEgress as an HTTP header, while the sandbox sees +only a placeholder environment value. + +Set `OPENCODE_LLM_HOST` when using a custom endpoint: + +```bash +OPENCODE_LLM_HOST=api.openai.com python3 network_policy.py +``` + +For quick policy checks without spending LLM tokens: + +```bash +python3 network_policy.py --skip-agent +``` + +## Validation + +```bash +python3 -m py_compile env_utils.py _opencode_common.py run_opencode.py resume_opencode.py network_policy.py +python3 -m unittest discover . -p 'test_*.py' +bash -n build-template.sh +``` + +Optional image checks: + +```bash +docker build --platform linux/amd64 -t opencode-cube:verify . +docker run --rm opencode-cube:verify opencode --version +docker run --rm opencode-cube:verify node --version +docker run --rm opencode-cube:verify python3 --version +``` + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `Missing required environment variable` | `.env` is incomplete | Fill every required field in `.env` | +| `OPENCODE_MODEL must be in provider/model form` | Model lacks provider prefix | Use values like `openai/gpt-4.1-mini` | +| OpenCode cannot authenticate | Wrong provider key name | Match the prefix: `openai/*` needs `OPENAI_API_KEY` | +| Template probe fails | The envd port is not reachable | Use `--probe 49983 --probe-path /health` with the CubeSandbox base entrypoint | +| `403 Forbidden - CubeEgress` | Strict egress blocked a host | Set `OPENCODE_LLM_HOST` to the real provider host | +| Supply-chain hardening required | The example Dockerfile uses the NodeSource setup script for readability | Pin and verify Node.js packages or mirror the artifacts in production pipelines | + +## Security notes + +- The Docker image never contains provider secrets. +- `run_opencode.py` and `resume_opencode.py` inject the provider key only for + the command and redact known secret values from failure output. This is + convenient for local validation but leaves egress open. +- `network_policy.py` is the recommended shared-cluster pattern: default-deny + egress plus CubeEgress header injection. +- Do not commit `.env`. diff --git a/examples/opencode-integration/README_zh.md b/examples/opencode-integration/README_zh.md new file mode 100644 index 000000000..328ffe90c --- /dev/null +++ b/examples/opencode-integration/README_zh.md @@ -0,0 +1,171 @@ +# OpenCode + CubeSandbox 示例 + +[English](README.md) + +本示例演示如何在 CubeSandbox MicroVM 中运行 +[OpenCode](https://opencode.ai/)。宿主端脚本创建隔离沙箱,让 OpenCode 修改一个 +很小的 Python 项目,并验证生成产物;同时覆盖 pause/resume 会话保持和受限 LLM +出网。 + +## 包含内容 + +```text +opencode-integration/ +|-- Dockerfile # Node.js + OpenCode 的 CubeSandbox 模板镜像 +|-- .env.example # 复制为 .env 后填写本地配置 +|-- build-template.sh # 打印 docker/cubemastercli 命令 +|-- env_utils.py # provider、model、env、命令构造 helper +|-- _opencode_common.py # 沙箱命令 helper +|-- run_opencode.py # 一次性编码任务 demo +|-- resume_opencode.py # pause/resume 会话保持 demo +|-- network_policy.py # 默认拒绝出网 + CubeEgress 注入 demo +|-- test_env_utils.py # 配置处理单测 +|-- test_commands.py # 命令构造单测 +`-- requirements.txt # 宿主端 Python 依赖 +``` + +## 前置条件 + +- 已部署 CubeSandbox,CubeAPI 可通过 `http://:3000` 访问。 +- `cubemastercli` 已连接集群。 +- 构建机有 Docker,并能推送到 Cube 节点可拉取的镜像仓库。 +- 宿主端 Python 3.10+。 +- 一个 OpenCode 支持的 LLM provider API key。 + +## 1. 构建镜像 + +```bash +cd examples/opencode-integration +docker build --platform linux/amd64 -t /opencode-cube:latest . +docker push /opencode-cube:latest +``` + +镜像基于 `ghcr.io/tencentcloud/cubesandbox-base:2026.16`,安装 Node.js 22 +和 `opencode-ai`。 + +## 2. 注册 CubeSandbox 模板 + +```bash +cubemastercli tpl create-from-image \ + --image /opencode-cube:latest \ + --writable-layer-size 4G \ + --expose-port 49983 \ + --probe 49983 \ + --probe-path /health +``` + +等待模板任务进入 `READY` 后,把返回的 `template_id` 写入 `.env`。 + +## 3. 配置宿主端脚本 + +```bash +cp .env.example .env +pip install -r requirements.txt +``` + +必填变量: + +| 变量 | 说明 | +|---|---| +| `E2B_API_URL` | CubeAPI 地址,例如 `http://:3000` | +| `E2B_API_KEY` | 本地开发可填任意非空值;启用鉴权时填写真实 key | +| `CUBE_TEMPLATE_ID` | 第 2 步创建的模板 ID | +| `OPENCODE_MODEL` | `provider/model` 形式的 OpenCode 模型 | +| `_API_KEY` | 与 provider 前缀匹配的 API key | + +如需使用 OpenAI-compatible 自定义端点,设置 `OPENCODE_BASE_URL`。如果未设置, +脚本也接受 provider-specific 变量,例如 `OPENAI_BASE_URL`。脚本会在沙箱 +工作目录写入 `opencode.json`,配置 `provider..options.baseURL`。 + +如果连接的是本仓库的 `dev-env/` VM,还需要设置: + +```bash +CUBE_DEV_SIDECAR=1 +``` + +sidecar 会 patch E2B SDK,让 sandbox 流量走 dev-env 的 CubeProxy 端口转发。 + +`network_policy.py` 使用原生 `cubesandbox` SDK。用 `dev-env/` 跑这个脚本时,还需要设置原生 SDK 的 API 和代理变量: + +```bash +CUBE_API_URL=http://127.0.0.1:13000 +CUBE_PROXY_NODE_IP=127.0.0.1 +CUBE_PROXY_PORT_HTTP=11080 +``` + +## 4. 运行一次性编码任务 + +```bash +python3 run_opencode.py --dry-run +python3 run_opencode.py +``` + +脚本会在 `/workspace` 放入一个小型 Python 项目,让 OpenCode 实现 +`calculator.add`,运行 `python3 -m unittest discover -v`,并验证 `result.md` +包含 `OPENCODE_CUBE_OK`。 + +## 5. 验证 pause/resume 会话保持 + +```bash +python3 resume_opencode.py +``` + +第一轮让 OpenCode 写入 `plan.md`,随后暂停沙箱;脚本再连接同一个 sandbox ID, +验证 `/workspace` 和 OpenCode 状态目录仍存在,然后继续任务并检查 +`OPENCODE_RESUME_OK`。 + +## 6. 受限出网模式 + +```bash +python3 network_policy.py +``` + +该路径使用原生 `cubesandbox` SDK 创建沙箱:默认拒绝出网,只允许配置的 LLM host。 +真实 provider key 由 CubeEgress 在链路上注入 HTTP header,沙箱里只能看到占位值。 + +自定义端点请设置: + +```bash +OPENCODE_LLM_HOST=api.openai.com python3 network_policy.py +``` + +只检查策略、不实际调用 LLM: + +```bash +python3 network_policy.py --skip-agent +``` + +## 验证 + +```bash +python3 -m py_compile env_utils.py _opencode_common.py run_opencode.py resume_opencode.py network_policy.py +python3 -m unittest discover . -p 'test_*.py' +bash -n build-template.sh +``` + +可选镜像检查: + +```bash +docker build --platform linux/amd64 -t opencode-cube:verify . +docker run --rm opencode-cube:verify opencode --version +docker run --rm opencode-cube:verify node --version +docker run --rm opencode-cube:verify python3 --version +``` + +## 排错 + +| 现象 | 可能原因 | 处理方式 | +|---|---|---| +| `Missing required environment variable` | `.env` 未填完整 | 补齐 `.env` 必填项 | +| `OPENCODE_MODEL must be in provider/model form` | 模型缺少 provider 前缀 | 使用 `openai/gpt-4.1-mini` 这类格式 | +| OpenCode 鉴权失败 | provider key 名称不匹配 | `openai/*` 对应 `OPENAI_API_KEY` | +| 模板 probe 失败 | envd 端口不可达 | 使用 CubeSandbox base entrypoint 时配置 `--probe 49983 --probe-path /health` | +| `403 Forbidden - CubeEgress` | 严格出网模式阻断了目标 host | 设置真实的 `OPENCODE_LLM_HOST` | +| 需要生产级供应链加固 | 示例 Dockerfile 为了可读性使用 NodeSource setup script | 生产镜像建议 pin 并校验 Node.js 包来源,或使用内部镜像源 | + +## 安全说明 + +- Docker 镜像不包含任何 provider secret。 +- `run_opencode.py` 和 `resume_opencode.py` 只在单次命令环境中注入 key,并会在失败输出中脱敏已知 secret,适合本地验证。 +- `network_policy.py` 是共享集群推荐路径:默认拒绝出网 + CubeEgress header 注入。 +- 不要提交 `.env`。 diff --git a/examples/opencode-integration/_opencode_common.py b/examples/opencode-integration/_opencode_common.py new file mode 100644 index 000000000..b69581a08 --- /dev/null +++ b/examples/opencode-integration/_opencode_common.py @@ -0,0 +1,123 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Shared sandbox command helpers for the OpenCode integration examples.""" + +from __future__ import annotations + +import inspect +import os +import sys +from collections.abc import Callable +from functools import lru_cache +from typing import Any + + +SECRET_ENV_SUFFIXES = ("API_KEY", "_KEY", "_TOKEN", "_SECRET") +SECRET_ENV_NAMES = {"KEY", "TOKEN", "SECRET", "API_KEY"} + + +def stream_writer(stream) -> Callable[[object], None]: + def write(chunk: object) -> None: + text = getattr(chunk, "line", chunk) + stream.write(redact_secrets(str(text))) + stream.flush() + + 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 = "root", +): + kwargs = {"cwd": cwd, "timeout": timeout, "user": user} + kwargs = {key: value for key, value in kwargs.items() if value is not None} + if envs: + kwargs[_command_env_kwarg(type(sandbox.commands))] = envs + if stream: + kwargs["on_stdout"] = stream_writer(sys.stdout) + kwargs["on_stderr"] = stream_writer(sys.stderr) + kwargs = _filter_supported_kwargs(type(sandbox.commands), kwargs) + + return sandbox.commands.run(command, **kwargs) + + +def ensure_success(result, action: str) -> None: + exit_code = getattr(result, "exit_code", None) + if exit_code not in (None, 0): + stdout = redact_secrets(getattr(result, "stdout", "")) + stderr = redact_secrets(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")) + + +def warn_direct_secret_env(key_name: str) -> None: + if _secret_is_present(os.environ.get(key_name)): + print( + f"Warning: {key_name} will be passed into the sandbox process " + "environment for this local convenience demo. Use network_policy.py " + "for shared clusters so CubeEgress can inject credentials without " + "exposing the real key inside the sandbox.", + file=sys.stderr, + ) + + +@lru_cache(maxsize=None) +def _command_env_kwarg(commands_type: type) -> str: + params = _command_run_parameters(commands_type) + if params is None: + return "envs" + if "envs" in params: + return "envs" + if "env" in params: + return "env" + return "envs" + + +@lru_cache(maxsize=None) +def _command_run_parameters(commands_type: type) -> dict[str, inspect.Parameter] | None: + try: + return dict(inspect.signature(commands_type.run).parameters) + except (TypeError, ValueError): + return None + + +def _filter_supported_kwargs(commands_type: type, kwargs: dict[str, Any]) -> dict[str, Any]: + params = _command_run_parameters(commands_type) + if params is None: + return kwargs + if any(param.kind == inspect.Parameter.VAR_KEYWORD for param in params.values()): + return kwargs + return {key: value for key, value in kwargs.items() if key in params} + + +def _is_secret_env(name: str) -> bool: + normalized = name.upper() + return normalized in SECRET_ENV_NAMES or normalized.endswith(SECRET_ENV_SUFFIXES) + + +def _secret_is_present(value: str | None) -> bool: + return bool(value and value.strip() and not value.strip().startswith("<")) + + +def redact_secrets(text: str) -> str: + redacted = text + values = { + value + for name, value in os.environ.items() + if _is_secret_env(name) and _secret_is_present(value) + } + for value in sorted(values, key=len, reverse=True): + redacted = redacted.replace(value, "") + return redacted diff --git a/examples/opencode-integration/build-template.sh b/examples/opencode-integration/build-template.sh new file mode 100755 index 000000000..49c0e71af --- /dev/null +++ b/examples/opencode-integration/build-template.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +IMAGE="${IMAGE:-opencode-cube:latest}" +WRITABLE_LAYER_SIZE="${WRITABLE_LAYER_SIZE:-4G}" +EXPOSE_PORT="${EXPOSE_PORT:-49983}" +PROBE_PORT="${PROBE_PORT:-49983}" +PROBE_PATH="${PROBE_PATH:-/health}" + +cat < None: + candidate_paths = [Path(__file__).with_name(".env"), Path.cwd() / ".env"] + seen_paths: set[Path] = set() + checked_paths: list[str] = [] + for path in candidate_paths: + resolved = path.resolve() + if resolved in seen_paths: + continue + seen_paths.add(resolved) + checked_paths.append(str(path)) + if path.is_file(): + load_dotenv(dotenv_path=path, override=False) + return + print( + "Warning: no .env file found; continuing with existing environment. " + f"Checked: {', '.join(checked_paths)}", + file=sys.stderr, + ) + + +def required(name: str) -> str: + value = os.environ.get(name) + if not value: + raise SystemExit(f"Missing required environment variable: {name}") + return value + + +def optional(name: str, default: str = "") -> str: + value = os.environ.get(name) + return default if value is None else value + + +def int_env(name: str, default: int) -> int: + raw = os.environ.get(name) + if not 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 opencode_workspace() -> str: + return optional("OPENCODE_WORKSPACE", DEFAULT_WORKSPACE) + + +def opencode_model() -> str: + model = required("OPENCODE_MODEL").strip() + if "/" not in model: + raise SystemExit( + "OPENCODE_MODEL must be in provider/model form, for example openai/gpt-4.1-mini" + ) + return model + + +def opencode_provider(model: str | None = None) -> str: + selected = model or opencode_model() + return selected.split("/", 1)[0].strip().lower() + + +def provider_key_name(provider: str | None = None) -> str: + provider_name = provider or opencode_provider() + return PROVIDER_KEY_ENV.get(provider_name, f"{provider_name.upper()}_API_KEY") + + +def require_provider_key(provider: str | None = None) -> str: + key_name = provider_key_name(provider) + value = os.environ.get(key_name) + if not value: + raise SystemExit(f"Missing required environment variable: {key_name}") + return value + + +def opencode_base_url(provider: str | None = None) -> str: + provider_name = provider or opencode_provider() + explicit = os.environ.get("OPENCODE_BASE_URL") + if explicit: + return explicit + provider_specific = os.environ.get(f"{provider_name.upper()}_BASE_URL") + return provider_specific or "" + + +def opencode_llm_host(provider: str | None = None) -> str: + explicit = os.environ.get("OPENCODE_LLM_HOST") + if explicit: + return _host_from_url(explicit) + base_url = opencode_base_url(provider) + if base_url: + return _host_from_url(base_url) + return PROVIDER_DEFAULT_HOST.get(provider or opencode_provider(), "") + + +def build_opencode_env(include_secrets: bool = True) -> dict[str, str]: + provider = opencode_provider() + env = { + "OPENCODE_DISABLE_AUTO_UPDATE": optional("OPENCODE_DISABLE_AUTO_UPDATE", "1"), + } + for name in PASSTHROUGH_ENV_NAMES: + value = os.environ.get(name) + if not value: + continue + if name.endswith("_API_KEY") and not include_secrets: + continue + env[name] = value + return env + + +def opencode_config_json(provider: str | None = None) -> str: + provider_name = provider or opencode_provider() + base_url = opencode_base_url(provider_name) + config: dict[str, object] = {"$schema": "https://opencode.ai/config.json"} + if base_url: + config["provider"] = { + provider_name: { + "options": { + "baseURL": base_url, + } + } + } + return json.dumps(config, indent=2, sort_keys=True) + + +def opencode_command( + prompt: str, + *, + workspace: str | None = None, + model: str | None = None, + title: str | None = None, + session: str | None = None, + continue_last: bool = False, + auto: bool = True, + json_format: bool = True, +) -> str: + args = ["opencode", "run"] + if model: + args.extend(["--model", model]) + if workspace: + args.extend(["--dir", workspace]) + if title: + args.extend(["--title", title]) + if session: + args.extend(["--session", session]) + elif continue_last: + args.append("--continue") + if auto: + args.append("--auto") + if json_format: + args.extend(["--format", "json"]) + 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) + + +def setup_dev_sidecar_if_requested() -> None: + value = os.environ.get("CUBE_DEV_SIDECAR", "").strip().lower() + if value not in ("1", "true", "yes", "on"): + return + sidecar_dir = Path(__file__).resolve().parents[1] / "e2b-dev-sidecar" + if not sidecar_dir.is_dir(): + raise SystemExit( + "CUBE_DEV_SIDECAR=1 was set, but examples/e2b-dev-sidecar was not found." + ) + if str(sidecar_dir) not in sys.path: + sys.path.insert(0, str(sidecar_dir)) + from dev_sidecar import setup_dev_sidecar + + setup_dev_sidecar() + + +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 "" + + +def provider_inject(provider: str, secret: str) -> list[dict[str, str]]: + provider_name = provider.strip().lower() + if provider_name == "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}"}] diff --git a/examples/opencode-integration/network_policy.py b/examples/opencode-integration/network_policy.py new file mode 100644 index 000000000..63e8cb16d --- /dev/null +++ b/examples/opencode-integration/network_policy.py @@ -0,0 +1,210 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Run OpenCode with default-deny egress and CubeEgress credential injection.""" + +from __future__ import annotations + +import argparse +import os +import shlex +import sys + +from cubesandbox import Action, Inject, Match, Rule, Sandbox + +from _opencode_common import ensure_success, run_command, sandbox_identifier +from env_utils import ( + build_opencode_env, + int_env, + load_local_dotenv, + opencode_command, + opencode_config_json, + opencode_llm_host, + opencode_model, + opencode_provider, + opencode_workspace, + provider_inject, + provider_key_name, + require_provider_key, + required, + shell_join, +) + +PLACEHOLDER_KEY = "cube-egress-managed-placeholder" +DEFAULT_PROMPT = ( + "Create {workspace}/egress_check.md containing exactly OPENCODE_EGRESS_OK. " + "Use the write tool or shell redirection, then stop only after the file exists." +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run OpenCode under a restricted CubeEgress policy." + ) + parser.add_argument("--template", default=os.environ.get("CUBE_TEMPLATE_ID")) + parser.add_argument("--host", default=None) + parser.add_argument("--workspace", default=opencode_workspace()) + parser.add_argument("--prompt", default=None) + parser.add_argument("--title", default="cube-opencode-egress") + parser.add_argument( + "--sandbox-timeout", + type=int, + default=int_env("OPENCODE_SANDBOX_TIMEOUT", 1800), + ) + parser.add_argument( + "--exec-timeout", + type=int, + default=int_env("OPENCODE_EXEC_TIMEOUT", 900), + ) + parser.add_argument("--skip-agent", action="store_true") + args = parser.parse_args() + if args.prompt is None: + args.prompt = DEFAULT_PROMPT.format(workspace=args.workspace) + return args + + +def build_rules(provider: str, host: str, secret: str) -> list[Rule]: + return [ + Rule( + name=f"allow_{provider}_llm", + match=Match(scheme="https", sni=host, host=host), + action=Action( + allow=True, + audit="metadata", + inject=[Inject(**spec) for spec in provider_inject(provider, secret)], + ), + ) + ] + + +def seed_workspace(sandbox: Sandbox, workspace: str) -> None: + quoted_workspace = shlex.quote(workspace) + command = f"""mkdir -p {quoted_workspace} +cat > {quoted_workspace}/opencode.json <<'EOF' +{opencode_config_json()} +EOF +""" + result = run_command(sandbox, command, timeout=60) + ensure_success(result, "seed egress workspace") + + +def create_sandbox(template_id: str, rules: list[Rule], timeout: int) -> Sandbox: + 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} (expected placeholder, not the real key)") + + +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: {status or 'blocked'}") + + +def verify_agent_output(sandbox: Sandbox, workspace: str) -> None: + quoted_workspace = shlex.quote(workspace) + command = shell_join( + f"test -f {quoted_workspace}/egress_check.md", + f"grep -q OPENCODE_EGRESS_OK {quoted_workspace}/egress_check.md", + f"cat {quoted_workspace}/egress_check.md", + ) + result = run_command(sandbox, command, timeout=60) + ensure_success(result, "verify egress demo output") + if getattr(result, "stdout", ""): + print(result.stdout) + + +def print_command_output(result) -> None: + stdout = getattr(result, "stdout", "") + stderr = getattr(result, "stderr", "") + if stdout: + print(stdout) + if stderr: + print(stderr, file=sys.stderr) + + +def main() -> int: + load_local_dotenv() + args = parse_args() + + template_id = args.template or required("CUBE_TEMPLATE_ID") + required("E2B_API_URL") + required("E2B_API_KEY") + model = opencode_model() + provider = opencode_provider(model) + secret = require_provider_key(provider) + host = args.host or opencode_llm_host(provider) + if not host: + raise SystemExit("Set OPENCODE_LLM_HOST or OPENCODE_BASE_URL for this provider.") + + key_name = provider_key_name(provider) + envs = build_opencode_env(include_secrets=False) + envs[key_name] = PLACEHOLDER_KEY + + rules = build_rules(provider, host, secret) + sandbox = None + sandbox_id = "unknown" + try: + print(f"Provider: {provider}") + print(f"Allowed LLM host: {host}") + print(f"Creating sandbox from template: {template_id}") + sandbox = create_sandbox(template_id, rules, args.sandbox_timeout) + sandbox_id = sandbox_identifier(sandbox) + print(f"Sandbox ready: {sandbox_id}\n") + + show_key_not_in_vm(sandbox, key_name) + show_non_llm_blocked(sandbox) + seed_workspace(sandbox, args.workspace) + + if args.skip_agent: + print("\n--skip-agent set: not invoking OpenCode.") + return 0 + + command = opencode_command( + args.prompt, + workspace=args.workspace, + model=model, + title=args.title, + ) + print("\nRunning OpenCode through CubeEgress injection...\n") + result = run_command( + sandbox, + command, + cwd=args.workspace, + envs=envs, + timeout=args.exec_timeout, + stream=True, + ) + ensure_success(result, "run OpenCode with restricted egress") + print_command_output(result) + verify_agent_output(sandbox, args.workspace) + return 0 + finally: + if sandbox is not None: + try: + sandbox.kill() + print(f"\nSandbox {sandbox_id} killed.") + except Exception as exc: # noqa: BLE001 + print( + f"Warning: failed to kill sandbox {sandbox_id}: {exc}", + file=sys.stderr, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/opencode-integration/requirements.txt b/examples/opencode-integration/requirements.txt new file mode 100644 index 000000000..5951768a5 --- /dev/null +++ b/examples/opencode-integration/requirements.txt @@ -0,0 +1,3 @@ +e2b>=2.4.1 +cubesandbox>=0.3.0 +python-dotenv>=1.0.0 diff --git a/examples/opencode-integration/resume_opencode.py b/examples/opencode-integration/resume_opencode.py new file mode 100644 index 000000000..94f7caf78 --- /dev/null +++ b/examples/opencode-integration/resume_opencode.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +"""Demonstrate OpenCode state persistence across CubeSandbox pause/resume.""" + +from __future__ import annotations + +import argparse +import os +import shlex +import sys + +from e2b import Sandbox + +from _opencode_common import ( + ensure_success, + run_command, + sandbox_identifier, + warn_direct_secret_env, +) +from env_utils import ( + build_opencode_env, + int_env, + load_local_dotenv, + opencode_command, + opencode_config_json, + opencode_model, + opencode_provider, + opencode_workspace, + provider_key_name, + require_provider_key, + required, + setup_dev_sidecar_if_requested, + shell_join, +) + +TURN_1_PROMPT = ( + "In {workspace}, create plan.md with a numbered two-step plan for adding a " + "multiply function to calculator.py. Only write plan.md." +) + +TURN_2_PROMPT = ( + "Continue from the previous session. Read plan.md, add multiply(a, b) to " + "calculator.py, add a unittest for multiply(4, 5) == 20, run " + "`python3 -m unittest discover -v`, and write progress.md with the exact " + "marker OPENCODE_RESUME_OK." +) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run OpenCode before and after CubeSandbox pause/resume." + ) + parser.add_argument("--template", default=os.environ.get("CUBE_TEMPLATE_ID")) + parser.add_argument("--workspace", default=opencode_workspace()) + parser.add_argument("--title", default="cube-opencode-resume") + parser.add_argument( + "--sandbox-timeout", + type=int, + default=int_env("OPENCODE_SANDBOX_TIMEOUT", 1800), + ) + parser.add_argument( + "--exec-timeout", + type=int, + default=int_env("OPENCODE_EXEC_TIMEOUT", 900), + ) + return parser.parse_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}/opencode.json <<'EOF' +{opencode_config_json()} +EOF +cat > {quoted_workspace}/calculator.py <<'EOF' +def add(a: int, b: int) -> int: + return a + b +EOF +cat > {quoted_workspace}/test_calculator.py <<'EOF' +import unittest + +from calculator import add + + +class CalculatorTest(unittest.TestCase): + def test_adds_two_numbers(self) -> None: + self.assertEqual(add(2, 3), 5) + + +if __name__ == "__main__": + unittest.main() +EOF +""" + result = run_command(sandbox, command, timeout=timeout) + ensure_success(result, "seed resume workspace") + + +def run_turn( + sandbox: Sandbox, + workspace: str, + prompt: str, + title: str, + model: str, + envs: dict[str, str], + timeout: int, + *, + continue_last: bool = False, +): + command = opencode_command( + prompt, + workspace=workspace, + model=model, + title=title, + continue_last=continue_last, + ) + return run_command( + sandbox, + command, + cwd=workspace, + envs=envs, + timeout=timeout, + stream=True, + ) + + +def verify_after_resume(sandbox: Sandbox, workspace: str) -> None: + quoted_workspace = shlex.quote(workspace) + command = shell_join( + f"cd {quoted_workspace}", + "test -f plan.md", + "test -f calculator.py", + "test -d /root/.local/share/opencode", + "printf '\\n--- plan.md survived pause/resume ---\\n'", + "cat plan.md", + ) + result = run_command(sandbox, command, timeout=60) + ensure_success(result, "verify OpenCode state survived pause/resume") + if getattr(result, "stdout", ""): + print(result.stdout) + + +def verify_final(sandbox: Sandbox, workspace: str) -> None: + quoted_workspace = shlex.quote(workspace) + command = shell_join( + f"cd {quoted_workspace}", + "python3 -m unittest discover -v", + "grep -q 'def multiply' calculator.py", + "test -f progress.md", + "grep -q OPENCODE_RESUME_OK progress.md", + "printf '\\n--- progress.md ---\\n'", + "cat progress.md", + ) + result = run_command(sandbox, command, timeout=120) + ensure_success(result, "verify final resumed output") + 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") + required("E2B_API_URL") + required("E2B_API_KEY") + model = opencode_model() + provider = opencode_provider(model) + require_provider_key(provider) + key_name = provider_key_name(provider) + envs = build_opencode_env(include_secrets=True) + + setup_dev_sidecar_if_requested() + warn_direct_secret_env(key_name) + + sandbox = None + sandbox_id = "unknown" + try: + print(f"Creating sandbox from template: {template_id}") + sandbox = Sandbox.create(template=template_id, timeout=args.sandbox_timeout) + sandbox_id = sandbox_identifier(sandbox) + print(f"Sandbox ready: {sandbox_id}") + + seed_project(sandbox, args.workspace, timeout=60) + + print("\n=== Turn 1: create plan.md ===\n") + result_1 = run_turn( + sandbox, + args.workspace, + TURN_1_PROMPT.format(workspace=args.workspace), + args.title, + model, + envs, + args.exec_timeout, + ) + ensure_success(result_1, "run OpenCode turn 1") + + print(f"\nPausing sandbox {sandbox_id}...") + paused_id = sandbox.pause() + 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.") + + verify_after_resume(sandbox, args.workspace) + + print("\n=== Turn 2: continue after resume ===\n") + result_2 = run_turn( + sandbox, + args.workspace, + TURN_2_PROMPT, + args.title, + model, + envs, + args.exec_timeout, + continue_last=True, + ) + ensure_success(result_2, "run OpenCode turn 2") + + verify_final(sandbox, args.workspace) + return 0 + finally: + if sandbox is not None: + try: + sandbox.kill() + print(f"\nSandbox {sandbox_id} killed.") + except Exception as exc: # noqa: BLE001 + print( + f"Warning: failed to kill sandbox {sandbox_id}: {exc}", + file=sys.stderr, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/opencode-integration/run_opencode.py b/examples/opencode-integration/run_opencode.py new file mode 100644 index 000000000..b154872b3 --- /dev/null +++ b/examples/opencode-integration/run_opencode.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import argparse +import os +import shlex +import sys + +from _opencode_common import ( + ensure_success, + run_command, + sandbox_identifier, + warn_direct_secret_env, +) +from env_utils import ( + build_opencode_env, + int_env, + load_local_dotenv, + opencode_command, + opencode_config_json, + opencode_model, + opencode_provider, + opencode_workspace, + provider_key_name, + require_provider_key, + required, + setup_dev_sidecar_if_requested, + shell_join, +) + +DEFAULT_PROMPT_TEMPLATE = ( + "You are in {workspace}. Implement the missing add(a, b) function in " + "calculator.py, run `python3 -m unittest discover -v`, and write " + "{workspace}/result.md containing the exact marker OPENCODE_CUBE_OK plus " + "one short sentence about the test result." +) + + +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 OpenCode coding-agent task inside CubeSandbox." + ) + parser.add_argument("--template", default=os.environ.get("CUBE_TEMPLATE_ID")) + parser.add_argument("--prompt", default=None) + parser.add_argument("--workspace", default=opencode_workspace()) + parser.add_argument("--title", default="cube-opencode-demo") + parser.add_argument( + "--sandbox-timeout", + type=int, + default=int_env("OPENCODE_SANDBOX_TIMEOUT", 1800), + ) + parser.add_argument( + "--exec-timeout", + type=int, + default=int_env("OPENCODE_EXEC_TIMEOUT", 900), + ) + parser.add_argument("--dry-run", action="store_true") + parser.add_argument("--keep-alive", action="store_true") + parser.add_argument("--verbose", action="store_true") + parser.add_argument("--raw", action="store_true", help="Reserved for symmetry.") + args = parser.parse_args() + if args.prompt is None: + args.prompt = default_prompt(args.workspace) + return args + + +def seed_project(sandbox, workspace: str, timeout: int) -> None: + quoted_workspace = shlex.quote(workspace) + config_json = opencode_config_json() + command = f"""mkdir -p {quoted_workspace} +cat > {quoted_workspace}/opencode.json <<'EOF' +{config_json} +EOF +cat > {quoted_workspace}/calculator.py <<'EOF' +def add(a: int, b: int) -> int: + raise NotImplementedError("OpenCode should implement this") + + +if __name__ == "__main__": + print(add(2, 3)) +EOF +cat > {quoted_workspace}/test_calculator.py <<'EOF' +import unittest + +from calculator import add + + +class CalculatorTest(unittest.TestCase): + def test_adds_two_numbers(self) -> None: + self.assertEqual(add(2, 3), 5) + + +if __name__ == "__main__": + unittest.main() +EOF +cat > {quoted_workspace}/README.md <<'EOF' +# CubeSandbox OpenCode Smoke Project + +Implement calculator.add, run the test, and summarize the result. +EOF +""" + result = run_command(sandbox, command, timeout=timeout) + ensure_success(result, "seed OpenCode workspace") + + +def verify_workspace(sandbox, workspace: str, timeout: int) -> None: + quoted_workspace = shlex.quote(workspace) + command = shell_join( + f"cd {quoted_workspace}", + "python3 -m unittest discover -v", + "test -f result.md", + "grep -q OPENCODE_CUBE_OK result.md", + "printf '\\n--- result.md ---\\n'", + "cat result.md", + ) + result = run_command(sandbox, command, timeout=timeout) + ensure_success(result, "verify OpenCode output") + 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") + required("E2B_API_URL") + required("E2B_API_KEY") + model = opencode_model() + provider = opencode_provider(model) + require_provider_key(provider) + key_name = provider_key_name(provider) + + command = opencode_command( + args.prompt, + workspace=args.workspace, + model=model, + title=args.title, + ) + envs = build_opencode_env(include_secrets=True) + + if args.dry_run: + print(f"Template: {template_id}") + print(f"Provider: {provider}") + print(f"Model: {model}") + print(f"Workspace: {args.workspace}") + print(f"Command: {command}") + print("Secrets: redacted") + return 0 + + setup_dev_sidecar_if_requested() + warn_direct_secret_env(key_name) + from e2b import Sandbox + + print(f"Creating sandbox from template: {template_id}") + sandbox = None + result = None + try: + sandbox = Sandbox.create(template=template_id, timeout=args.sandbox_timeout) + sandbox_id = sandbox_identifier(sandbox) + print(f"Sandbox ready: {sandbox_id}") + + if args.verbose: + version_result = run_command(sandbox, "opencode --version", timeout=60) + ensure_success(version_result, "check OpenCode version") + print(f"OpenCode version: {getattr(version_result, 'stdout', '').strip()}") + + seed_project(sandbox, args.workspace, timeout=60) + print(f"Seeded deterministic project in {args.workspace}") + + print("\nRunning OpenCode task...\n") + result = run_command( + sandbox, + command, + cwd=args.workspace, + envs=envs, + timeout=args.exec_timeout, + stream=True, + ) + ensure_success(result, "run OpenCode") + + print("\nVerifying generated project state...") + verify_workspace(sandbox, args.workspace, timeout=120) + return 0 + finally: + if sandbox is not None and not args.keep_alive: + try: + sandbox.kill() + print("\nSandbox killed.") + except Exception as exc: # noqa: BLE001 + print(f"Warning: failed to kill sandbox: {exc}", file=sys.stderr) + elif sandbox is not None: + print(f"\nSandbox kept alive: {sandbox_identifier(sandbox)}") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/opencode-integration/test_commands.py b/examples/opencode-integration/test_commands.py new file mode 100644 index 000000000..ebc6535f2 --- /dev/null +++ b/examples/opencode-integration/test_commands.py @@ -0,0 +1,51 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from env_utils import opencode_command + + +class CommandTest(unittest.TestCase): + def test_opencode_command_uses_headless_run_mode(self) -> None: + command = opencode_command( + "fix tests", + workspace="/workspace", + model="openai/gpt-4.1-mini", + title="cube-demo", + ) + self.assertIn("opencode run", command) + self.assertIn("--model openai/gpt-4.1-mini", command) + self.assertIn("--dir /workspace", command) + self.assertIn("--auto", command) + self.assertIn("--format json", command) + + def test_prompt_is_shell_quoted(self) -> None: + prompt = 'write file"; touch /tmp/pwned; echo "' + command = opencode_command( + prompt, + workspace="/workspace", + model="openai/gpt-4.1-mini", + ) + self.assertIn("'write file", command) + self.assertIn("touch /tmp/pwned", command) + self.assertTrue(command.endswith("'")) + + def test_continue_last_switch(self) -> None: + command = opencode_command( + "continue", + workspace="/workspace", + model="openai/gpt-4.1-mini", + continue_last=True, + ) + self.assertIn("--continue", command) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/opencode-integration/test_env_utils.py b/examples/opencode-integration/test_env_utils.py new file mode 100644 index 000000000..12ea308ee --- /dev/null +++ b/examples/opencode-integration/test_env_utils.py @@ -0,0 +1,140 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import sys +import unittest +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import env_utils + + +class EnvUtilsTest(unittest.TestCase): + def test_model_must_include_provider_prefix(self) -> None: + with patch.dict(os.environ, {"OPENCODE_MODEL": "gpt-4.1-mini"}, clear=True): + with self.assertRaises(SystemExit): + env_utils.opencode_model() + + def test_provider_is_model_prefix(self) -> None: + with patch.dict(os.environ, {"OPENCODE_MODEL": "openai/gpt-4.1-mini"}, clear=True): + self.assertEqual(env_utils.opencode_provider(), "openai") + + def test_provider_key_name_defaults_from_prefix(self) -> None: + self.assertEqual(env_utils.provider_key_name("openai"), "OPENAI_API_KEY") + self.assertEqual(env_utils.provider_key_name("deepseek"), "DEEPSEEK_API_KEY") + self.assertEqual(env_utils.provider_key_name("custom"), "CUSTOM_API_KEY") + + def test_require_provider_key_fails_when_missing(self) -> None: + with patch.dict(os.environ, {}, clear=True): + with self.assertRaises(SystemExit): + env_utils.require_provider_key("openai") + + def test_int_env_rejects_non_integer_values(self) -> None: + with patch.dict(os.environ, {"OPENCODE_EXEC_TIMEOUT": "slow"}, clear=True): + with self.assertRaises(SystemExit): + env_utils.int_env("OPENCODE_EXEC_TIMEOUT", 900) + + def test_build_env_omits_all_api_keys_for_vault_mode(self) -> None: + env = { + "OPENCODE_MODEL": "openai/gpt-4.1-mini", + "OPENAI_API_KEY": "sk-openai", + "ANTHROPIC_API_KEY": "sk-anthropic", + "HTTPS_PROXY": "http://proxy.local:8080", + } + with patch.dict(os.environ, env, clear=True): + result = env_utils.build_opencode_env(include_secrets=False) + self.assertNotIn("OPENAI_API_KEY", result) + self.assertNotIn("ANTHROPIC_API_KEY", result) + self.assertEqual(result["HTTPS_PROXY"], "http://proxy.local:8080") + + def test_build_env_includes_provider_keys_by_default(self) -> None: + env = { + "OPENCODE_MODEL": "openai/gpt-4.1-mini", + "OPENAI_API_KEY": "sk-openai", + } + with patch.dict(os.environ, env, clear=True): + result = env_utils.build_opencode_env() + self.assertEqual(result["OPENAI_API_KEY"], "sk-openai") + + def test_optional_preserves_empty_string(self) -> None: + with patch.dict(os.environ, {"OPENCODE_DISABLE_AUTO_UPDATE": ""}, clear=True): + self.assertEqual(env_utils.optional("OPENCODE_DISABLE_AUTO_UPDATE", "1"), "") + + def test_llm_host_prefers_base_url(self) -> None: + env = { + "OPENCODE_MODEL": "openai/gpt-4.1-mini", + "OPENCODE_BASE_URL": "https://llm.example.test/v1", + } + with patch.dict(os.environ, env, clear=True): + self.assertEqual(env_utils.opencode_llm_host("openai"), "llm.example.test") + + def test_base_url_uses_provider_specific_fallback(self) -> None: + env = { + "OPENCODE_MODEL": "openai/gpt-4.1-mini", + "OPENAI_BASE_URL": "https://openai-compatible.example/v1", + } + with patch.dict(os.environ, env, clear=True): + self.assertEqual( + env_utils.opencode_base_url("openai"), + "https://openai-compatible.example/v1", + ) + + def test_config_json_contains_base_url_only_when_set(self) -> None: + env = { + "OPENCODE_MODEL": "openai/gpt-4.1-mini", + "OPENCODE_BASE_URL": "https://llm.example.test/v1", + } + with patch.dict(os.environ, env, clear=True): + config = env_utils.opencode_config_json("openai") + self.assertIn('"baseURL": "https://llm.example.test/v1"', config) + + def test_config_json_omits_provider_without_base_url(self) -> None: + with patch.dict(os.environ, {"OPENCODE_MODEL": "openai/gpt-4.1-mini"}, clear=True): + config = env_utils.opencode_config_json("openai") + self.assertNotIn('"provider"', config) + + def test_host_from_url_handles_empty_and_scheme_less_values(self) -> None: + self.assertEqual(env_utils._host_from_url(""), "") + self.assertEqual(env_utils._host_from_url("api.deepseek.com"), "api.deepseek.com") + self.assertEqual( + env_utils._host_from_url("https://api.example.test/v1"), + "api.example.test", + ) + + def test_load_local_dotenv_warns_when_no_file_exists(self) -> None: + stderr = StringIO() + missing_dir = Path("/tmp/cube-opencode-missing-env-test") + with patch.object(env_utils, "__file__", str(missing_dir / "env_utils.py")): + with patch("pathlib.Path.cwd", return_value=missing_dir): + with patch("sys.stderr", stderr): + env_utils.load_local_dotenv() + self.assertIn("Warning: no .env file found", stderr.getvalue()) + + def test_provider_inject_uses_bearer_header_for_openai_style_providers(self) -> None: + self.assertEqual( + env_utils.provider_inject("deepseek", "sk-test"), + [{"header": "Authorization", "secret": "sk-test", "format": "Bearer ${SECRET}"}], + ) + + def test_provider_inject_uses_anthropic_headers(self) -> None: + self.assertEqual( + env_utils.provider_inject("anthropic", "sk-ant"), + [ + {"header": "x-api-key", "secret": "sk-ant", "format": "${SECRET}"}, + { + "header": "anthropic-version", + "secret": "2023-06-01", + "format": "${SECRET}", + }, + ], + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/examples/opencode-integration/test_opencode_common.py b/examples/opencode-integration/test_opencode_common.py new file mode 100644 index 000000000..234147c41 --- /dev/null +++ b/examples/opencode-integration/test_opencode_common.py @@ -0,0 +1,97 @@ +# Copyright (c) 2026 Tencent Inc. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import os +import sys +import unittest +from dataclasses import dataclass +from io import StringIO +from pathlib import Path +from unittest.mock import patch + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from _opencode_common import ensure_success, redact_secrets, run_command, stream_writer + + +@dataclass +class FakeResult: + stdout: str = "" + stderr: str = "" + exit_code: int = 0 + + +class EnvOnlyCommands: + def __init__(self) -> None: + self.kwargs = {} + + def run(self, command: str, *, env: dict[str, str] | None = None): + self.kwargs = {"command": command, "env": env} + return FakeResult() + + +class FakeSandbox: + def __init__(self) -> None: + self.commands = EnvOnlyCommands() + + +class CommonHelperTest(unittest.TestCase): + def test_run_command_uses_env_keyword_when_envs_is_not_supported(self) -> None: + sandbox = FakeSandbox() + result = run_command(sandbox, "true", envs={"OPENAI_API_KEY": "placeholder"}) + self.assertEqual(result.exit_code, 0) + self.assertEqual( + sandbox.commands.kwargs, + {"command": "true", "env": {"OPENAI_API_KEY": "placeholder"}}, + ) + + def test_redact_secrets_hides_known_env_values(self) -> None: + with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-secret-value"}, clear=True): + self.assertEqual( + redact_secrets("provider key is sk-secret-value"), + "provider key is ", + ) + + def test_stream_writer_redacts_known_env_values(self) -> None: + stream = StringIO() + with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-stream-secret"}, clear=True): + stream_writer(stream)("stdout sk-stream-secret\n") + self.assertEqual(stream.getvalue(), "stdout \n") + + def test_redact_secrets_replaces_longest_values_first(self) -> None: + env = { + "A_API_KEY": "sk-foo", + "B_API_KEY": "sk-foobar", + } + with patch.dict(os.environ, env, clear=True): + self.assertEqual(redact_secrets("value sk-foobar"), "value ") + + def test_redact_secrets_uses_secret_name_suffixes(self) -> None: + env = { + "CSRF_TOKEN_EXPIRY_SECONDS": "3600", + "SESSION_TOKEN": "secret-token", + } + with patch.dict(os.environ, env, clear=True): + self.assertEqual( + redact_secrets("ttl 3600 token secret-token"), + "ttl 3600 token ", + ) + + def test_ensure_success_redacts_stdout_and_stderr(self) -> None: + result = FakeResult( + stdout="stdout sk-secret-value", + stderr="stderr sk-secret-value", + exit_code=1, + ) + with patch.dict(os.environ, {"OPENAI_API_KEY": "sk-secret-value"}, clear=True): + with self.assertRaises(SystemExit) as raised: + ensure_success(result, "run command") + message = str(raised.exception) + self.assertIn("", message) + self.assertNotIn("sk-secret-value", message) + + +if __name__ == "__main__": + unittest.main()